concept

Boundary Type Leak

also called Entity Leak, Type Crossing

A type owned by one layer travelling out through another layer's interface - which silently makes the inner layer's structure part of the outer layer's public contract.

layered architectureboundariesapi contractormcoupling

A controller returns the ORM entity directly in the HTTP response. The project still has controller, service and repository packages, the tests pass, and nothing is broken. What has happened is that the database schema is now the public API: renaming a column renames a field every client reads, adding one publishes it, and changing a type is a breaking change for mobile clients nobody can force to upgrade.

Nobody decided that. A type crossed a boundary, and the moment a type crosses a boundary, the boundary is gone for that type, whatever the folders are called.

Why it matters

Layering is usually discussed as a dependency rule — the web layer may call the service layer, which may call the repository. That rule is necessary and insufficient, because a dependency pointing the right way can still carry the wrong type. The direction of the call is not the direction of the coupling; the type is.

The consequence is distributed in time and in people. The person who renames the column is in the persistence layer and has no visibility of the clients reading the serialised field. The failure surfaces as a mobile application version from eight months ago breaking, and the commit that caused it looks local.

There is a second, mechanical consequence. A persistence entity serialised directly carries its lazily loaded relationships, so the serialiser walks the object graph and issues queries during response rendering. A list endpoint returning 40 rows with two relationships each reviewed as one query and issues 120 requests to the database, adding 200 to 400 ms to a response that was meant to take 15 ms, and nothing in the controller shows it.

Implementation patterns

  • A distinct type at every boundary you care about. A request model and a response model owned by the web layer, populated from the domain rather than being the domain. The mapping code is the price of the boundary, paid once per field.
  • Enforce it in the build. A rule that the web package may not reference the persistence package, or that no type carrying a persistence annotation appears in a response signature. A rule enforced only in review has a half-life measured in staff turnover.
  • Generate the outer contract from a schema, where the stack allows it, so the contract is the artefact and the mapping is derived.
  • Allow primitives and value types across freely. The rule is about types whose definition belongs to another layer's concerns, not about every object.
  • Check the direction of data as well as calls. A repository that accepts a web-layer request model has the same problem in reverse.

Industry example

The pattern is the reason public API tooling and persistence tooling are separate in most mature stacks. OpenAPI-first workflows exist precisely so the response shape is defined independently of storage, and gRPC's generated message types serve the same purpose: the wire type is not the storage type, and nobody has to remember to keep them apart.

The widely documented counter-example is the "architecture sinkhole", named in Mark Richards's Software Architecture Patterns (2015): a layered application where most requests pass straight through every layer with no logic added. The sinkhole is usually diagnosed as too many layers, and the deeper symptom is that the layers contain no distinct types, so they have nothing to do but forward.

Failure scenarios

  • A column rename becomes a client break, and the author could not have known.
  • Accidental exposure. A column added for internal bookkeeping — a risk score, an internal status, a soft-delete flag — appears in the JSON because serialisation defaults to everything.
  • Query explosion during serialisation, from lazily loaded relationships walked by the serialiser.
  • Field-hiding annotations as the control, which puts the field list in two places and makes exposure the default for the next column someone adds.
  • A version pinned by a client you cannot reach, so the schema is now frozen by a device population rather than by a decision.

Trade-offs

Choose Gains Pays
Distinct types per boundary Schema free to change; exposure is explicit Mapping code, per field, maintained
Entity straight through Less code today Schema becomes the contract; exposure by default

The mapping code is the honest cost and it is small and boring. The reason teams skip it is that its benefit arrives later and is received by someone else, which is the same asymmetry behind most boundary erosion.

When not to use it

When there is exactly one client and it ships in the same release as the server. Internal admin screens, back-office tools and dashboards deployed together have no externally pinned contract, and a separate response model there buys typing rather than safety.

Also when the type is already a plain record with no persistence annotations and no relationships — something you would have written identically in both layers. Duplicating it to honour a rule is the kind of layering that produces nine files per change and earns layered architecture its reputation.

Interview question

Q: A team shows you a service where repository entities are returned directly from HTTP handlers. They point out that it has worked for three years and that mapping code is waste. Make the case for changing it, and tell me where you would agree with them.

What a strong answer covers: naming the specific cost rather than the principle — a column rename is a client-visible break and the author cannot see who reads the field · the serialisation query explosion as a concrete second failure · the exposure default, where a new internal column is published unless someone remembers to exclude it · the enforcement question, since a convention without a build rule will not survive staff turnover · and the genuine agreement: for a single client released with the server, or for a plain record with no annotations, the mapping is waste and they are right.

Quick check

Quiz: Why is "calls only go downwards" an incomplete layering rule? — Because a correctly directed call can still return a type owned by the inner layer, which makes that layer's structure part of the outer contract.

Flashcard: What one change makes a layer boundary real? — A distinct type at the edge, owned by the outer layer, plus a build rule that fails when anything else crosses.