Software Architecture · Architectural Styles
Layered Architecture and the Cost of Strict Layer Isolation
How an allowed-to-use relation constrains a layered system to one dependency direction, what sink layers permit, and what every pass-through boundary charges in mapping code.
A stack of four rectangles with arrows pointing down is the most reproduced diagram in software architecture, and it conveys almost nothing. What matters is not that there are layers but which references between them are legal. That set of legal references is the whole design, and the boxes exist only to name it. A system where the presentation layer may call the domain layer, which may call the persistence layer, has a different structure from one where presentation may call persistence directly, even though both are drawn identically.
Architecture description practice gives the relation a name: the allowed-to-use relation between elements. In a layered style the relation is a partial order over layers, and every permitted reference points from a higher layer to a lower one at the same or a lower index. The picture is a shorthand for that order.
Strict and relaxed versions of the same diagram
Two interpretations of the same four boxes are in common use, and they differ in cost.
Strict layering permits a layer to reference only the layer immediately beneath it. A request from presentation reaches persistence through every intermediate layer, and each intermediate layer must expose a method for the operation whether or not it contributes behaviour to it. In a four-layer system there are three boundaries to cross for any read or write, and the strict reading forbids jumping over any of them.
Relaxed layering permits a layer to reference any layer beneath it. Presentation may call persistence directly and skip the domain layer for operations where the domain has nothing to contribute, which is the common case for read-only queries that return a record unchanged.
The two readings pull in opposite directions on the same axis: substitutability against indirection. Strict layering makes a layer replaceable without touching its callers, because the callers never held a reference to anything below the adjacent layer. That property is worth something only when the layer is plausibly replaced, tested in isolation, or independently deployed. A domain layer that will never be swapped for another implementation does not need the protection; it does need to be called from somewhere, and strict layering forces every caller through a domain method that may do nothing but delegate.
Strict layering also forces the interface of each layer to be a projection of everything beneath it. If the persistence layer gains a query for recently modified records, the domain layer gains a pass-through method for it and the presentation layer gains a pass-through method on top of that. Two methods exist solely so that the call can travel downward. The code is not wrong; it is the deliberate price of the property.
Sink layers and the bottom of the order
A sink layer is a layer that no other layer references. Where the sink sits changes the shape of the system more than the count of layers does.
Placing the domain model at the bottom as a sink means presentation and persistence both reference it, and it references neither. Placing persistence at the bottom means the domain layer references persistence, and the domain model becomes an artifact of the storage schema: an entity class carries the annotations the mapper needs, and the database’s constraints leak upward into the type system. The first arrangement is what the hexagonal and onion styles formalize, and it is reached by inverting the reference at the persistence boundary rather than by moving boxes around.
The inversion is small in code. The domain layer declares the interface it needs; the persistence layer implements it. At compile time the arrow points from the implementation to the declaration, which is inward.
// domain layer — declares the capability, references nothing outward
public interface OrderRepository {
Optional<Order> findById(OrderId id);
void save(Order order);
}
record OrderId(String value) {}
record Order(OrderId id, String status) {}
The adapter that implements it sits in the persistence layer, and its compile-time reference points back at the declaration:
public final class JdbcOrderRepository implements OrderRepository {
private final DataSource dataSource;
public JdbcOrderRepository(DataSource dataSource) {
this.dataSource = dataSource;
}
@Override
public Optional<Order> findById(OrderId id) {
String sql = "SELECT id, status FROM orders WHERE id = ?";
try (Connection c = dataSource.getConnection();
PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, id.value());
try (ResultSet rs = ps.executeQuery()) {
// row mapping stays here; the domain type never sees a ResultSet
return rs.next()
? Optional.of(new Order(new OrderId(rs.getString("id")), rs.getString("status")))
: Optional.empty();
}
} catch (SQLException e) {
throw new IllegalStateException("load failed for " + id.value(), e);
}
}
@Override
public void save(Order order) {
String sql = "INSERT INTO orders (id, status) VALUES (?, ?) "
+ "ON CONFLICT (id) DO UPDATE SET status = EXCLUDED.status";
try (Connection c = dataSource.getConnection();
PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, order.id().value());
ps.setString(2, order.status());
ps.executeUpdate();
} catch (SQLException e) {
throw new IllegalStateException("save failed for " + order.id().value(), e);
}
}
}
Nothing in the interface names JDBC, a table, or a SQL type. That constraint, not the package layout, is what keeps the order pointing inward. The adapter details are the subject of Ports and Adapters, which takes the same inversion and applies it at every external boundary rather than only at storage.
What each boundary charges
The cost of a layer boundary has three components, and only the first is visible in the call graph.
The first is the call itself: a method dispatch and a stack frame per boundary. For an in-process call that cost is fixed and small, and in a system whose work is dominated by a database round trip it is unmeasurable. The cost matters only when a boundary sits on a path that runs millions of times per second, such as a per-message decode. Ordinary request handling does not.
The second is data translation. A layer that does not accept its neighbour’s types must map between its own and the neighbour’s, and mapping is proportional to the size of the object graph crossing the boundary. A query returning n rows that crosses two boundaries with distinct types allocates a new object per row per boundary, so the allocation count is 2n rather than n, plus the intermediate values used to build them. This is the component that grows with payload and is the one worth reasoning about. A boundary that passes the same type through unchanged avoids the allocation but loses the isolation the boundary was supposed to provide.
The third is transactional and policy demarcation. A layer that owns transactions must open and close them at a known point, and a boundary above it cannot span two transactions without an explicit unit-of-work object. The complexity here is not per-call but per-operation, and it is where most of the accidental difficulty in a four-layer application lives.
A useful test for whether a boundary earns its cost: name the substitution the boundary enables. If the answer is “a different implementation of this interface would be dropped in”, the boundary is load-bearing. If the answer is “no substitution is conceivable”, the boundary is a naming convention with a stack frame attached, and collapsing it into its neighbour removes both the mapping code and the pass-through methods without changing behaviour.
Skipping, bridging, and the common module that eats the order
Three failure modes account for most layered systems that stop being layered while still being drawn as layers.
Layer skipping is legal under relaxed layering and is often the right call, but it is invisible in the diagram. A controller that calls the repository directly for a read path and the service for a write path produces two dependency directions from the same layer, and the read path now encodes knowledge of the schema. When skipping becomes common, the domain layer is no longer a participant in most requests, and its invariants are enforced only on the write path. The asymmetry is the defect, not the skip.
Layer bridging is the case where a layer exposes an operation only to forward it to the layer beneath, so that callers can travel through it. Bridging is exactly what strict layering mandates, and it is defensible as the price of substitutability. It becomes a defect when the bridging method does work that belongs to a different layer, such as a presentation-facing method on the domain layer that formats a string for display. The formatting now has two homes.
The third failure is the shared utility module. Layers often need common helpers — logging, identifier generation, clock access — and the obvious place is a module at the bottom of the order that everyone references. That module is a sink, which is correct, until it needs a domain concept. The moment a date-range helper takes a domain type, the utility module references the domain layer and the partial order is broken. If the domain layer also references the utilities, the two form a cycle and no topological order of the layers exists at all. Tooling that computes strongly connected components over the module graph reports this immediately; the diagram never does. The common fix is to keep the shared module free of domain types, accepting some duplication, or to inject the capability as an interface rather than referencing a concrete helper.
What layering constrains and what it does not
The relation describes compile-time or deployment-time references. It says nothing about the runtime call graph, and the two diverge as soon as control flow becomes asynchronous. An event published by the domain layer and consumed by an adapter below it reverses the apparent direction: at compile time the publisher does not know the consumer, while at runtime the data flows outward. Styles built on that reversal, such as event notification, are not layered systems with a broken order; they are a different order in which the dependency arrow and the data-flow arrow no longer coincide.
Within a single process, strict layering is cheap to enforce and cheap to test: the substitution that a boundary enables is an in-process one, and a test can supply a fake adapter without a network. Once layers are split across process boundaries, the cost of each boundary changes by orders of magnitude, and the number of boundaries a request crosses becomes a design variable with a latency consequence rather than only a code-organization one.
References
- Documenting Software Architectures: Views and Beyond — Clements, Bachmann, Bass, Garlan — Addison-Wesley
- Software Architecture in Practice — Bass, Clements, Kazman — Addison-Wesley
- PresentationDomainDataLayering — Martin Fowler
- Patterns of Enterprise Application Architecture — Martin Fowler — Addison-Wesley