A controller returns the ORM entity straight to the HTTP response. The folder structure still shows controller, service and repository layers, and nothing is broken. What has already been decided for the team, and what is the test for whether a layer boundary is real?
Show the full answer Hide the answer
What has been decided
The database schema is now the public API. Renaming a column renames a field in the JSON every client reads. Adding a column exposes it, including the ones you did not mean to publish. Changing a type is a breaking change for mobile clients you cannot force to upgrade.
None of that was a decision anybody made in a meeting. It happened because an object defined by the persistence layer travelled all the way out through the HTTP response, and the moment a type crosses a boundary, the boundary is gone for that type regardless of what the folders are called.
The test for a real boundary
Name the types allowed to cross it, in each direction, and check that nothing else does. That is the whole test, and it is mechanisable: a build rule that the web layer may not reference the persistence package, or that no type annotated as an entity appears in a response signature.
Folder names are not a boundary. Neither is a class called Service that forwards every call. The
things that make a boundary real are:
- A distinct type at the edge — a request model and a response model owned by the web layer, populated from the domain rather than being the domain.
- A direction — calls go down, and nothing travels up except data in types the caller owns.
- An enforcement mechanism that fails the build, because a rule enforced only in review has a half-life measured in staff turnover.
Why this costs more later than it looks now
The mapping code people object to is the price of the boundary, and it is paid once per field. The alternative is paid on every schema change forever, and it is paid by people who cannot see the coupling: a database migration becomes an API versioning problem, and the person renaming the column has no way to know who is reading it.
There is also a concrete failure that follows. An entity serialised directly usually carries its lazily loaded relationships, so the serialiser walks the object graph and issues queries during response rendering. One list endpoint that looked like a single query in review becomes 240 queries in production, and the cause is invisible in the controller's code.
When this is the wrong answer
For an internal tool with one client that you deploy together. If the consumer is a page in the same deployable, released at the same moment, the schema is not really public and a separate response model buys nothing but typing. Admin screens, back-office tools and internal dashboards are the honest cases.
Also when the "entity" is already a value type with no persistence annotations and no relationships — a plain record you would have written the same way in both layers. Duplicating it to honour a rule is the kind of layering that produces nine files per change and gives layered architecture its bad reputation.
Common weak answers
- "It violates separation of concerns." True and not useful. Say what it will cost: a column rename becomes a client-visible break, and nobody renaming the column will know.
- "Add a service layer." There already is one; it forwards. A layer that only forwards adds a file per change and no boundary. The missing thing is a type, not a tier.
- "Use a serialisation annotation to hide fields." It hides them from this response and keeps the coupling: the field list is now maintained in two places and the next developer adding a column must remember to exclude it. The default is still exposure.