11.1 Layering Goes Beyond Folder Separation: Responsibilities, Dependencies, and Transaction Boundaries
A 1,500-line module can handle HTTP, business rules, and SQL in one place, but every kind of change then crosses the whole file. Layering should isolate reasons to change and constrain dependency direction; creating three folders does not earn a victory lap.
Creating separate directories for controller, service, and repository only establishes naming. True layering occurs only when changes are isolated, dependency flow is constrained, and transaction boundaries are clearly defined.
This lesson addresses three key questions: what responsibilities belong to each layer, how data crosses layer boundaries, and where a business operation should begin and end.
Divide by Cause of Change
A typical business application can be divided into four distinct responsibilities:
| Layer | Responsible For | Should Not Be Responsible For |
|---|---|---|
| Presentation Layer | HTTP, CLI, or UI input/output; protocol validation; identity context | Business rules, SQL, cross-step transactions |
| Application Layer | Orchestrating a use case; defining transactions; invoking domain objects and ports | HTTP details, ORM mappings, core business logic decisions |
| Domain Layer | Business state, rules, invariants, and decisions | Framework lifecycle, network and database protocols |
| Infrastructure Layer | Technical implementation of databases, messaging, files, and third-party systems | Decisions about business policies |
This doesn't mean every project must have four layers. A simple CRUD application can merge the application and domain layers. When business rules become complex, a separate domain layer helps prevent business policies from being scattered across controllers, SQL statements, and message consumers.
The key question isn't "what does this class call itself," but rather "what change would force it to be modified." A change in HTTP status codes shouldn't require updating domain objects. A database driver upgrade shouldn't alter business eligibility rules.
How a Request Crosses Boundaries
Take the example of "player registers for a match":
POST /tournaments/{id}/entries
│ HTTP → command
▼
EntryController presentation layer
│
▼
RegisterEntry application: transaction and call order
├── TournamentRepository.findById(id) load
├── Tournament.register(playerId) domain decision
└── TournamentRepository.save(...) save
↑ port implemented by an infrastructure adapterThe diagram shows call order owned by the application layer; domain objects do not call concrete repositories. Presentation converts protocol data into a command. The application loads the object, invokes domain rules, then saves through a port.
These Java 21 / Spring Framework 6.2 project fragments assume domain models, TournamentRepository, TournamentNotFound, and EntryAuthorizer; save each public type separately. The authorizer uses the server’s authenticated context to check whether the actor may register the specified player. A request’s playerId names the target; it is not proof of identity.
public record RegisterEntryCommand(
java.util.UUID tournamentId, java.util.UUID playerId) {
public RegisterEntryCommand {
java.util.Objects.requireNonNull(tournamentId, "tournamentId");
java.util.Objects.requireNonNull(playerId, "playerId");
}
}
@org.springframework.stereotype.Service
public class RegisterEntry {
private final TournamentRepository tournaments;
private final EntryAuthorizer authorizer;
public RegisterEntry(TournamentRepository tournaments, EntryAuthorizer authorizer) {
this.tournaments = java.util.Objects.requireNonNull(tournaments);
this.authorizer = java.util.Objects.requireNonNull(authorizer);
}
@org.springframework.transaction.annotation.Transactional
public void handle(RegisterEntryCommand command) {
authorizer.requireCanRegister(command.playerId(), command.tournamentId());
Tournament tournament = tournaments.findById(command.tournamentId())
.orElseThrow(TournamentNotFound::new);
tournament.register(command.playerId());
tournaments.save(tournament);
}
}RegisterEntry neither returns ResponseEntity nor accepts ORM entities as input. This example deliberately lets the application layer depend on Spring’s transaction annotation. A stricter core can put transaction handling in an outer decorator, as discussed in the next chapter.
The service must be Spring-managed, with transaction management enabled and a matching transaction manager and repository adapter. Transactions begin when calls pass through the proxy. The class is not final: it has no interface, so it needs a subclass proxy. Direct construction and same-object self-invocation in default proxy mode do not activate the annotation. See Spring’s transaction annotation documentation.
import java.util.UUID;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotNull;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
record RegisterEntryRequest(@NotNull UUID playerId) {}
@RestController
final class EntryController {
private final RegisterEntry registerEntry;
EntryController(RegisterEntry registerEntry) {
this.registerEntry = java.util.Objects.requireNonNull(registerEntry);
}
@PostMapping("/tournaments/{id}/entries")
ResponseEntity<Void> register(
@PathVariable("id") UUID id,
@Valid @RequestBody RegisterEntryRequest body
) {
registerEntry.handle(new RegisterEntryCommand(id, body.playerId()));
return ResponseEntity.noContent().build();
}
}The controller requires Spring MVC, JSON conversion, and a Jakarta Bean Validation implementation. @Valid with @NotNull rejects a missing player ID. The command also rejects nulls to protect non-HTTP entry points. Success returns 204 with no body.
Missing fields are protocol errors; a closed tournament is a business rejection. Spring argument validation maps the former to 400. The project must map domain rejection, absence, and authorization failure through exception handlers. Those handlers are not shown, so this is not a complete HTTP error contract.
Transaction Boundaries Belong to Use Cases
Transactions should typically encapsulate a single atomic business operation, not the entire web request, nor be scattered across individual repository methods.
For example, if enrolling a participant requires:
- Checking match status;
- Writing the enrollment record;
- Reserving a spot;
then the application service should keep these database operations in one transaction. This alone does not prevent overbooking: two transactions may both observe the last available slot. Use unique constraints, versioned updates, or locks for the specific invariant. Spring normally rolls back unchecked exceptions and Error; checked exceptions require appropriate rollback rules. Swallowing an exception will not make the transaction manager infer your intent. If external messaging is also needed, do not treat unreliable network calls as part of a database transaction. Instead, write the message to an Outbox within the transaction and publish it asynchronously. This issue is explored in Chapter 15.
Data Models at the Boundary
The same concept can be represented differently across various boundaries:
RegisterEntryRequestis a transport model, designed for JSON compatibility;RegisterEntryCommandis a use case input, serving application semantics;Tournamentis a domain model, responsible for maintaining invariants;TournamentRowis a persistence model, tailored for database schema alignment.
Small systems can intentionally reuse certain types, but should never treat reuse as a default. Serializing ORM entities directly to the client couples the database schema, lazy loading, and public APIs into a single monolithic dependency.
Common False Layering Patterns
Controllers Bypassing the Application Layer
When controllers directly access repositories, they bring business flow and transaction semantics back into the protocol layer. As a result, adding new message or batch entry points becomes limited to copying the same orchestration logic, leading to duplication and poor maintainability.
Services Are Just Forwarders
If every service method simply calls the corresponding repository method, adding a new layer provides no isolation or separation of concerns. Either merge these layers or restructure application services to reflect actual use cases rather than technical naming conventions.
Packaging by Technical Terms
Global use of controllers/, services/, and repositories/ spreads a single business capability across the entire codebase. As scale grows, prioritize organizing by business domain first, then break down each domain into layers:
tournament/
├── api/
├── application/
├── domain/
└── infrastructure/Only Allow Calls Between Adjacent Layers
The requirement to "pass through layer by layer" is not the goal. What matters is protecting boundaries of responsibility and dependency direction. It's common for presentation layers to call application layers, and application layers to use domain layers, this pattern makes sense. There's no need to artificially create meaningless proxy methods just to enforce a layering formality.
Completion Criteria
When designing a use case, you should be able to answer the following questions:
- Who translates external protocols into use case inputs?
- Who enforces business invariants?
- Where does a transaction begin and where does it end?
- Have the database or framework types crossed boundaries they shouldn't have?
- When the same use case is restructured to use a message-based entry point, which code can be reused unchanged?
The next lesson zooms in on the presentation layer, distinguishing between MVC, MVP, and MVVM. These patterns address structural issues related to interaction flow, not the overall layering of a backend system.
References
Martin Fowler, Presentation Domain Data Layering
Martin Fowler, Service Layer
Microsoft, Common web application architectures