Architecture patterns and when to use them
A practical guide to layered, hexagonal, vertical-slice, modular-monolith, service, event-driven, CQRS and migration patterns—and the constraints that justify each one.
A pattern is a response to a recurring force
An architecture pattern is a reusable way to organise responsibilities under a familiar set of constraints. It is not a complete architecture and it is not automatically good design. The same pattern can remove friction in one system and create ceremony in another.
Start with the force acting on the software: changing business rules, several external integrations, independent release needs, very different read and write workloads, or a legacy dependency that cannot be replaced at once. Choose the smallest pattern that addresses that force.
Patterns also operate at different levels. Layered and hexagonal architecture structure application code. A modular monolith or independently deployed services shape deployment and ownership. Event-driven integration changes how components coordinate over time. Comparing them as direct alternatives produces confused designs.
Layered architecture: useful when the flow is genuinely simple
A conventional controller, service and persistence structure is easy to teach and maps naturally onto many backend frameworks. It works well for applications where requests follow a predictable path and the business rules do not require several competing representations.
The boundaries should describe responsibilities rather than merely folders. Transport code validates and translates HTTP concerns, application code coordinates behaviour, and persistence code owns database interaction. A service class that simply forwards every call to a repository is a layer without a useful responsibility.
Use layers when they make the direction of dependency obvious. Move away from a rigid horizontal structure when every feature requires coordinated edits across many large shared layers or when domain rules become coupled to framework and persistence types.
- Good fit: straightforward request-and-response applications
- Advantage: familiar flow and low conceptual overhead
- Risk: pass-through layers and responsibilities spread by technical type
- Warning sign: a small feature touches every shared layer
Ports and adapters: protect important rules from changing edges
Hexagonal architecture places application behaviour behind ports and implements external concerns through adapters. An HTTP controller, database repository and third-party API client depend on application contracts rather than defining the core model themselves.
This is useful when business behaviour needs to remain stable while transport, persistence or integrations change independently. It also enables focused tests through explicit ports without requiring every test to start a framework or database.
Do not create an interface for every class. A port should represent a meaningful capability needed or offered by the application. If the application is a small data-entry service with one stable database and very little behaviour, several adapter layers may cost more than the change they protect.
final case class AccountId(value: String)
final case class Balance(value: BigDecimal)
trait AccountStore {
def find(id: AccountId): Future[Option[Balance]]
}
final class GetBalance(accounts: AccountStore) {
def apply(id: AccountId): Future[Option[Balance]] =
accounts.find(id)
}
// MongoDB and test implementations sit behind AccountStore.
// Play controllers translate HTTP at the outer edge.Vertical slices: organise around a change, not a technical layer
A vertical-slice architecture keeps the request, use case, validation and relevant persistence behaviour for one capability close together. Instead of navigating global controller, service and repository packages, an engineer follows one feature through its complete path.
This works well when features change relatively independently and shared horizontal layers have become coordination points. A slice can still use ports, domain types and common infrastructure; vertical organisation does not require duplicating every utility.
The risk is inconsistent decisions and duplicated rules when slices are treated as isolated mini-applications. Share stable technical mechanisms carefully, but keep business behaviour with the capability that owns it. If several slices continually change together, the boundary may be artificial.
A modular monolith: stronger boundaries without distributed operations
A modular monolith remains one deployable application while separating capabilities through explicit module interfaces. Internal classes and persistence details stay private to their module, and dependency rules prevent arbitrary access across boundaries.
It is often the strongest default for a growing product. Teams gain clearer ownership and smaller reasoning boundaries while retaining local calls, one deployment path and straightforward transactions. It can also reveal which modules genuinely need independent scaling later.
A collection of folders is not a modular monolith if every module reads every table or imports internal types. Enforce boundaries in the build where possible, keep module APIs narrow and assign ownership to data as well as code.
- Good fit: a product with several capabilities but one practical release unit
- Advantage: explicit boundaries without network failure modes
- Risk: boundaries exist only by convention
- Extract later only when a module has an independent operational reason
Independent services: use an operational boundary for an operational reason
An independently deployed service is valuable when a capability needs separate scaling, release timing, failure isolation or ownership. It is not a cleaner version of a class or module; it is another production system with its own configuration, monitoring, security and recovery needs.
Choose a cohesive capability with a team able to own it through production. Define its contract and data ownership before moving it. If two services must deploy together, write the same tables and call each other for most requests, the split has increased distance without creating independence.
Begin with a modular boundary and extract one proven constraint. The test is not whether the organisation can create a new repository. It is whether the resulting service can change, operate and recover with less coordination than before.
Event-driven integration: decouple time, not understanding
Event-driven integration lets a producer record that something happened without waiting for every interested consumer to complete. It is useful when several independent reactions follow one fact, work can happen later, or a temporary consumer failure should not block the initiating request.
The producer still owns the meaning and schema of the event. Consumers need to handle duplicates, delayed delivery, ordering assumptions and incompatible versions. Operational tooling must answer which event was produced, which consumer handled it and how failed work is recovered.
Do not use events merely to avoid defining an API or because asynchronous diagrams appear loosely coupled. A user waiting for an immediate answer may be better served by a direct call. If the business requires one transaction across every participant, asynchronous coordination changes the product semantics and needs explicit agreement.
- Good fit: independent reactions and work that may complete later
- Advantage: producers do not wait for every consumer
- Risk: eventual consistency, duplicates and difficult tracing
- Warning sign: nobody can state who owns the event or recovery
CQRS: separate read and write models only when their needs diverge
Command Query Responsibility Segregation separates the model used to make changes from the model used to answer reads. At its lightest, this can mean distinct command and query code within one application. It does not require separate services or databases.
CQRS becomes useful when complex invariants govern writes while reads need several denormalised views, different scaling or independently optimised queries. It can keep each model focused instead of forcing one representation to satisfy incompatible workloads.
For ordinary create, read, update and delete behaviour, separate models add mapping, synchronisation and more places for stale data. Use a direct query or purpose-built view before adopting a distributed CQRS design. The pattern should pay for the consistency and operational questions it creates.
Repository: isolate persistence decisions without hiding useful queries
A repository presents persistence through language meaningful to the application. It can centralise MongoDB or SQL access, protect mapping and make data ownership visible. It is useful when application behaviour should not construct database queries directly.
Avoid claiming that a generic repository makes databases interchangeable. PostgreSQL and MongoDB have different modelling, query and transaction capabilities. Reducing both to save, find and delete can conceal the operations the application actually needs.
Expose cohesive application queries and preserve the database strengths behind the boundary. Test important persistence behaviour against the real database integration; an in-memory test double cannot prove indexes, constraints, serialisation or query semantics.
Anti-corruption layer: translate at a boundary you do not control
An anti-corruption layer prevents an external or legacy model from spreading through the application. It translates identifiers, states, errors and transport details into concepts the receiving code owns.
Use it when an integration has different terminology, unstable payloads or rules that would otherwise leak into many features. Keeping translation in one place makes compatibility changes visible and protects the application from accidental dependence on irrelevant fields.
A pass-through wrapper provides little protection. The layer is useful when it makes a real semantic decision: validating external data, resolving mismatched states or presenting a stable contract to the rest of the application.
Strangler migration: replace behaviour in controlled slices
The strangler pattern moves selected behaviour from an existing system to a new implementation while both operate during the transition. Routing changes gradually, allowing the team to verify real behaviour without requiring a single replacement release.
Choose a narrow capability, establish the old behaviour and define which implementation owns each request and write. Observability and rollback matter because two implementations now exist. Temporary routing, compatibility and migration code need explicit removal conditions.
Use this pattern when a staged replacement materially reduces migration risk. Avoid it when the transition layer would remain indefinitely or when the proposed slices cannot own their data and behaviour independently. A bounded refactor inside the existing application may be safer.
Combine patterns only where each earns its place
A system might use vertical slices inside a modular monolith, ports around external integrations and a strangler approach for one legacy capability. That is coherent because each pattern operates at a different level and answers a named constraint.
Do not begin with a diagram containing every recognised pattern. Start from one important change and trace where uncertainty, coupling or operational risk makes it difficult. Introduce a boundary, verify whether it improves that change, and stop adding structure when the problem is solved.
Architecture remains a set of trade-offs maintained through code, tests, deployment and ownership. A pattern is successful when the next relevant change becomes safer or clearer—not when the repository resembles its textbook diagram.
Simple request flow and limited domain complexity
-> Layered architecture
Features change independently but deploy together
-> Vertical slices or a modular monolith
Core rules must survive changing external edges
-> Ports and adapters
A capability has an independent operational constraint
-> Consider a separately deployed service
Several reactions may happen later and independently
-> Consider event-driven integration
Old behaviour must be replaced without a big-bang release
-> Strangler migration
No measurable constraint
-> Keep the simpler design