Feature/infrastructure layer - #8
Merged
Merged
Conversation
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.
Phase 5 — Infrastructure persistence (build plan steps 24–29)
EF Core arrives and the domain entities become the persisted entities: a context and provider (24),
the conversions that make a rich domain storable (25), per-aggregate mappings (26), a migration that
builds the schema from nothing (27), the Phase 3 ports implemented over EF (28), and a read path
scoped to a tenant (29).
354 tests pass (324 unit, 30 integration); the solution builds under nullable + analyzers +
warnings-as-errors with zero warnings in Debug and Release, and
make check-contractsis clean.AppDbContext;AddPersistenceConversions/;AggregateRootConventions; theSaveChangesversion stampConfigurations/— one per aggregate;ContactInfoMapping;SkillSetMapping; snake_casePersistence/Migrations/—InitialSchemaand the model snapshotRepositories/— five ports over EF;UnitOfWork; scoped registrationTenantQueryFilters;Tenancy/TenantContextITenantContext; a secondPortTestsrule to go with itJob.SchedulableStatusesProgram.csfix.config/dotnet-tools.json;make migrate/make migration; migrations marked generatedgeography(Point,4326)for every point,timestamptzfor every instant,bigintcents for money,text[]for skills,intervalfor durations, everything snake_case.Decisions a reviewer should weigh in on
Loading is not construction (25 → 26) — the one I most want a second opinion on
EF cannot bind a complex property to a constructor parameter (
Cannot bind 'window' in 'Job(…)').Typed ids,
MoneyandGeoPointbind — they are converted scalars;TimeWindow,ContactInfoandcollections do not. So
Job,TechnicianandCustomerneed a private parameterlessconstructor, after which EF sets private setters and backing fields, which is what Doc 2 §6
describes anyway.
I gave one to all eight domain types, not the three that need it, so the rule holds everywhere:
the persistence layer never runs a domain constructor. The other five bind fine — verified by
deleting theirs and watching the suite stay green — but without one EF runs
Invoice's constructoron load, starting every invoice as a draft before overwriting it from the row. Harmless today, and
exactly the thing that stops being harmless the day someone raises an event in there. Five
constructors that appear to do nothing is the price.
Doc 2 says "owned types"; these are conversions and complex types (25)
An owned type is an entity type, and every value object here is a
readonly record struct, whichcannot be one — EF's successor for the idea is the complex type. Two of the three need not even be
that:
Moneyis its cents, andGeoPointis onegeography(Point,4326), which the step's ownparenthetical asks for and two doubles could not be. The axis order flips inside
GeoPointConverter— aGeoPointis (lat, lng) as people say it, aPointis (X, Y) — which isthe line here most worth a second reader.
TimeWindow's read-only properties are invisible to EF's property discovery: the step-5 decision(
{ get; }not{ get; init; }, so awithexpression cannot invert a window) meeting EF for thefirst time. It survives — naming
StartandEndexplicitly puts them in the model, and EF thenbuilds every window through the constructor that validates it.
Tenancy is a sweep, and it catches
Organizationtoo (29)A line in each configuration would be forgettable, and a configuration that forgot its filter passes
every test it has while one tenant reads another's jobs. Stated once and keyed on the type, it holds
for aggregates nobody has written yet; a type with two
OrgIdproperties throws at model buildrather than picking one. It catches
Organizationthrough its ownId, which is right — a tenantcan load itself and cannot list anybody else — at the cost of
IgnoreQueryFiltersin step 53'sseeder.
default(OrgId)matches no rows, so apath that forgot to establish a tenant returns an empty dispatch board with no error and nothing
pointing at the cause.
CanConnectandMigratequery no entity, so the health check andmigration path are unaffected.
Nothing stops a handler stamping the wrong
OrgIdonto a new row; step 32 is the first handlerthat has to get it right.
ITenantContextneeded an exemption from a step-19PortTestsrule — no port may mention anOrgId, because scope is meant to be ambient. It is the one port whose job is supplying thatscope, so
ExactlyOnePortSaysWhoseDataItIspins the exemption at exactly one: it fails if a secondport starts asking, and if this one ever stops answering.
Smaller calls
text[], and the comparer is the point (26). Rebuild the set with the defaultcomparer and nothing throws — matching silently becomes case-sensitive and the symptom is a job no
technician can be assigned to. I expected to lose
readonlyon_skills; EF writes readonlybacking fields.
jobs(org_id, status, window_start)is hand-written in the migration (27), becausewindow_startbelongs to a complex type andHasIndextakes members of the entity. Stable — thesnapshot never knew about it — but regenerating the initial migration would lose it, which
make migrationwarns about in its own help text.Job.SchedulableStatusesincludesDispatched(28), from the port's own wording: everythingnot "finished, abandoned, or already under way". The job is on a phone but nobody has set off.
That is the row worth arguing about; step 37 tests it against a real optimiser run.
under a seed and greedy insertion consumes these lists in order, so an unordered read presents as
the optimiser being non-deterministic — a long way from the missing
ORDER BY.jobs.location_idhas no foreign key (26 → 27). EF cannot express it (ServiceLocationisowned), and raw SQL in the migration would be a constraint nothing regenerates and nobody finds.
The exposure is recorded: a job can point at a removed location. Step 32 decides.
Program.cswas swallowingHostAbortedException(27) — how EF's design-time tools stop thehost — so every
dotnet efcommand would have exited non-zero. Onewhenclause..editorconfigmarks**/Migrations/*.csas generated (27), rather than reformatting everygenerated file forever and losing that argument the first time someone runs the tool.
Testing
Thirty integration tests against one shared PostGIS container, which the fixture migrates at the
start of the run using the migrations
make migrateapplies to the compose database.Migrate()throwsPendingModelChangesWarningon a model change no migration captures — verified by adding an indexand watching 20 of 21 tests fail. It runs on every test run, not a step someone can skip.
EF model — a filter that baked one id in at first use would pass any single-tenant test and leak
from the second request onwards. Every aggregate is checked; four of five is not four-fifths of a
tenancy story. It also simplified the older tests, which now own an
OrgIdeach.five ports, commits with one
SaveChangesAsync, and reads them all back. The rest are the querieswhose behaviour is a decision — schedulable statuses, the half-open horizon edge, plan ordering,
rollback — resolved through a real DI scope, so a wrong lifetime fails them.
SchemaTestsmigrates its own empty database. Not ceremony: the PostGIS image installs theextension into the database it creates at startup, so migrating the fixture's would pass whether
or not the migration enables PostGIS — found by deleting the line and watching everything stay
green.
Mutation-checked, each failing only what it should:
UseNetTopologySuite()GeoPointaxis orderHaveColumnTypeonGeoPointIsConcurrencyToken()/ the version stampOrdinalassignments(job_id)Job's materialisation constructorgistfromix_jobs_locationPendingModelChangesWarningEnRoutetoSchedulableStatusesORDER BYfrom the plan querydefaultinstead of throwingOrgIdparameterPortTeststenancy rulesThree things the table cannot show. The pass caught a bad test of mine —
RefusesToQueryWhenNoTenantHasBeenResolvedasserted against the fixture's test double, proving onlythat the double throws; it now builds a provider from
AddPersistencealone. It removed ratherthan proved
Navigation(…).HasField("_locations")and itsInvoicesibling, which changednothing when deleted because EF's convention already finds the backing field. And two things are
untested by design:
GetByJobAsyncusesSingleOrDefaultwhere the unique index makes a secondrow impossible, and Npgsql's refusal of non-UTC
DateTimeOffsetis verified but enforced at theedge (46/47) and in the seed data (53), not here.
What came out, and what is deliberately not here
UnconfiguredAggregates— the// TEMPORARY: removed in step 26file from step 24 — is gone withits one call, as is step 25's throwaway probe and with it the reason
AppDbContextwas unsealed; itis sealed again.
Schema.RecreateAsyncwent with step 27's migration.Nothing resolves a tenant from a principal yet (step 45), so
TenantContext.Resolvehas noproduction caller. No MediatR,
Resultor pipeline behaviours — step 30, where the transactionbehaviour will wrap the unit of work this phase provides. No feature slices, no seed data (53), and
no row-level security, which Doc 2 §8 names as later defence in depth.