What Teaching Code Owes Its Reader
The retrospective - nine declared omissions that are all defensible, a set of undeclared ones that all turned out to be live bugs, and the rule that falls out of the difference.
Seventeen parts ago I said the interesting question about GroupFlights was not what is missing — the README answers that in a bulleted list — but which absences are recorded, and which were forgotten. Having now read the whole estate, the answer is close to perfectly predictive, and it is the thesis of this series.
Every declared omission is defensible. Every undeclared one I found turned out to be a live defect.
The declared column
The README states nine things this system does not do. Authentication and authorisation are a naive header imitation. There is no external message broker; the in-memory dispatcher deliberately publishes with a delay and outside the transaction. There is no e-mail or notification gateway, only a console write. There is no payment integration, only a fake gateway that auto-pays after five seconds. The project contains only example unit tests, in order to contrast a model with public getters against a fully encapsulated one.
Every one of those held up under reading, and several were better than advertised.
The naive auth is load-bearing DevEx. Because identity is two headers, a .http scenario file is a complete runnable transaction — no login, no token, no refresh, no variable chain. accept-offer.http is four lines. Twenty of those files plus a fourteen-step README walkthrough are the estate's real onboarding surface, and they exist because the auth is fake. That is a trade, made deliberately, and it compensates for a Swagger document that cannot authenticate at all.
The five-second dispatcher delay is pedagogy. Part 14 argued this properly: fifty milliseconds would achieve identical transactional isolation and teach nothing. The delay plus the full-event log line makes eventual consistency watchable in a console.
Six test methods is a rhetorical device. Part 5 took the two domain test files apart, and the technique in the encapsulated one — assert on the emitted domain event, because there is no state to read — is worth stealing whole. Judge them as documentation that compiles, and they are good documentation.
That is what a declared omission buys you. Stated, it becomes a design position you can argue with. Unstated, it becomes a bug report.
The undeclared column
// src/Sales/.../CrossCuttings/WorkloadManagementEnforcementCommandHandlerDecorator.cs:27-36
public async Task HandleAsync(TCommand command, CancellationToken cancellationToken = default)
{
//We could check the appsettings.json settings here to enable/disable this feature on demand
var enabled = false;
if (enabled is false)
{
await _commandHandler.HandleAsync(command, cancellationToken);
return;
}
…
A fully built Scrutor decorator, wired into three command handlers from the composition root, enforcing that a cashier may only act on workloads assigned to them — dead behind a hardcoded local false, with a comment describing configuration that was never written. Everything below line 36 is unreachable, including the only throw site of NotAssignedToThisWorkloadException, which lives in Shared.Types as a framework exception with no reachable producer anywhere in the estate.
The same rule is enforced for real in Inquiries. So the estate has one context relationship switched off and its neighbour switched on, and nothing anywhere says which is intended.
// src/Sales/GroupFlights.Sales.Application/PaymentRegistry/PaymentRegistry.cs:5-6
//TODO: Przepisac na wlasciwe persistence
private static readonly Dictionary<Guid, PaymentRegistryEntry> _inMemoryRegistry = new ();
A TODO that reads “rewrite to proper persistence” and means “every payment-to-reservation correlation is lost on restart, after which PaymentCompleted is silently swallowed” (Part 7).
And the quietest one:
// src/Backoffice/.../Repositories/FileRepository.cs:21-25
public async Task<DocumentFile> GetFileByContractId(Guid contractId, CancellationToken cancellationToken)
{
return await _dbContext.Documents.SingleOrDefaultAsync(x => x.ContractId.Equals(contractId), cancellationToken);
}
DocumentFile.Owner is a UserId. It is written on contract generation, written again on signed upload, mapped, migrated and persisted. Grep for a read of it and there is none. GET /backoffice/contracts/{contractId} is ClientOnly, which under the naive scheme means "sends any well-formed GUID in X-UserId". Any client can download any contract by knowing its id. The column that would prevent it exists, is populated, and is never compared to anything.
Nothing in the README, the four ADRs, or a comment mentions it. It is the difference between “we did not implement authorisation” — which is declared — and “we implemented an ownership field and forgot to check it”, which is not.
Add the ones the earlier parts covered: six repository update methods missing their id predicate; a deadline-overdue handler that calls OnChangePayed() while OnPaymentOverdue() has zero callers; three “deadline not met” paths setting Fulfilled = true, one of them marking an unsigned contract as signed; publish-before-commit in the payment webhook; a rule named per-reservation and enforced per-user; every 404 returning 500.
Not one of those is mentioned anywhere in the repository's own documentation. Every declared gap is safe; every undeclared gap bites.
The rule
That asymmetry is not a coincidence, and it generalises past this repository.
A declared omission has been thought about. Somebody held it in their head long enough to write it down, which means they considered the blast radius, decided it was acceptable, and told you. The declaration is the evidence of the thinking.
An undeclared omission has not. The absence of a sentence is the absence of the consideration that would have produced the sentence. var enabled = false; is not a decision to disable workload enforcement; it is a decision deferred and then forgotten, which is why the comment beside it describes configuration in the conditional tense.
So the rule teaching code owes its reader is not “be complete” — completeness would ruin it. It is: if you left it out, say so, and say what it costs. Three lines in a README convert a bug into a lesson. The README already does this nine times, and it is the best thing in the repository. The failures are all in the places it stopped.
The corollary for a reader is a reading technique, and it is what this series has really been demonstrating: read the README as a claim sheet, then go and check. In this estate three of the four verifiable claims held exactly, one (Clean Architecture) leaked in four named places, and the gaps between statement and source were the highest-value material available. For a teaching repository the README is the contract with the reader, and a contract you can test is worth more than one you cannot.
What to steal
Ranked by how portable it is:
- Access control declared as data beside the route, as a required constructor parameter (Part 16). You cannot register an endpoint without stating who may call it, so the security audit is one method per module. Attach it as endpoint metadata rather than to a side dictionary and the whole design is sound.
- The two-class lifecycle (Part 3).
OfferDraft.RevealToClient()returns anOffer. Illegal operations become unrepresentable rather than guarded. Use it when the operation set changes; use a status flag when only the history changes. - Asserting on emitted domain events (Part 5). Couples the test to the published contract instead of a field name.
- Clock as a method parameter, not an entity dependency (Part 15). Keeps aggregates constructible with
new, which is what makes time-dependent domain logic testable without a container. - The information-disclosure boundary enforced by a type. Only
HumanPresentableExceptionmessages reach a client; everything else is logged and replaced. About fifty domain exceptions subclass it. Leaking a connection string requires deliberately subclassing a specific base — the type system does the work discipline usually has to. IDoNotMigrate(Part 9). A one-line marker solving a real read-model/write-model migration race, well named.- The hand-rolled unit of work in WorkloadManagement (Part 2). The model records changes; the repository replays them. Twenty lines, and the model never sees EF.
When to read this repo, and when not to
Read it if you want to see three persistence strategies scored against each other in one estate rather than argued about in the abstract; if you have ever been told to encapsulate your aggregates and want to know what that costs at the schema; or if you want a worked example of tactical DDD that is small enough to hold entirely in your head.
Do not read it as a production reference. There is no outbox, no dead-letter channel, no correlation id on any of the thirteen event types, no idempotent receiver, and — verified across all thirty-five projects — zero uses of BeginTransaction or TransactionScope. There is no Dockerfile, no CI, no health check, no structured logging, no retention policy, and one superuser connection string shared by all seven schemas. The deployment story is three README commands.
And do not read it as a licence to copy. There is no LICENSE file, no SPDX header in any of the 439 source files, and no licence expression in any project file. Public on GitHub, all rights reserved by default. Read it, learn from it, credit DevMentors — github.com/devmentors/group-flights-ddd and the course at domain-driven-design.net — and write your own.
The last word
The best thing this estate does is refuse to be consistent. Eight modules, three application architectures, three persistence strategies, two testing styles — and the discipline to let a 569-line module stay anaemic while a 5,056-line one gets four projects and nine domain events.
Most codebases cannot do that, because the shape stopped being a decision years ago. GroupFlights makes the decision eight times, writes down why four of them happened, and leaves the evidence where a reader can check it. Where it fails, it fails in the places it stopped writing things down.
If you take one thing from eighteen parts: the gap between a repository's documentation and its source is not noise around the real content. For teaching code, it is the content.
The companion series, GroupFlights — Strategy Before Code, reads the same estate from the strategic end — the ADRs, the context map, and the contracts that hold the eight modules apart.