One Domain, Three Dialects
A teaching repo that implements one airline-charter domain three ways on purpose - CRUD, CRUD-plus-rich, and Clean Architecture - and persists it three ways to match. Part 1 of a source-reading series.
Most codebases are monolingual by accident. Somebody picked a shape in week two — anaemic entities and a service layer, or aggregates and repositories, or handlers and DTOs — and eight years later every new module gets the same shape whether it earns it or not. The shape stops being a decision and becomes a habit, and the only way to find out whether it was ever right is to inherit the thing.
GroupFlights does the opposite. It is a 2022-era .NET 6 modular monolith of 439 C# files and 14,559 lines, and it implements eight modules of one domain — group airline charter sales — in three deliberately different application architectures, backed by three deliberately different persistence strategies. The README says so in plain terms, halfway down: depending on the complexity and class of problem being solved, the application architecture is CRUD, Clean Architecture, or CRUD-plus-rich-domain-model. That is not a retrospective apology for inconsistency. It is the point of the repository.
To be clear up front: I did not write GroupFlights. It is the companion repository for DevMentors' Polish-language course Domain-Driven Design “Pragmatycznie” — github.com/devmentors/group-flights-ddd, and the course itself at domain-driven-design.net. I am a source-reader. Everything in this series was read at commit a19b337; where the implementation is the story I will quote a short excerpt and name the file and line; everything else is fresh illustrative C# I wrote for the article.
One more thing, and it is binding rather than decorative: the repository has no licence file. No LICENSE, no COPYING, no SPDX header in any of the 439 source files, no PackageLicenseExpression in any of the 35 project files. Public on GitHub, all rights reserved by default. So this series names real classes, real file paths and real line numbers — those are facts about a public repository — and quotes source only in short attributed excerpts where nothing else would do. Nothing here is presented as reusable. Read the repo; do not vendor it.
The three dialects, and who speaks them
Eight modules are registered in Program.cs:19-26, one Modules.RegisterModule<T>() call each. Their shapes could not be more different:
| Module | Application architecture | Persistence | Lines |
|---|---|---|---|
| Sales | Clean Architecture, rich domain | aggregate serialised to jsonb |
5,056 |
| Postsale | Clean Architecture, fully encapsulated | relational over private fields | 3,548 |
| Inquiries | CRUD plus a thin rich model | relational | 1,715 |
| TimeManagement | CRUD | classic relational | 922 |
| Finance | CRUD | relational | 835 |
| WorkloadManagement | CRUD plus rich | relational | 580 |
| Backoffice | CRUD | relational | 569 |
| Communication | one class, one log statement | none | 106 |
Sales gets four projects — Domain, Application, Infrastructure, Api — because reserving group seats against airline offers with expiry windows, cashier buffers, haul-based fee policies and four interlocking deadlines is genuinely hard. Backoffice gets two projects and public setters because storing a PDF against a contract id is not. The estate is arguing that architecture is a per-problem decision, and the unit of decision is the module.
Whether it is right is a separate question, and this series spends eighteen parts on it. The short version: the classification mostly holds, and the one place it inverts is the most interesting thing in the repo.
Why the persistence contrast is the real spine
The three application architectures are the headline. The three persistence strategies are the part you can actually measure, and they are the arc this series is built around.
Sales serialises whole aggregates into a jsonb column. Its entire Sales_Initial migration produces three tables for four aggregate roots, roughly twenty-five value objects and nine domain events. Postsale maps a fully encapsulated aggregate — ten private fields, exactly one public member — onto relational tables through Property<T>("_fieldName") and nested owned types. Its migration produces eight tables for one aggregate, seventeen columns on the main one, fourteen of them named after private C# fields. TimeManagement does the boring thing: a class is a table, properties are columns, children are child tables.
Three tables versus eight is not a style difference. It is a bill, and both parties paid a different one. Parts 6 through 10 of this series follow that bill line by line, and they are written as one argument rather than five posts: what each strategy buys, what it costs, and — the question that decides it — which queries you can still write afterwards.
The punchline arrives in Part 10, and it is not about elegance. TimeManagement's DeadlineScheduler runs Where(d => d.Fulfilled == null) every five seconds against every unfulfilled deadline in the system. That query is trivial against a classic relational model, expressible against Postsale's private-field columns, and impossible against a JSON document — which is why Sales needs a promoted ContractId column, a whole side table called DeadlineRegistryEntries, and an in-memory dictionary just to answer "which aggregate owns this foreign id?".
Six test methods, and why that is a decision
Across 14,559 lines there are six test methods. One in Shared.UnitTests covering Email; one in Sales.Domain.UnitTests; two in Postsale.UnitTests; and a couple more scattered. If you came here for a testing exemplar, leave now.
But calling this repository under-tested misses what it is doing. The README frames the two domain test classes explicitly as a contrast — testing a model with public getters (OfferDraftTests) versus a fully encapsulated model with no access to fields or properties at all (ReservationChangeRequestTests). They exist to demonstrate a technique, not to protect the code. That is a rhetorical device, and it works: Part 5 of this series is entirely about how you assert on an aggregate that will not tell you anything, and it is one of the most useful things in the estate.
There is also a structural reason the estate has zero integration tests, and it is not laziness. Modules is a static class holding two static mutable dictionaries (RegistrationExtensions.cs:8-20), and RegisterModule calls Dictionary.Add. Boot a second host in the same test process — which is exactly what WebApplicationFactory does — and you get a duplicate-key ArgumentException before a single request runs. Two static fields are the reason an entire category of test does not exist here. That causal chain is itself worth the read.
The frame I am reading this with
The estate declares nine omissions in its README: no authentication or authorisation beyond two headers, no message broker, no e-mail gateway, no payment integration, only example unit tests, and so on. Every one of those is defensible, and every one is stated.
The interesting question, then, is not “what is missing” — the README already answered that. It is: which absences are recorded, and which were forgotten? That distinction turns out to be almost perfectly predictive. The declared omissions are all fine. The undeclared ones — a cross-cutting decorator switched off by a hardcoded var enabled = false;, a //TODO: rewrite to proper persistence on a static dictionary that loses payment correlation on restart, an Owner column written on every document and never checked — are all live defects. Part 18 states that as a rule.
Criticism in this series is earned and specific, and it comes with the author's reasoning attached wherever the source supplies it. This is teaching code written to be read, and quite a lot of it teaches well.
Where the series goes
- One Domain, Three Dialects — this post.
- CRUD, Rich, Clean — And Who Actually Got Which — the classification tested against source, and the inversion at the centre of it.
- Make the Draft a Different Class — illegal states made unrepresentable by a method that returns a different type.
- One Public Member, Sixteen Consequences — the encapsulation ledger, and the chain it starts.
- Asserting on Events When There Are No Getters — the two test files, side by side.
- The Aggregate as One Column — Sales'
jsonbstrategy and the reflection resolver that makes it work. - The Queries You Cannot Write — what the document costs, counted in promoted columns and side tables.
- When Private Fields Become Column Names — Postsale's encapsulated relational mapping.
- CQRS Arrived as a Consequence — a second POCO and a second
DbContexton the same table. - The Model the Scheduler Can Query — the third strategy, and the polling loop that justifies it.
- A Specification That Never Specifies Anything — a named pattern implemented against its own intent.
- At Most Once, and Sometimes Not at All — the dispatcher's real delivery guarantees.
- Publish Then Save, Save Then Publish — the same estate demonstrating both orderings.
- Five Seconds Is a Teaching Decision — the tick that makes eventual consistency visible, and what it costs.
- The ADR Said UTC and One Switch Unsaid It — time as a domain concern, undone in shared plumbing.
- Endpoints Declared as Data — the best idea in the mini-framework, honestly graded.
- Sixteen Concepts, Copied Once — extraction residue, and what each divergence reveals.
- What Teaching Code Owes Its Reader — the retrospective.
A companion series, GroupFlights — Strategy Before Code, reads the same repository from the other end: the ADRs, the context map, and the contracts. And because DevMentors also publish a second modular monolith that this corpus already covers in One Process, Three Modules, I will reference that series where the two estates touch rather than re-deriving it.
Next, the three-tier classification — and the module the README never mentions, which turns out to hold the only real aggregate in the repository.