Split the app into web, service, persistence, storage and foundation - #32
Merged
Merged
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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, withstoragea second outbound adapter andfoundationa leaf.persistenceandstorageimport nothing of ours butfoundation;foundationimports 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 anEntitysuffix 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 —Questioncarriesversionandstatus,QuestionResponsecarries neither;QuestionAttemptcarries the session token, the response drops it.Policy moved up, mechanism stayed down.
StudentQuestionQueryPolicyis now a service concept — what an audience may see.QuestionAccessoris the only place it's applied, injected by qualifier so the audience is chosen once in a field declaration.QuestionQueryRunneris 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=-1andsize=abcalike.PageableQueryBindernames the rule instead — 400 for a conversion failure, 422 for out of range — states the size ceiling back rather than clamping it, and refusessortoutright since the runners impose a total order.PageableConfigurationGuardstops the app booting if the two now-inert sort properties are set.Every content field is bounded at the entity and the column.
V10carries 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
page=-1,size=0,size=abcsizeabove the configured maxsort=…X-Session-TokenNothing consumes the API yet.
Testing
188 unit and component, 126 integration, 11 performance. No statement-count pin moved.
QuestionServiceAccessorCTis 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.mdneeds a rewrite: package layout, entity naming, policy placement, the retired*SpecificationFactoryITflavour, the pin-comment rule, and an explicit statement that editing an unmerged migration on a branch is fine.QuestionOriginRepositorywas deleted as unused. It returns with extraction in 1.2 and owes a repository IT for the@NotNullauthorship guard and thedocument_id-iff-EXTRACTEDinvariant.