Web Application Architecture Consulting
Architectural decisions are expensive to change: moving from monolith to microservices takes months. Consulting helps make the right choice before writing code or identify problems before they become critical.
Monolith vs Microservices: When to Choose What
Monolith — right choice for most startups and teams up to 10 developers:
- No inter-service communication overhead
- Easier debugging (one process, one log)
- Cheaper to maintain
- Modular monolith with clear boundaries — excellent starting point
Microservices justified when:
- Different parts need independent scaling
- Teams work isolated on different domains
- Different technology requirements (Python ML service, Go API)
- Throughput requires horizontal scaling of individual components
Intermediate option — Modular Monolith with service extraction as needed:
src/
modules/
auth/ # Bounded Context: authorization
domain/
application/
infrastructure/
billing/ # Bounded Context: payment
notifications/ # Bounded Context: notifications
shared/
kernel/ # Common primitives (Money, UserId)
infrastructure/ # DB, HTTP clients
API Layer Patterns
REST vs GraphQL vs tRPC:
// tRPC: type-safe RPC without code generation
// server/routers/users.ts
const usersRouter = router({
getById: publicProcedure
.input(z.object({ id: z.string().uuid() }))
.query(async ({ input }) => {
return db.user.findUnique({ where: { id: input.id } });
}),
create: protectedProcedure
.input(createUserSchema)
.mutation(async ({ input, ctx }) => {
// ctx.user — authenticated user
}),
});
// client/pages/users.tsx — types shared automatically
const { data } = trpc.users.getById.useQuery({ id: userId });
tRPC optimal for monolithic Next.js/Nuxt apps. GraphQL — for public API with different clients. REST — for external service integrations and open API.
Data Handling: Patterns
Repository Pattern with Prisma:
// domain/repositories/UserRepository.ts
interface UserRepository {
findById(id: UserId): Promise<User | null>;
save(user: User): Promise<void>;
findByEmail(email: Email): Promise<User | null>;
}
// infrastructure/prisma/PrismaUserRepository.ts
class PrismaUserRepository implements UserRepository {
constructor(private readonly db: PrismaClient) {}
async findById(id: UserId): Promise<User | null> {
const record = await this.db.user.findUnique({
where: { id: id.value }
});
return record ? UserMapper.toDomain(record) : null;
}
}
CQRS for Complex Domains:
// Commands — change state
class CreateOrderCommand {
constructor(
public readonly userId: string,
public readonly items: OrderItem[]
) {}
}
// Queries — read-only, can be denormalized
class GetOrderSummaryQuery {
constructor(public readonly orderId: string) {}
}
// Different read and write models — each optimized for its purpose
Caching: Strategies
// Cache-Aside (Lazy Loading)
async function getUser(id: string): Promise<User> {
const cached = await redis.get(`user:${id}`);
if (cached) return JSON.parse(cached);
const user = await db.user.findUnique({ where: { id } });
await redis.setex(`user:${id}`, 3600, JSON.stringify(user));
return user;
}
// Write-Through (synchronous cache update)
async function updateUser(id: string, data: UpdateUserDto): Promise<User> {
const user = await db.user.update({ where: { id }, data });
await redis.setex(`user:${id}`, 3600, JSON.stringify(user));
return user;
}
// Cache Invalidation by tags (via Redis)
// All caches for user invalidated at once
async function invalidateUserCache(userId: string) {
const keys = await redis.keys(`*user:${userId}*`);
if (keys.length) await redis.del(...keys);
}
Queues and Background Tasks
// BullMQ: typed tasks
interface EmailJobData {
to: string;
template: 'welcome' | 'password-reset' | 'invoice';
variables: Record<string, string>;
}
const emailQueue = new Queue<EmailJobData>('emails', { connection: redis });
// Producer (from main code)
await emailQueue.add('send', {
to: user.email,
template: 'welcome',
variables: { name: user.name }
}, {
attempts: 3,
backoff: { type: 'exponential', delay: 2000 }
});
// Consumer (separate worker)
const worker = new Worker<EmailJobData>('emails', async (job) => {
await emailService.send(job.data);
}, { connection: redis, concurrency: 5 });
What Architectural Consulting Includes
| Step | Contents | Time |
|---|---|---|
| Discovery | Business requirements, current pain points, team | 2–3 hours |
| Current Architecture Review | Code and schema analysis, if project exists | 1–2 days |
| Design | Component diagram, ADR, risks | 2–3 days |
| Documentation | Architecture Decision Records, C4 diagrams | 1 day |
| Q&A with Team | Clarifying uncertainties, alternatives | 2–4 hours |
Result — set of ADR (Architecture Decision Records) with justification for each decision, C4 diagrams (Context, Container, Component), and prioritized refactoring plan if project exists.
Architecture consultation for new project — 3–5 working days. Existing architecture audit — 5–10 working days depending on system size.







