Skip to content

After twin moves from Marten to EF Core; Marten twin becomes an experiment - #18

Merged
erikshafer merged 1 commit into
mainfrom
after-twin-efcore
Sep 9, 2026
Merged

erikshafer merged 1 commit into
mainfrom
after-twin-efcore

Conversation

@erikshafer

Copy link
Copy Markdown
Owner

The core after twin moves from Marten documents to EF Core through Wolverine's EF Core integration. The Marten twin moves intact to experiments/after-marten/ and keeps running the same scenarios there.

Why. The talk is about architecture (A-Frame, Railway Oriented Programming, pure functions, distance), not about the Critter Stack. With a document store on one side, the honest answer to "what changed between the twins?" included the persistence library, and a room that notices that stops listening to the argument. Both twins now use EF Core 10 + Npgsql against one PostgreSQL database, so what moves between them is the architecture and the mediator. What stays confounded, and the talk says so, is the web framework.

Baseline before the change is tagged kcdc-marten-baseline at 70a2b13.

What did not change

The HTTP contract is byte-identical and no scenario changed. Same folders, same file names, same endpoint methods, same guard order, the same pure Decide with the same MidpointRounding.ToEven arithmetic, PlaceOrder.Post still returning (PlacedOrder, NotifyBaker), [Entity(Required = true, OnMissing = OnMissing.ProblemDetailsWith404)] still loading GetCake and GetOrder, no SaveChanges in any endpoint. PublishCake.cs, GetCake.cs, GetOrder.cs and CouponValidation.cs are byte-identical to the Marten twin. Messaging is unchanged: same queue, same PublishMessage<NotifyBaker>().ToRabbitQueue(...).UseDurableOutbox(), same ListenToRabbitQueue, same AutoProvision(), and the envelope still commits in the same transaction as the order (verified in the generated code: one command batch, INSERT INTO after.orders and INSERT INTO after.wolverine_outgoing_envelopes).

The DbContext is never edited per feature

LayerCakeDbContext has no DbSet properties and no per-entity mapping: HasDefaultSchema("after") plus ApplyConfigurationsFromAssembly. Each table's IEntityTypeConfiguration<T> lives in its own feature file beside the type it maps (Cakes/Cake.cs carries Cake and CakeTable), and endpoints reach for db.Set<T>(). That is what keeps the scorecard line "on the order slice the after twin edited zero existing files" true after the move, and it is deliberate. The before twin's DbContext is edited by three of the four slices.

Order.Lines stays one jsonb column via OwnsMany(o => o.Lines, lines => lines.ToJson()): no child table, no foreign key, identical wire shape.

Schema, without a migrations folder

opts.UseEntityFrameworkCoreWolverineManagedMigrations() plus builder.Services.AddResourceSetupOnStartup(), so Weasel builds the tables this DbContext describes and Wolverine's envelope tables when the host starts. EnsureCreatedAsync was rejected on purpose: it does nothing at all when the database already exists, and the twins share one, so it would have been a silent no-op under Testcontainers and live. Seeding therefore moved into a SeedOnStartup hosted service registered after Wolverine (Development only). The before twin keeps its EF Core migrations.

Two silent failures worth knowing about

NotifyBakerHandler returning Store<BakerTask> compiled, ran, logged nothing and wrote no row. The generated code says why: // No explicit update necessary with EF Core without a Version property. Wolverine's EF Core storage-action applier treats Store as update-of-a-tracked-entity, not upsert, so a new row never lands. Separately, a handler returning an ISideEffect has no DbContext dependency, so AutoApplyTransactions never notices it and nothing calls SaveChangesAsync. Both symptoms look identical from outside.

So the baker notification is now AddBakerTask : ISideEffect returned by a pure [Transactional] handler. The side effect checks for an existing task by order id before adding, because the broker delivers at least once and EF Core inserts where identity-keyed upsert made redelivery harmless for free. The handler is still a pure function and the unit test still asserts on a returned value with no context, real or faked.

The three read endpoints are [NonTransactional] with AsNoTracking(), which is the EF Core way of saying what IQuerySession said before.

Results

suite result
dotnet test LayerCake.slnx 54/54 contract, 15/15 unit, 0 warnings
tests/LayerCake.ContractTests.Marten 27/27
tests/LayerCake.Slices.Marten.Tests 15/15
tests/LayerCake.ContractTests.CleanTemplate 6/10, unchanged, as committed

Root suite is 13.6s to 16.9s warm, 22.1s cold after dotnet clean. Both Marten projects are outside LayerCake.slnx, so root dotnet test is still exactly two hosts.

Live run against docker compose up -d: schema built and seeded on startup, /ping, /cakes, /cakes/{id}, /coupons/bday10 valid at 10 percent off, POST /orders with BDAY10 returning subtotal 68.00, discount 6.80, total 61.20, the baker task appearing on /baker/tasks?orderId=... after the message crossed RabbitMQ, and Swagger 200.

Numbers the deck re-sources

Per-slice after-twin file counts are unchanged path for path: 5 created / 1 edited, 3 / 2, 6 / none, none / 1.

raw non-blank
Before twin 2,140 1,775 (unchanged)
After twin, EF Core 844 707
After twin, Marten (now the experiment) 703 581

The ratio moves from about 3.0x to about 2.5x. The +141 raw is almost entirely the price of telling a relational store what a document store infers: Program.cs is one line shorter, the four table configurations are +70 / +62, NotifyBaker.cs is +23 / +21 and SeedData.cs +18 / +16 for the redelivery and re-seed checks that identity upserts made free. File-by-file table in the inventory.

LayerCakeDbContext is taken as a parameter by 9 methods in 7 files, or 8 in 6 counting feature code only.

Packages

WolverineFx.EntityFrameworkCore and WolverineFx.Postgresql added at 6.30.0, tracking the existing Wolverine pin; EF Core 10.0.4 and Npgsql 10.0.3 reused from the before twin's pins. This is the one documented exception to the version freeze. Marten and WolverineFx.Marten stay pinned for the experiment.

Test and doc changes

Two shared test files changed, both twin bindings and no scenario: TwinHosts.cs (the after fixture wipes and re-seeds through the DbContext) and CakeScenarios.cs (AfterTwinCakes.InsertCakeDirectlyAsync inserts through the DbContext). Both gained AfterDb / BeforeDb using-aliases because both twins now have a type called LayerCakeDbContext. No assertion, scenario name or abstract member changed.

The Marten experiment keeps its LayerCake.Slices namespaces; only the project and assembly are renamed to LayerCake.Slices.Marten, and tests/LayerCake.ContractTests.Marten aliases the assembly the same way the template host does, because otherwise every shared type name would be ambiguous.

Docs updated: docs/file-inventory.md (new dated section with the full breakdown), docs/build-log.md (dated entry), README.md, CLAUDE.md, one stale line in openspec/config.yaml, and a one-line pointer at the top of each docs/slices/ spec. openspec/specs/ is deliberately untouched: no built behaviour changed, so the archived specs still describe what ships.

…iment

Both twins now use EF Core 10 + Npgsql against one PostgreSQL database, so
the only things that differ between them are the architecture and the
mediator. The after twin runs EF Core through Wolverine's EF Core
integration; the Marten version moves intact to experiments/after-marten/
and keeps running the same scenarios there.

Shape is unchanged: same folders, same file names, same endpoint methods,
same guard order, same pure Decide with the same rounding, same
(PlacedOrder, NotifyBaker) return, no SaveChanges in any endpoint. The HTTP
contract is byte-identical and no scenario changed.

The DbContext has no DbSet properties and no per-entity mapping. Each table
configures itself in its own feature file via IEntityTypeConfiguration and
ApplyConfigurationsFromAssembly, so adding a feature adds files and edits
nothing. Order.Lines stays one jsonb column via OwnsMany(...).ToJson().
No migrations folder: Wolverine-managed migrations plus resource setup on
startup build the schema, because EnsureCreated no-ops when the database
already exists and the twins share one.

NotifyBaker returns an AddBakerTask side effect from a pure [Transactional]
handler. Storage.Store<T> is an upsert on Marten but generates a no-op
against EF Core, and a handler returning a side effect is invisible to
AutoApplyTransactions; both fail silently. The side effect checks for
redelivery, which identity-keyed upsert made free.

Suite: 54/54 contract scenarios and 15/15 unit facts, 0 warnings, 16.9s
warm and 22.1s cold. Marten experiment 27/27 and 15/15. Template
experiment unchanged at 6/10.

Counts: before twin 2,140 raw / 1,775 non-blank, unchanged. After twin
844 / 707, up from 703 / 581. Per-slice file counts unchanged path for
path. Detail in docs/file-inventory.md and docs/build-log.md.
@erikshafer erikshafer self-assigned this Sep 9, 2026
@erikshafer
erikshafer merged commit 3fb503d into main Sep 9, 2026
1 check passed
@erikshafer
erikshafer deleted the after-twin-efcore branch September 9, 2026 18:14
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