Selecting Architecture Patterns — Matching Patterns to Problems
Match architecture patterns to real problems: layered architecture, vertical slice, modular monolith, microservices, event-driven, and CQRS — when each applies and which forces drive the choice.
Patterns Are Solutions to Forces
An architecture pattern is a named, reusable solution to a recurring problem.
It only makes sense in the context of specific forces.
Pattern selection process:
1. Identify the forces on your system (team size, scale, regulatory, integration needs)
2. Map forces to patterns that address them
3. Evaluate the costs of the pattern against the forces it addresses
4. Choose the pattern whose trade-offs you can live with
Using a pattern without understanding the forces it addresses
is cargo-cult architecture — you get the costs without the benefits.Layered Architecture
When to use:
→ Small team (1-5 developers)
→ CRUD-heavy application with limited business logic
→ Short timeline — need working software fast
→ Single deployment unit, single team
Forces it addresses:
→ Separation of concerns: UI, application logic, data access
→ Familiar to most .NET developers (Controllers → Services → Repositories)
Costs:
→ "God repositories" that grow unbounded
→ Tight coupling through layers: changing data model breaks service and controller
→ Horizontal layers don't map to business features — a "prescription" spans all layers
→ Logic tends to leak into controllers or repositories as complexity grows
Clinical example fit:
→ Good for: a simple admin portal to manage ward lists (CRUD, small team, low complexity)
→ Poor for: a prescription workflow with INR checks, approval states, and MHRA auditVertical Slice Architecture
When to use:
→ Medium complexity business logic
→ Team that prefers feature-by-feature development
→ Want to avoid the "layer tax" (changing a feature touches 4 projects)
Forces it addresses:
→ High cohesion: all code for "CreatePrescription" lives in one folder
→ Lower coupling: changes to one feature don't ripple into other feature files
→ Easy to add/remove features independently
Costs:
→ Duplication of infrastructure code across slices (solved by shared behaviors)
→ Harder to find global cross-cutting concerns
→ Requires discipline — developers still create shared utilities that become coupling
Clinical example fit:
→ Good for: clinical workflow features (CreatePrescription, ApprovePrescription, RecordInr)
→ Pairs well with MediatR pipeline behaviors for validation, logging, auditModular Monolith
When to use:
→ Medium-to-large system with clear bounded contexts
→ Team doesn't have microservices operational experience
→ Need bounded context isolation without distributed systems complexity
→ Plan to potentially extract services later
Forces it addresses:
→ Module isolation prevents accidental cross-context coupling
→ Single deployable unit — simple CI/CD, one App Service
→ Each module can evolve independently within the monolith
→ In-process communication (no network latency between modules)
Costs:
→ Modules must agree on deployment cadence (one deploy for all)
→ Modules share memory — a memory leak in one affects all
→ Isolation is enforced by convention and tests, not runtime boundaries
→ Cannot scale modules independently (scale the whole application)
Clinical example fit:
→ Ideal for: a hospital's clinical platform with Patients, Prescriptions, LabResults,
Billing — clear bounded contexts, 3-8 developer team, Azure App Service deploymentMicroservices
When to use:
→ Large system with multiple autonomous teams
→ Different parts need to scale independently
→ Different tech stacks are required per domain
→ Organization is structured around services (Conway's Law)
→ Operational maturity: Kubernetes, distributed tracing, service mesh
Forces it addresses:
→ True independent deployability — one team releases without coordinating with others
→ Independent scaling — LabResults service scales separately from Billing
→ Fault isolation — one service crashing doesn't crash the whole system
Costs:
→ Distributed systems complexity: eventual consistency, Saga pattern, network failures
→ Operational overhead: container orchestration, distributed tracing, health checks × N
→ Cross-service testing is harder than in-process module testing
→ Latency: what was a method call is now an HTTP request
Clinical example fit:
→ Appropriate when: 5+ teams, each owning a clinical domain
→ Premature when: a 4-person team that "wants to build like Netflix"CQRS
// Command Query Responsibility Segregation:
// Separate the write model (commands) from the read model (queries)
// Write model: normalised, rich domain model
public sealed class CreatePrescriptionHandler
: IRequestHandler<CreatePrescriptionCommand, Result<Guid>>
{
public async Task<Result<Guid>> Handle(CreatePrescriptionCommand cmd, CancellationToken ct)
{
var prescription = Prescription.Create(
PatientId.Of(cmd.PatientId),
MedicationName.Of(cmd.MedicationName),
DosageValue.Of(cmd.DoseAmount, cmd.DoseUnit));
await _repository.AddAsync(prescription, ct);
return Result.Success(prescription.Id.Value);
}
}
// Read model: denormalised, optimised for display
public sealed class GetPrescriptionListHandler
: IRequestHandler<GetPrescriptionListQuery, PagedList<PrescriptionListItemDto>>
{
public async Task<PagedList<PrescriptionListItemDto>> Handle(
GetPrescriptionListQuery query, CancellationToken ct)
{
// Direct SQL — no ORM overhead, no unnecessary JOINs
return await _db.QueryAsync<PrescriptionListItemDto>("""
SELECT p.id, p.medication_name, ps.full_name AS patient_name,
p.status, p.created_at
FROM prescriptions.prescriptions p
JOIN prescriptions.patient_snapshots ps ON ps.patient_id = p.patient_id
WHERE p.ward_id = @wardId AND p.status = 'Active'
ORDER BY p.created_at DESC
OFFSET @offset ROWS FETCH NEXT @pageSize ROWS ONLY
""", new { wardId = query.WardId, offset = query.Offset, pageSize = query.PageSize });
}
}
// When CQRS is worth it:
// → Read patterns differ significantly from write patterns
// → Queries are performance-critical
// → Write logic is complex (aggregates, domain rules)
// When it's not worth it:
// → Simple CRUD with no domain logic — CQRS adds overhead with no benefitEvent-Driven Architecture
When to use:
→ Actions in one part of the system should trigger reactions in others
→ Producers don't need to know who consumes their events
→ Eventual consistency is acceptable for downstream reactions
→ Need audit trail of everything that happened
Forces it addresses:
→ Decouples producer from consumer — adding a new reaction requires no change to the producer
→ Natural audit log — the event stream records everything that happened
→ Enables replay: replay events to rebuild state or populate a new read model
Costs:
→ Eventual consistency: reactions happen after the fact
→ Event contract versioning: events must be backward-compatible as schemas evolve
→ Harder to trace: "why didn't this side effect happen?" requires distributed tracing
→ Outbox pattern required for reliable delivery (events and DB writes must be atomic)
Clinical example:
→ PrescriptionApproved event triggers:
Pharmacy module: creates a dispense task
Billing module: creates a billing entry
Notification module: sends a confirmation to the ward nurse
→ None of these are synchronous — they happen after the approval is committedPattern Selection Quick Reference
Requirement → Pattern
────────────────────────────────────────────────────────────────────────
Simple CRUD, 1-3 devs, fast delivery → Layered architecture
Feature complexity, medium team → Vertical slice + MediatR
Clear bounded contexts, single deploy → Modular monolith
Multiple autonomous teams, scale → Microservices
Read/write model diverge → CQRS
Reactions to domain events → Event-driven with outbox
Full audit trail, time travel → Event sourcing
Multiple vendors for same operation → Strategy pattern
Distributed transactions → Saga (orchestration or choreography)Production issue I've seen: A 3-person team chose microservices for a clinical dashboard application because "that's how you build modern software." Six months in: 7 services, each with its own Kubernetes deployment, Helm chart, and CI pipeline. The team spent more time managing infrastructure than building features. A consultant was brought in. Their assessment: the application had 800 users, one team, and no requirement for independent scaling. It was rebuilt as a modular monolith in 6 weeks. The microservices architecture was correct for a different context — it was wrong for a 3-person team with a hospital's operational constraints. The pattern was cargo-culted, not chosen.
Key Takeaway
Pattern selection is a matching exercise: identify the forces on your system, then choose the pattern that addresses them at an acceptable cost. Layered for simple CRUD, vertical slice for feature-centric medium complexity, modular monolith for bounded contexts without distributed systems overhead, microservices when team autonomy and independent scaling are genuine requirements. CQRS when reads and writes diverge. Event-driven when producer-consumer decoupling matters. Never choose a pattern because it's fashionable — choose it because it addresses a real force.
Found this helpful?
Leave a comment
Have a question, correction, or just found this helpful? Leave a note below.