A tiny bakery selling layer cakes. Built in layers, served in slices.
LayerCake is the same small bakery API written twice, side by side, in one .NET solution:
| Where | Shape | |
|---|---|---|
| Before | src/before/ (4 projects) |
Clean Architecture: controllers, MediatR, FluentValidation, AutoMapper, DTOs, repositories over EF Core |
| After | src/after/ (1 project) |
Vertical slices: one feature per file on Wolverine.Http endpoints over the same EF Core |
One shared contract-test suite (tests/LayerCake.ContractTests) runs the exact same scenarios against both. If the suite is green twice, the two implementations behave identically. Everything else in the repo exists to make that comparison honest and easy to see for yourself. Both twins use EF Core against PostgreSQL, so the only things that change between them are the architecture and the mediator. Two further hosts sit outside the solution in experiments/, each running the same scenarios to test a claim rather than ship a feature. See The proof.
It is the companion repo for the talk "How I Gave Up Clean Architecture, and Why My Code Got Simpler" by Erik Shafer, first given at KCDC 2026 in Kansas City on September 10, 2026. The talk is headed for more user groups and conferences, and the repo keeps growing between runs: paying by card through Tendr (feature 5 below) was added after KCDC. You do not need to have seen the talk to use it. Slides and a recording will be linked here when they are published.
- Quick start
- What you are looking at
- Three features, one message, one vendor
- The HTTP contract
- Same feature, two shapes
- The proof: one suite, two hosts
- Running it live
- Repo tour
- Tech stack
- What this repo is not
- Further reading
- Contributing
- License
Prerequisites: the .NET 10 SDK and Docker (Docker Desktop or any engine Testcontainers can reach). Nothing else.
git clone https://github.com/erikshafer/layer-cake.git
cd layer-cake
dotnet build
dotnet testdotnet test is the whole point of the repo. For each twin the suite starts a throwaway PostgreSQL 17 container and a throwaway RabbitMQ 4 container (the baker notification crosses a real broker on both sides), applies the schema and seeds it, and starts a copy of Tendr, the fake card vendor, on a free local port. It runs every scenario against the Clean Architecture host, runs the identical scenarios against the vertical-slice host, and tears it all down. It also runs the after twin's pure-function unit tests and Tendr's own tests. No compose step, no leftover state. The same command runs in CI on every push.
Want to poke at the APIs in a browser instead? See Running it live.
The talk makes a claim: the ceremony that Clean Architecture asks of a .NET codebase (the layers, the interfaces over interfaces, the mediator, the mappers, the DTOs) buys less than it costs for most systems, and a feature-folder vertical-slice shape ends up simpler without giving up the things the layers were supposed to protect. A claim like that is easy to make with a strawman. So the rules for this repo were:
- The before twin is built earnestly. It is written the way a disciplined .NET team following the standard Clean Architecture template actually builds things. If it would embarrass a competent architecture review, it does not ship. It is not a caricature and it is not padded to inflate a file count. That sentence was tested rather than asserted: slice 001 was rebuilt on the Clean Architecture Solution Template (
dotnet new ca-sln) as generated and counted against both twins. The template differs in specifics (no repository, reads projected straight to DTOs, Minimal API endpoint groups instead of controllers) and matches in layering and ceremony (five MediatR behaviours on every request, a validator, a DTO with a mapping profile, an exception-to-ProblemDetails mapping, an EF configuration, a seed). The number and the file list are in the experiment section of the inventory; the idiom-by-idiom comparison is the 2026-09-06 entry indocs/build-log.md. - The after twin uses its stack's own idioms, not a translation of the before twin. Wolverine's compound handlers and
ProblemDetailsguards,[Entity]loading, its durable outbox, side effects as return values. It does not smuggle in a Result type or a hand-rolled mediator. - Both expose a byte-honest identical HTTP contract, and one suite proves it. Exact status codes, explicit content types, camelCase JSON, 404 for missing resources, the same problem-details shape on failures.
- Same database engine and the same ORM. Both twins talk to PostgreSQL 17 through EF Core 10 and Npgsql, in the same
layercakedatabase when run live: the layered twin owns thebeforeschema, the slice twin theafterschema. The comparison is about code shape, not about swapping databases or data-access libraries.
The after twin was originally written on Marten documents, and that version still exists and still passes the same scenarios (experiments/after-marten/). It was moved out of the solution the day before KCDC for one reason: with a document store on one side, the honest answer to "what changed?" included the persistence library, and that is not what the talk is arguing about. What moves between the twins now is the architecture and the mediator. What is left confounded, and the talk says so, is the web framework: controllers plus MediatR on one side, Wolverine.Http on the other.
A bakery publishes cakes, shoppers browse them, check a coupon, and place an order, paying now by card or at pickup; the baker gets a to-do entry for each order. Three features, each chosen because it showcases one claim, plus two extensions of the third: one puts its side effect on a real broker, the other sends its card to an outside service.
| # | Feature | What it demonstrates |
|---|---|---|
| 1 | Publish and browse cakes | The hook. One trivial write, traced through every layer of the before twin, then the same feature as a single file in the after twin. |
| 2 | Validate a coupon | Railway-oriented flow. A coupon is invalid, notYetActive, expired, or valid, always as a 200 envelope. The evaluation is one shared function that the next feature reuses, which is the in-repo answer to "how do slices share logic?" |
| 3 | Place an order | The A-Frame shape (load, decide purely, persist) and a reliable side effect: placing an order creates exactly one baker task. The after twin sends that through Wolverine's transactional outbox, written in the same EF Core transaction as the order, so no order without a task and no task without an order. |
| 4 | Notify the baker over RabbitMQ (extends 3) | What one message costs each shape. The baker notification crosses a real RabbitMQ queue in both twins. The before twin adds a port, an adapter, a hosted consumer, and a re-dispatch through MediatR, and publishes after its commit with no outbox. The after twin changes Program.cs and nothing else. |
| 5 | Pay with Tendr (extends 3) | What one call to an outside service costs each shape, and where the seam goes in a slice. An order may carry a card; both twins authorize the total with Tendr over HTTP before storing the order. The before twin adds a payment-gateway port in Application, a typed-client adapter with options and wire DTOs in Infrastructure, and two exceptions its filter maps to 402 and 503. The after twin adds one file (the typed client and its records) and, in PlaceOrder.cs, one more rung on the load leg and one more Validate guard. |
Step 4 adds no endpoint and changes no contract; the scenario that already proved exactly one baker task now proves it across the broker. Same broker on both sides; only the idiom changes. It is recorded as slice 004 in docs/slices/. Step 5 adds an optional card to the order and changes nothing for an order without one; it is slice 005.
Tendr (src/tendr/Tendr, port 42040) is a very small, fake card vendor that lives in this repo so the call from PlaceOrder is a real HTTP request over a real socket, live and in the contract suite, without a Stripe account. It takes an Idempotency-Key (both twins send the order id), answers from a four-entry test-card table (4242 4242 4242 4242 approves; 4000 0000 0000 0002 and 4000 0000 0000 9995 decline; anything else is unknown), and keeps each authorization as a short event stream in Marten. It is not a twin, it is not counted anywhere, and how it is built is not part of the argument; its README has the API. It shares the bakery's PostgreSQL database in its own tendr schema purely for convenience.
Design notes for each feature, including the contract clauses, the required structure of each twin, and the scenario list, live in docs/slices/.
Both twins serve exactly this. Request and response bodies are camelCase JSON; failures are application/problem+json with the reason in the body.
| Endpoint | Success | Failures |
|---|---|---|
POST /cakes |
201 + Location: /cakes/{id}, body { id, name, description, price, publishedAt } |
400 missing name or non-positive price, 409 duplicate name |
GET /cakes |
200 array of cakes |
|
GET /cakes/{id} |
200 cake |
404 |
GET /coupons/{code} |
200 always: { code, status }, plus percentOff only when status is valid |
never fails; an unknown code is status: "invalid" |
POST /orders |
201 + Location: /orders/{id}, body with priced lines, subtotal, discount, total, optional couponCode, placedAt, and payment: { authorizationId, status } only when a card was sent |
in this order: 400 empty lines or quantity below 1, 422 unknown cake ids (listed), 422 coupon not valid (status named), 402 card declined (reason named); 503 when a card was sent and Tendr did not answer within two seconds |
GET /orders/{id} |
200 same shape as the POST body |
404 |
GET /baker/tasks |
200 array of { orderId, summary, createdAt }, optional ?orderId= filter |
The two request bodies: POST /cakes takes { name, description, price }, and POST /orders takes { lines: [{ cakeId, quantity }], couponCode, card: { number } }, where couponCode and card are optional. An order without a card is paid at pickup and never calls Tendr. The card numbers that approve and decline are in Tendr's test-card table.
Seed data is identical on both sides: three cakes (Classic Yellow, Chocolate Stout, Lemon Chiffon) and three coupons whose windows are relative to today so they never rot:
| Code | Status today |
|---|---|
BDAY10 |
valid, 10% off |
SUMMER25 |
expired |
HOLIDAY30 |
not yet active |
docs/file-inventory.md records the actual files created for each feature as it was built, counted honestly (generated EF migrations excluded, files edited to wire a feature in listed separately). The numbers a slide can quote are the numbers in that file.
| Feature | Before twin | After twin |
|---|---|---|
| Publish and browse cakes | 19 files created, 4 edited | 5 files created, 1 edited |
| Validate coupon | 13 files created, 4 edited | 3 files created, 2 edited |
| Place order | 26 files created, 6 edited | 6 files created, none edited |
| Notify the baker over RabbitMQ | 8 files created, 6 edited | none created, 1 edited |
| Pay with Tendr | 11 files created, 8 edited | 1 file created, 3 edited |
| Whole twin, all C# source | 2,405 lines | 1,000 lines |
The shortest way to feel the difference is to read one feature in both. Publishing a cake in the before twin touches:
Domain/Entities/Cake.cs
Application/Cakes/CakeDto.cs
Application/Cakes/Commands/PublishCake/PublishCakeCommand.cs
Application/Cakes/Commands/PublishCake/PublishCakeCommandHandler.cs
Application/Cakes/Commands/PublishCake/PublishCakeCommandValidator.cs
Application/Common/Behaviors/ValidationBehavior.cs
Application/Common/Interfaces/ICakeRepository.cs
Application/Common/Mappings/CakeMappingProfile.cs
Application/Common/Exceptions/DuplicateCakeNameException.cs
Infrastructure/Persistence/Configurations/CakeConfiguration.cs
Infrastructure/Repositories/CakeRepository.cs
WebApi/Controllers/CakesController.cs
WebApi/Filters/ApiExceptionFilterAttribute.cs
And in the after twin it is src/after/LayerCake.Slices/Cakes/PublishCake.cs, shown here in full apart from the usings and the 201 response record:
public record PublishCake(string? Name, string? Description, decimal Price);
public static class PublishCakeEndpoint
{
// Wolverine runs ValidateAsync before Post and short-circuits with the
// ProblemDetails, so the endpoint method only ever sees the happy path.
public static async Task<ProblemDetails> ValidateAsync(
PublishCake command, LayerCakeDbContext db, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(command.Name))
return new ProblemDetails { Detail = "Name is required", Status = 400 };
if (command.Price <= 0)
return new ProblemDetails { Detail = "Price must be greater than zero", Status = 400 };
var nameTaken = await db.Set<Cake>().AnyAsync(c => c.Name == command.Name, ct);
return nameTaken
? new ProblemDetails { Detail = $"A cake named \"{command.Name}\" has already been published", Status = 409 }
: WolverineContinue.NoProblems;
}
[WolverinePost("/cakes")]
public static PublishedCake Post(PublishCake command, LayerCakeDbContext db)
{
var cake = new Cake
{
Id = Guid.NewGuid(),
Name = command.Name!,
Description = command.Description ?? string.Empty,
Price = command.Price,
PublishedAt = DateTimeOffset.UtcNow,
};
// AutoApplyTransactions commits this; no SaveChangesAsync in handlers.
db.Add(cake);
return new PublishedCake(cake.Id, cake.Name, cake.Description, cake.Price, cake.PublishedAt);
}
}Both twins are talking to the same EF Core, against the same PostgreSQL, with the same unique index on the cake name backing that 409. Neither side is hiding anything. The before twin does the same validation, the same duplicate check, the same persistence; it just does them across a domain entity, a command, a handler, a validator, a pipeline behavior, a repository interface, a repository, a mapping profile, a DTO, an EF configuration, a controller, and an exception filter. Both are worth reading. The talk is about what all of that indirection is buying.
tests/LayerCake.ContractTests is a single Alba + xUnit + Shouldly project. Every scenario is written once in an abstract class (CakeScenarios, CouponScenarios, OrderScenarios, PaymentScenarios, PaymentOutageScenarios), and two sealed subclasses at the bottom of each file bind it to a host: one boots the Clean Architecture twin, one boots the vertical-slice twin. The test runner sees 35 scenarios twice, 70 runs, and every one of them must pass.
A second, smaller project, tests/LayerCake.Slices.Tests, exists for the after twin only. It has no host, no database, and no mocks: 19 facts call the pricing function, the guard chain (card guard included), the coupon rule, and the baker handler directly and inspect what they return. It is not part of the parity proof. It is the exhibit for why the vertical-slice code is cheap to test, and dotnet test runs it alongside the contract suite.
experiments/ holds hosts that test a claim rather than ship a feature. Neither is in the solution, so dotnet test at the root is exactly the two-host proof above, plus Tendr's own tests (tests/Tendr.Tests, which prove the vendor's API on its own terms and belong to neither twin).
experiments/after-marten/ is the after twin as it stood on Marten documents, kept intact and still green: same slices, same scenarios, a document store instead of an ORM. It is what the after twin looked like before the twins were put on one ORM so that only the architecture moved between them.
dotnet test tests/LayerCake.ContractTests.Marten/LayerCake.ContractTests.Marten.csproj # 27/27
dotnet test tests/LayerCake.Slices.Marten.Tests/LayerCake.Slices.Marten.Tests.csproj # 15/15experiments/before-clean-template/ is the Clean Architecture Solution Template (dotnet new ca-sln, version 10.8.0) generated as-is, with slice 001 built on it the template's way, and tests/LayerCake.ContractTests.CleanTemplate/ runs the unchanged cake and ping scenarios against it. The test project is separate because one process can load one MediatR: the template ships MediatR 14, the before twin pins 12.5.0, and sharing a test bin broke every before-twin scenario before it ran. Run the experiment on its own (Docker is the only prerequisite):
dotnet test tests/LayerCake.ContractTests.CleanTemplate/LayerCake.ContractTests.CleanTemplate.csprojAs committed it is 6 of 10 green, and that is the template as shipped, not a bug in the scenarios: the four error scenarios get the right status codes but the template's exception handler writes application/json instead of application/problem+json, and its 400 bodies drop the errors dictionary that names the failing field. One line in the template's handler makes it 10 of 10; the committed state leaves it as generated. For the same three endpoints the template touches 9 files created / 8 edited, against 19 / 4 for the before twin and 5 / 1 for the after twin. The file list is the experiment section of the inventory, and the narrative (package graph, the hop trace, why the four are red, the one-line fix) is the 2026-09-06 entry in docs/build-log.md. The experiment is outside the scorecard and outside the proof.
The scenarios assert what the contract says, not what is convenient: exact status codes rather than "any 2xx", the Location header on creates, the raw body never containing percentOff unless the coupon is valid or payment unless a card was sent, the discount math to the cent, that a bad coupon with a declining card fails on the coupon without the card ever reaching Tendr, and that placing an order produces exactly one baker task. For that last one the suite polls the baker endpoint with a short timeout and does not know or care which twin does the work asynchronously.
Each twin gets its own PostgreSQL 17 and RabbitMQ 4 containers from Testcontainers, has its schema applied (EF Core migrations on the before twin, Wolverine-managed schema creation on the after twin), and is reset and reseeded per scenario class. Each twin also gets its own Tendr host, started on Kestrel at a free port against that twin's PostgreSQL container, so the card call crosses a socket without another container; the outage scenarios boot the twin pointed at a closed port instead. The two twins run in parallel, and every scenario starts from the same three cakes and three coupons.
The suite needs nothing but Docker, but to click around you want the twins running against a persistent database.
docker compose up -d # PostgreSQL 17 + RabbitMQ 4 (both twins use the broker)
dotnet run --project src/tendr/Tendr # http://localhost:42040, the card vendor
dotnet run --project src/before/LayerCake.WebApi # http://localhost:42010
dotnet run --project src/after/LayerCake.Slices # http://localhost:42020Tendr is only needed for orders that carry a card; without it running, a card order answers 503 and an order without a card still goes through.
Both twins apply their schema and seed data on startup in Development. Swagger UI is at /swagger on each. Both write to the one layercake database through EF Core: the before twin into the before schema via migrations, the after twin into the after schema via Wolverine's managed schema creation. docker compose down -v wipes everything.
Both twins expect RabbitMQ to be up (compose starts it): placing an order publishes the baker notification to a queue, and each twin consumes its own queue inside its own process. The after twin's default launch profile additionally turns on CritterWatch telemetry; set CritterWatch__Enabled=false or pass --no-launch-profile to run it without the console's queues. The broker's management UI is at http://localhost:15672 (guest/guest) if you want to watch the two layercake-*-baker-tasks queues.
The experimental template host runs live too. It drops and recreates whatever database its connection string names, so give it its own database name and never the compose layercake one:
dotnet run --project experiments/before-clean-template/src/Web -- --urls http://localhost:5113 "--ConnectionStrings:LayerCake.CleanTemplateDb=Server=127.0.0.1;Port=5432;Database=layercake_clean_template;Username=postgres;Password=postgres;"Point it at 42010 instead and the demo page's Before switch drives it for cakes; the page reports Location: null because the template's CORS policy does not expose the header (the raw response carries it), and the baker's board shows a 404 because the template has no such endpoint. Both are expected.
src/frontend/index.html is one static HTML file: inline CSS and JS, no build step, no server. With both twins running, open it straight from disk. It walks the whole journey (browse, publish, coupon, order with pay-now or pay-at-pickup, baker's board) against either twin through a Before/After switch, with a wire pane showing every request and response so the identical contract is visible to a human. It is a demo surface, not part of the proof; the contract tests are the proof.
src/monitor/LayerCake.CritterWatch is a third host: a CritterWatch console for Wolverine at http://localhost:42030. It exists to watch the after twin's messaging (the NotifyBaker cascade from placing an order, its handler execution, the outbox) during prep, Q&A, and debugging.
docker compose up -d
dotnet run --project src/monitor/LayerCake.CritterWatch
dotnet run --project src/after/LayerCake.SlicesThen exercise the after twin (the demo page is the easy way) and open the console. It keeps its own event store in a dedicated critterwatch PostgreSQL database; the compose file creates that database on a fresh volume, and an existing volume needs a one-off CREATE DATABASE critterwatch;. RabbitMQ carries the telemetry between the twin and the console, on top of the baker notification it already carries for both twins. In Development the console runs without a license key; outside Development it reads JasperFx:LicenseKey from user secrets (id layercake-critterwatch). dotnet test never touches the console or the compose broker; it starts its own.
src/
before/ the Clean Architecture twin
LayerCake.Domain/ entities, enums, base classes
LayerCake.Application/ commands, queries, handlers, validators, DTOs, mapping profiles, interfaces (including the messaging and payment ports)
LayerCake.Infrastructure/ DbContext, EF configurations, migrations, repositories, services, the RabbitMQ publisher, the Tendr payment gateway
LayerCake.WebApi/ controllers, exception filter, the RabbitMQ consumer, Program.cs (port 42010)
after/
LayerCake.Slices/ the vertical-slice twin (port 42020)
Cakes/ Cake.cs (the entity and its table mapping) plus one file per feature: PublishCake.cs, BrowseCakes.cs, GetCake.cs
Coupons/ Coupon.cs, CouponValidation.cs (THE shared rule), ValidateCoupon.cs
Orders/ Order.cs, BakerTask.cs, PlaceOrder.cs, NotifyBaker.cs, GetOrder.cs, GetBakerTasks.cs
Payments/ Tendr.cs (the typed client for the card vendor and its records)
Ping.cs, SeedData.cs, LayerCakeDbContext.cs, Program.cs (the DbContext has no DbSets and no mapping: every table configures itself in its feature file)
frontend/index.html the static demo page
monitor/LayerCake.CritterWatch/ the optional monitoring console (port 42030)
tendr/Tendr/ Tendr, the fake card vendor both twins call (port 42040); not a twin
experiments/
after-marten/ the after twin as it stood on Marten documents; same slices, same scenarios, outside the solution and the scorecard
before-clean-template/ the Clean Architecture Solution Template (dotnet new ca-sln) with slice 001 built its way; outside the solution and the scorecard
tests/
LayerCake.ContractTests/ one Alba suite, both hosts, 35 scenarios x 2 (a Tendr host per twin)
LayerCake.Slices.Tests/ 19 pure-function facts against the after twin only: no host, no database, no mocks
Tendr.Tests/ the vendor's own API scenarios, test-card facts, and one request/reply over Wolverine's HTTP transport
LayerCake.ContractTests.Marten/ the same 27 scenarios against the Marten experiment; its own project, outside the solution
LayerCake.Slices.Marten.Tests/ the Marten experiment's 15 pure-function facts; outside the solution
LayerCake.ContractTests.CleanTemplate/ the cake and ping scenarios against the template host; its own project, outside the solution
docs/
slices/ design record per feature: contract, required structure, scenarios
file-inventory.md honest per-feature file counts (the numbers above come from here)
build-log.md dated decisions made while building, for anyone wondering "why is it like this?"
frontend.md the demo page's design record
critter-stack-audit.md the after twin checked against JasperFx's own guidance, with the divergences it kept and why
openspec/ the change workflow used during the build; specs/ is what actually shipped
Directory.Packages.props every package version, pinned, with the reason for each pin
docker-compose.yml PostgreSQL 17 + RabbitMQ 4 for running the twins live
| Concern | Before twin | After twin |
|---|---|---|
| Runtime | .NET 10, C# 14 | .NET 10, C# 14 |
| HTTP | ASP.NET Core controllers | Wolverine.Http endpoints |
| Mediation | MediatR 12.5.0 | Wolverine handlers |
| Persistence | EF Core 10 + Npgsql, schema before |
EF Core 10 + Npgsql, schema after, through Wolverine's EF Core integration |
| Validation | FluentValidation via a MediatR pipeline behavior | Wolverine Validate methods returning ProblemDetails |
| Mapping | AutoMapper 14.0.0 | none |
| Messaging | RabbitMQ.Client publisher behind a port, BackgroundService consumer, no outbox |
Wolverine's RabbitMQ transport with the durable outbox, configured in Program.cs |
| Outside service | typed HttpClient adapter behind an IPaymentGateway port, options, wire DTOs, exceptions mapped to 402 and 503 |
typed HttpClient as a method parameter on the order's load leg, a Validate guard for 402 and 503 |
| Database | PostgreSQL 17 | PostgreSQL 17 |
| Broker | RabbitMQ 4 | RabbitMQ 4 |
| Tests | the shared Alba + xUnit + Shouldly suite | the same suite |
MediatR and AutoMapper are deliberately pinned at their final open-source releases, which is exactly where a lot of real layered codebases sit today. Versions were frozen before KCDC's dry runs and stay frozen between runs of the talk, so the comparison never drifts under a package update; the rationale for each pin, and for the few deliberate additions since, is a comment in Directory.Packages.props.
It is a laboratory built for a talk, not a starter template and not a production system. Deliberately out of scope, because each one is a different talk:
- Authentication and authorization.
- Event sourcing. Both twins are state-stored. (Tendr, the fake vendor, keeps its authorizations as event streams, but it is a prop, not a twin.) If the after twin makes you curious, that refactor is one step away: see CritterMart, which these features were lifted and simplified from.
- Microservices, messaging between services, or anything beyond one deployable per twin. The one RabbitMQ message here leaves and re-enters the same process on each side, and Tendr stands in for a third party, not a service of ours.
- Real payments: no capture, refund, void, webhooks, retries, or card data kept by either twin.
- A frontend framework or SPA. The demo page is a single static file and the talk never depends on it.
It is also not a claim that Clean Architecture is never the right call, or that the Critter Stack is the only way to write slices. It is one honest before-and-after you can clone, run, and argue with.
docs/slices/for the design of each feature, anddocs/build-log.mdfor every non-obvious decision made along the way.- Wolverine, Marten, and Alba documentation.
- CritterMart, the larger event-sourced system these slices come from.
- Tendr's README, for the vendor's API and test cards.
- The talk's slides and recording, linked here once they are published.
Issues and pull requests are welcome, especially from anyone who thinks the before twin is not being given a fair shake: that is the one critique the repo most wants to hear. Two rules keep the comparison meaningful:
- Both twins must keep passing the shared suite.
dotnet testgreen twice is the bar for any change. - A change to one twin that alters the HTTP contract needs the matching change in the other twin and in the scenarios.
The before twin follows conventional Clean Architecture idioms (constructor injection, repository interfaces, DTO mapping). The after twin follows Critter Stack idioms (static endpoints, compound handlers, the DbContext as a method parameter, side effects as return values). That asymmetry is the exhibit, so please keep each side in its own style.