ADR-0002: Clean Architecture with single-responsibility use cases
- Status: Accepted
- Date: 2026-05-30
- Scope: core (any backend service)
Context
A REST API grows messy when controllers hold logic, services become grab-bags,
and validation is scattered. We want strict, predictable dependencies and one
obvious place for each kind of rule.
Decision
Layered Clean Architecture with dependencies pointing inward:
Presentation → Application → Domain
↑
Infrastructure (implements application ports)
Domain depends on nothing. The request chain is fixed:
HTTP → Controller → WebService → UseCase → Service → Repository
- Controllers route only — no logic.
- WebServices map DTO↔entity and orchestrate use cases.
- Use cases have a single
execute(), one responsibility, wrapped
@Transactional; they return domain entities, never DTOs. - Services encode business rules, not repo pass-throughs — the method
name states the rule (e.g.findOwnedByUserOrThrowvs
findAccessibleByUserOrThrow).BaseServiceprovidesfindByIdOrThrow/save/delete.
Four-level validation, each with a home:
- Presentation: annotation-based on DTO records.
- Application: the validation strategy pattern — a
@Componentextending the
feature’s abstract base strategy, auto-discovered by theValidatorbean. Adding
a rule needs no changes to existing code. These produce 400 (InvalidBodyException
carrying aValidationResult). Never throwInvalidBodyExceptiondirectly in a
use case — create a strategy. - Domain: constructor invariants.
- Repository: sort/filter sanitization.
Operation guards (“can this run?”) stay inline in the use case and throw
409 (ConflictException). Exception hierarchy: EntityNotFoundException(404),
InvalidBodyException(400), InvalidCredentialsException(401), ConflictException(409).
Generic infrastructure delegates live in one place: a BaseRepositoryAdapter<E,R>
implements the common JPA pass-throughs once, so feature adapters stay thin.
Two adjacent rule sets have their own ADRs: schema changes
(0010-schema-changes-via-migrations) and DTO design (0011-dto-design-rules).
The authoritative, always-current operational detail lives in the backend repo’s
CLAUDE.md — this ADR records the decision and its rationale.
Consequences
- Each kind of change has one obvious location; new validation rules are additive.
- A little more indirection (Controller→WebService→UseCase→Service) in exchange
for testable, legible boundaries.
Related
- 0003-ddd-intent-methods — how domain entities enforce their own invariants
- 0001-status-as-source-of-truth
- 0010-schema-changes-via-migrations, 0011-dto-design-rules,
0012-profile-switched-outbound-gateways
Amendment — 2026-09-10: transactional web services, whitelisted sort input, a catch-all only for server faults
Three presentation-layer rules that both Spring backends reached, or tripped over, with
no written home. They surfaced while building dracomania’s players and decks.
1. A WebService method is a transaction
Every WebService method is @Transactional, with readOnly = true on reads, and the
use case joins that transaction. Both backends run with open-in-view: false. Without
the outer transaction, the persistence context closes when the use case returns, and DTO
mapping that walks a lazy association throws LazyInitializationException. Evidence: 33
of Expenses’ 39 WebServices, and dracomania’s PlayerWebService and DeckWebService.
- Never reach a write from a
readOnlymethod. The joined use case inherits
read-only, and its changes are not flushed: no error, and no row. - A method that streams, or waits on a slow external call, holds no transaction.
Expenses’ChatWebService(SSE, and an LLM tool loop) has none. Its use cases run
their own transactions, and nothing lazy is mapped outside them. - A WebService that maps nothing lazy would work without the annotation, but it breaks
the day one of its DTOs gains a lazy field. Add the annotation anyway.
2. Sort input is a whitelist, never a raw Pageable
This is what the repository: sort/filter sanitization level above means in practice.
- A controller does not bind a raw Spring Data
Pageableand pass its sort through.
Doing so lets a client sort by any entity property name, and an unknown or malformed
one reaches the query and surfaces as a 500. Accept sort as a closed set (an enum
of sortable fields, mapped to property paths) or not at all. Page number and size may
still bind. - A small, bounded list returns a plain
List, with no paging contract. - A mapped sort names the entity’s property path, not the column: an embedded field sorts
by its path (attributes.magic, notmagic). The id tiebreaker still applies
(0016-stable-pagination-tiebreaker).
Found in dracomania, whose catalogue reads first took a Pageable. Expenses is not yet
compliant: ten controllers bind a Pageable whose sort the client controls. Three are
user-facing (ExpenseController, IncomeEntryController, InstallmentSeriesController)
and seven are admin lists.
3. The catch-all is for server faults only
The global handler’s @ExceptionHandler(Exception.class) resolves before Spring MVC’s
default resolver does. It therefore also catches the framework exceptions that mean the
client erred, and answers them with a 500. Every such exception is mapped to its 4xx
explicitly. The ones met so far:
| Exception | Cause | Status |
|---|---|---|
HttpMessageNotReadableException | a body that is not JSON, or holds a value of the wrong type | 400 |
MethodArgumentTypeMismatchException | a path or query value of the wrong type (/decks/not-a-uuid) | 400 |
NoResourceFoundException | an unknown route | 404 |
HttpRequestMethodNotSupportedException | a known route called with a verb it does not accept | 405 |
AccessDeniedException, AuthorizationDeniedException | a refusal by method security, which happens after dispatch (0018-resource-access-session-scoped-or-actor-declared) | 403 |
Spring MVC’s other standard exceptions (a missing parameter, an unsupported media type)
fall under the same rule. A 500 in response to bad client input is a bug, however wrong
the request was. At the time of writing, Expenses maps only the 403 pair; dracomania maps
the two 400s, the 404 and the 405, and adds the 403 with its security.
Amendment — 2026-09-10: a write that must survive a refusal commits on its own
A use case runs in one transaction, and a refusal ends it by throwing. The throw rolls back
everything the transaction wrote, including a write the refusal depends on. The response
still looks right, which is why this is found by running the flow, not by reading it. Both
backends met it and fixed it the same way.
- A write that must outlive the use case’s rollback runs in
@Transactional(propagation = Propagation.REQUIRES_NEW). It is one of two kinds:- what a refusal rests on: a session family revoked because a spent refresh token came
back (0013-sessions-and-refresh-tokens §2), or a wrong attempt counted against a
reset code. Rolled back, the first leaves a stolen family alive, and the second makes
the attempt limit decorative; - a record of an attempt: an email sent, a webhook received, a job run, an AI call’s
usage, a login attempt. These matter most when the work failed.
- what a refusal rests on: a session family revoked because a spent refresh token came
- The method lives on a bean of its own. Spring applies
REQUIRES_NEWthrough the
proxy, so a call from inside the same bean silently joins the outer transaction. Name the
bean for its reason:SessionRevocationTransactional,PasswordResetAttempts, Expenses’
JobRunTransactional. - Keep the inner transaction to one short write, on rows the outer transaction has not
written. A row the outer transaction has already updated stays locked until it commits,
and the outer transaction is waiting on the inner one. - A write that returns normally (a logout, a revocation the user asked for) commits with
its caller and needs none of this.
Evidence: dracomania SessionRevocationTransactional and PasswordResetAttempts; Expenses
SessionRevocationTransactional, EmailDeliveryService, PaymentWebhookEventLogger,
JobRunTransactional, AiUsageService and LoginAttemptService.