Skip to content

Feature/infrastructure layer - #8

Merged
finn-abel merged 7 commits into
mainfrom
feature/infrastructure-layer
Aug 10, 2026
Merged

Feature/infrastructure layer#8
finn-abel merged 7 commits into
mainfrom
feature/infrastructure-layer

Conversation

@finn-abel

Copy link
Copy Markdown
Contributor

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-contracts is clean.

Infrastructure (24) Npgsql + NetTopologySuite; AppDbContext; AddPersistence
Infrastructure (25) Conversions/; AggregateRootConventions; the SaveChanges version stamp
Infrastructure (26) Configurations/ — one per aggregate; ContactInfoMapping; SkillSetMapping; snake_case
Infrastructure (27) Persistence/Migrations/InitialSchema and the model snapshot
Infrastructure (28) Repositories/ — five ports over EF; UnitOfWork; scoped registration
Infrastructure (29) TenantQueryFilters; Tenancy/TenantContext
Application (29) ITenantContext; a second PortTests rule to go with it
Domain (26, 28) a materialisation constructor on each aggregate; Job.SchedulableStatuses
Api (24, 27) composition-root wiring; EF Core Design; a one-line Program.cs fix
Repo (27) .config/dotnet-tools.json; make migrate / make migration; migrations marked generated
-- make up && make migrate, from an empty database
CREATE INDEX ix_jobs_location                   ON jobs USING gist (location);
CREATE INDEX ix_technicians_home_base           ON technicians USING gist (home_base);
CREATE INDEX ix_service_locations_point         ON service_locations USING gist (point);
CREATE INDEX ix_jobs_org_id_status_window_start ON jobs (org_id, status, window_start);
CREATE UNIQUE INDEX ix_assignments_job_id       ON assignments (job_id);

geography(Point,4326) for every point, timestamptz for every instant, bigint cents for money,
text[] for skills, interval for 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, Money and GeoPoint bind — they are converted scalars; TimeWindow, ContactInfo and
collections do not. So Job, Technician and Customer need a private parameterless
constructor
, 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 constructor
on 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, which
cannot be one — EF's successor for the idea is the complex type. Two of the three need not even be
that: Money is its cents, and GeoPoint is one geography(Point,4326), which the step's own
parenthetical asks for and two doubles could not be. The axis order flips inside
GeoPointConverter
— a GeoPoint is (lat, lng) as people say it, a Point is (X, Y) — which is
the 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 a with expression cannot invert a window) meeting EF for the
first time. It survives — naming Start and End explicitly puts them in the model, and EF then
builds every window through the constructor that validates it.

Tenancy is a sweep, and it catches Organization too (29)

// An entity type with a property of type OrgId is scoped by that property.
modelBuilder.Entity(clrType).HasQueryFilter(entity => entity.OrgId == context.CurrentOrgId);

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 OrgId properties throws at model build
rather than picking one. It catches Organization through its own Id, which is right — a tenant
can load itself and cannot list anybody else — at the cost of IgnoreQueryFilters in step 53's
seeder.

  • An unresolved tenant throws rather than defaulting. default(OrgId) matches no rows, so a
    path that forgot to establish a tenant returns an empty dispatch board with no error and nothing
    pointing at the cause. CanConnect and Migrate query no entity, so the health check and
    migration path are unaffected.
  • Filters constrain reads, not writes — Doc 2 §8 says "on the read path" for this reason.
    Nothing stops a handler stamping the wrong OrgId onto a new row; step 32 is the first handler
    that has to get it right.
  • ITenantContext needed an exemption from a step-19 PortTests rule — no port may mention an
    OrgId, because scope is meant to be ambient. It is the one port whose job is supplying that
    scope, so ExactlyOnePortSaysWhoseDataItIs pins the exemption at exactly one: it fails if a second
    port starts asking, and if this one ever stops answering.

Smaller calls

  • Skills are one text[], and the comparer is the point (26). Rebuild the set with the default
    comparer and nothing throws — matching silently becomes case-sensitive and the symptom is a job no
    technician can be assigned to. I expected to lose readonly on _skills; EF writes readonly
    backing fields.
  • jobs(org_id, status, window_start) is hand-written in the migration (27), because
    window_start belongs to a complex type and HasIndex takes members of the entity. Stable — the
    snapshot never knew about it — but regenerating the initial migration would lose it, which
    make migration warns about in its own help text.
  • Job.SchedulableStatuses includes Dispatched (28), from the port's own wording: everything
    not "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.
  • Every list query is ordered, as correctness not tidiness (28). Doc 2 §4 requires determinism
    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_id has no foreign key (26 → 27). EF cannot express it (ServiceLocation is
    owned), 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.cs was swallowing HostAbortedException (27) — how EF's design-time tools stop the
    host — so every dotnet ef command would have exited non-zero. One when clause.
  • .editorconfig marks **/Migrations/*.cs as generated (27), rather than reformatting every
    generated 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 migrate applies to the compose database.

  • No CI job checks migrations against the model, because EF does it better. Migrate() throws
    PendingModelChangesWarning on a model change no migration captures — verified by adding an index
    and watching 20 of 21 tests fail. It runs on every test run, not a step someone can skip.
  • Isolation is proved with two organizations live in one process, both through the same cached
    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 OrgId each.
  • Repositories are covered incidentally (TESTING.md): one flow stages five aggregates through
    five ports, commits with one SaveChangesAsync, and reads them all back. The rest are the queries
    whose 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.
  • SchemaTests migrates its own empty database. Not ceremony: the PostGIS image installs the
    extension 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:

Mutation Fails
Drop UseNetTopologySuite() the geography read
Swap the GeoPoint axis order the job round trip
Drop HaveColumnType on GeoPoint the geography-column test
Drop IsConcurrencyToken() / the version stamp the stale write
Unregister one typed-id converter the model build, naming the property
Skill comparer → Ordinal the technician round trip
Drop the unique index on assignments(job_id) the second-assignment test
Delete Job's materialisation constructor 11 of 14, on the constructor binding
Remove the composite index from the migration the dispatch-board index test
Remove gist from ix_jobs_location one case of the geography-index theory
Remove the PostGIS extension from the migration the fresh-database test
Change the model without a migration 20 of 21, on PendingModelChangesWarning
Add EnRoute to SchedulableStatuses the schedulable-jobs test
Make the horizon overlap inclusive the schedulable-jobs test
Drop the ORDER BY from the plan query the plan-ordering test
Remove the tenant filters all four isolation tests, and one repository test
Skip one aggregate in the tenant sweep the all-aggregates isolation test
Unresolved tenant returns default instead of throwing the unresolved-tenant test
Give a second port an OrgId parameter both PortTests tenancy rules

Three things the table cannot show. The pass caught a bad test of mine
RefusesToQueryWhenNoTenantHasBeenResolved asserted against the fixture's test double, proving only
that the double throws; it now builds a provider from AddPersistence alone. It removed rather
than proved
Navigation(…).HasField("_locations") and its Invoice sibling, which changed
nothing when deleted because EF's convention already finds the backing field. And two things are
untested by design: GetByJobAsync uses SingleOrDefault where the unique index makes a second
row impossible, and Npgsql's refusal of non-UTC DateTimeOffset is verified but enforced at the
edge (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 26 file from step 24 — is gone with
its one call, as is step 25's throwaway probe and with it the reason AppDbContext was unsealed; it
is sealed again. Schema.RecreateAsync went with step 27's migration.

Nothing resolves a tenant from a principal yet (step 45), so TenantContext.Resolve has no
production caller. No MediatR, Result or pipeline behaviours — step 30, where the transaction
behaviour 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.

make up && make migrate    # the schema, from nothing, in the compose database
make test                  # 354 tests; migrations applied to a throwaway container
make test-fast             # 324 unit
make check-contracts

@finn-abel
finn-abel merged commit 6538265 into main Aug 10, 2026
2 checks passed
@finn-abel
finn-abel deleted the feature/infrastructure-layer branch August 10, 2026 14:21
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