Architecture patterns
Architecture patterns operate one level above GoF: not how classes relate, but where the boundaries inside a system are drawn and which direction dependencies are allowed to point.
They matter more than the class-level catalogue because they are far harder to change later. A misplaced boundary is not a refactoring, it is a project.
The main entries
Section titled “The main entries”Layered. The default, and the one most often applied without a decision. Works when the layers genuinely differ in rate of change; degrades into a pass-through when they do not — three layers where every change touches all three is one layer with extra typing.
Ports and adapters (hexagonal). The domain in the middle, with no dependency on anything technical. Everything outside — HTTP, database, queue — is an adapter plugged into a port the domain defines. The property that pays: the domain’s tests need no framework and run in milliseconds.
Clean / onion. The same dependency rule, expressed with more prescribed rings. Whether the extra rings earn their names is a real question and depends on the size of the system.
Modular monolith. One deployable, hard internal boundaries. Frequently the right answer, and almost always the right first answer — it keeps the boundary question open while the cost of getting it wrong is a package move rather than a distributed migration.
Microservices. Independent deployability bought with distributed-system costs: network failure, eventual consistency, versioned contracts, distributed tracing. Worth it when teams need to release independently. Not worth it as a default, and never worth it before the boundaries are known.
Event-driven. Components react to what happened instead of being called. Decouples the producer from the consumers; makes the overall flow harder to follow — the event catalogue exists precisely to make it followable again.
CQRS and event sourcing. Separate the write model from the read models; store the events rather than the state. Powerful, expensive, and specific. The honest default is that most systems do not need either.
The dependency rule underneath
Section titled “The dependency rule underneath”Most of these are one idea in different clothing: dependencies point inwards, towards the thing that changes least. The domain does not know about the database. The use case does not know about HTTP. Where an outer thing must be called, the inner one declares an interface and the outer one implements it.
That rule is what makes a system testable, and what contract-first enforces at the boundaries between systems.
Worked example — the rule, in code
Section titled “Worked example — the rule, in code”The layered default has the domain call the database, so the arrow points outwards and everything inside it needs a database to be tested:
package com.acme.shop.ordering.domain;
import com.acme.shop.infrastructure.jpa.OrderJpaRepository; // outwards
public class OrderPricing { private final OrderJpaRepository repository; // a framework type
public Money quote(OrderId id) { OrderEntity entity = repository.findById(id.value()).orElseThrow(); return new Money(entity.getTotal(), Currency.getInstance(entity.getCurrency())); }}Inverting it costs one interface, declared by the side that needs it and named in the domain’s own words:
package com.acme.shop.ordering.domain;
public interface Orders { Optional<Order> withId(OrderId id); void save(Order order);}package com.acme.shop.ordering.infrastructure;
class JpaOrders implements Orders { // package-private: nothing depends on it private final OrderJpaRepository jpa;
public Optional<Order> withId(OrderId id) { return jpa.findById(id.value()).map(OrderRecord::toDomain); }}The domain package now imports nothing from infrastructure, which is a rule a
build can fail on:
@ArchTeststatic final ArchRule domain_depends_on_nothing_technical = noClasses().that().resideInAPackage("..ordering.domain..") .should().dependOnClassesThat() .resideInAnyPackage("..infrastructure..", "jakarta.persistence..", "org.springframework..");That test is the difference between having hexagonal architecture and describing it in a wiki. Without it the first import under deadline pressure goes unnoticed, and after twenty of them the pattern is a directory naming convention.
The names in Orders — withId, not findByIdEquals — come from the
tactical DDD rules, which is where the
two catalogues meet: this one says which way the arrow points, that one says what
the thing at each end is called.
Choosing one
Section titled “Choosing one”The choice is driven by what has to change independently, not by what is modern. Write the decision down with its alternatives and its cost — the choice is expensive to reverse, and in two years nobody will remember why it was made.
Status
Section titled “Status”Outline, with the dependency rule worked through because it is the one idea the other entries are variations on. The per-pattern pages, each with a rendering per stack, are not written yet.