Skip to content

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.

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.

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.

The layered default has the domain call the database, so the arrow points outwards and everything inside it needs a database to be tested:

Dependency pointing outwards — the domain knows about JPA
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:

ordering/domain/Orders.java — the port, owned by the domain
package com.acme.shop.ordering.domain;
public interface Orders {
Optional<Order> withId(OrderId id);
void save(Order order);
}
ordering/infrastructure/JpaOrders.java — the adapter, owned by the outside
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:

An ArchUnit test — the boundary becomes a gate rather than a convention
@ArchTest
static 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 OrderswithId, 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.

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.

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.