Skip to content

Split the app into web, service, persistence, storage and foundation - #32

Merged
jamiewmeldrum merged 3 commits into
mainfrom
feature/layer-segregation-and-validation
Aug 16, 2026
Merged

Split the app into web, service, persistence, storage and foundation#32
jamiewmeldrum merged 3 commits into
mainfrom
feature/layer-segregation-and-validation

Conversation

@jamiewmeldrum

Copy link
Copy Markdown
Owner

Layering refactor. No new features — the outside surface moves only where validation was previously silent.

What changed

Five packages, one import direction. web → service → persistence, with storage a second outbound adapter and foundation a leaf. persistence and storage import nothing of ours but foundation; foundation imports nothing of ours at all. That's the only property here a compiler can check, and it's swept for in the build.

Nothing is called domain. Logic lives in services, so there was no rich model for the name to describe. Entities take an Entity suffix and the bare noun goes to the type that crosses a boundary. Service records carry what is safe to expose rather than shadowing an entity — Question carries version and status, QuestionResponse carries neither; QuestionAttempt carries the session token, the response drops it.

Policy moved up, mechanism stayed down. StudentQuestionQueryPolicy is now a service concept — what an audience may see. QuestionAccessor is the only place it's applied, injected by qualifier so the audience is chosen once in a field declaration. QuestionQueryRunner is left with the specification, the stable (created_at, id) order and the two-query concept stitch, and no idea a policy exists.

Paging refuses what it can't use. Micronaut's binder answers 200 to page=-1 and size=abc alike. PageableQueryBinder names the rule instead — 400 for a conversion failure, 422 for out of range — states the size ceiling back rather than clamping it, and refuses sort outright since the runners impose a total order. PageableConfigurationGuard stops the app booting if the two now-inert sort properties are set.

Every content field is bounded at the entity and the column. V10 carries the migrations. The session token is bounded at four layers because it's client-supplied and indexed — unbounded, an oversized one failed at insert on Postgres's btree entry limit rather than at validation.

API changes

Sent Was Now
path id below 1 404 422, naming the rule
page=-1, size=0, size=abc 200, silently defaulted 422 / 400
size above the configured max 200, silently clamped 422 stating the ceiling
sort=… 200, silently ignored 422
blank or over-64-char X-Session-Token 422 / stored 422 at the edge

Nothing consumes the API yet.

Testing

188 unit and component, 126 integration, 11 performance. No statement-count pin moved.

QuestionServiceAccessorCT is the one worth reading: the qualifier is all that binds the service to the student policy, so it resolves the specification the service caused and asserts the restrictions it carries. It was verified against a policy with those restrictions stripped, and fails.

Fixtures insert their own rows rather than hiding behind helpers. The performance pins lost their "what this number is made of" comments — after one refactor those leave a correct number with a false explanation.

Follow-ups

  • CLAUDE.md needs a rewrite: package layout, entity naming, policy placement, the retired *SpecificationFactoryIT flavour, the pin-comment rule, and an explicit statement that editing an unmerged migration on a branch is fine.
  • QuestionOriginRepository was deleted as unused. It returns with extraction in 1.2 and owes a repository IT for the @NotNull authorship guard and the document_id-iff-EXTRACTED invariant.

Nothing below the controller speaks the wire contract, and nothing above the
service speaks JPA. Entities take an Entity suffix so the bare noun belongs to
the type that crosses a boundary rather than the one Hibernate manages;
projections keep a Projection suffix and stay inside persistence. Shared enums,
exception types and helpers move to foundation, which imports nothing of ours at
all. Persistence and storage are two outbound adapters at the same level, and
neither imports anything above it — the one property here a compiler can check.

Nothing is called domain. The name was tried and dropped: logic lives in
services, so there was no rich model for it to describe. Each layer owns the
types it hands out, and the service's are records that carry what is safe to
expose rather than shadowing an entity. Question carries its lock version and
status; QuestionResponse carries neither. QuestionAttempt carries the session
token; the response drops it rather than echoing a caller-supplied,
credential-shaped value back into bodies and logs.

Persistence answers the query it is handed and never asks who wants it or why.
StudentQuestionQueryPolicy moves up to the service as the statement of what an
audience may see; QuestionAccessor is the single place it is applied, built per
audience and injected by qualifier so the choice is made once in a field
declaration and a holder of the student accessor cannot make an admin read. What
is left in the runner is mechanism — the specification, the stable (created_at,
id) order, and the two-query concept stitch — with no policy and no idea one
exists. The accessor also owns the one guarantee no other tier can make, that an
id query matched at most one row: the runner cannot know that, the accessor can,
because it built the query. Accessors exist only where there is a policy to apply
or a cardinality expectation to enforce, so there is exactly one.

Paging stops coercing what it cannot use. Micronaut's binder answers 200 to
page=-1 and size=abc alike, which left paging as the one place a bad request
passed silently. PageableQueryBinder refuses instead and names the rule: a
non-numeric value raises the conversion error a bad filter already raises, and an
out-of-range one is a 422. The size ceiling is stated back rather than clamped,
because a client handed 50 rows after asking for 500 cannot tell a limit from the
end of the data. Sorting is refused outright — the runners impose a total order
so pages cannot straddle, so no client sort could ever be honoured — and
PageableConfigurationGuard refuses to start the app if the two now-inert sort
properties are set, comparing bound values rather than keys because relaxed
binding admits three spellings of each.

Every content field now states its cap at the entity and the column. The numbers
are sized against the threat they defend — an extraction bug pasting a document
into one row — rather than against a verbose author, and mark scheme is never
capped below the question it marks. The session token is bounded at 64 at four
layers, because it is client-supplied and indexed: unbounded, an oversized token
failed at insert on Postgres's btree entry limit instead of at validation.

Tests follow the same structure and the same rule. QuestionServiceAccessorCT is
the one that matters most: the qualifier is all that binds the service to the
student policy, and getting it wrong would widen every read silently, so the test
resolves the specification the service caused and asserts the restrictions it
carries. It was checked against a policy with those restrictions removed, and
fails. Fixtures insert their own rows rather than hiding behind helpers, and the
performance pins lost the comments describing what each number was made of —
after one refactor those leave a correct number with a false explanation.
CLAUDE.md described a codebase that no longer exists: api/ for controllers, root
dto/, entities without the Entity suffix, LinkedQuestion as a projection and a
runner that owned its policy. A standing instruction that is wrong is worse than
one that is missing, because the next session rebuilds what was removed.

Two rules change rather than move. Editing a migration is bounded by the merge
and not the commit, so an unmerged branch appends to its newest V file — a local
dev database is not somewhere that matters. And a performance pin no longer owes
a comment saying what its number is made of: that restates the code and goes
stale, leaving a correct number with a false explanation after one refactor.
@jamiewmeldrum
jamiewmeldrum merged commit 91074cb into main Aug 16, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant