Skip to content
kc@kumarChandrachooda.com:~$ cd /blog/eight-modules-three-architectures-one-domain && read --section="top" 0%
Architecture

Eight Modules, Three Architectures, One Domain

GroupFlights picks a different application architecture per module on purpose - and the module nobody singled out turns out to hold the only real aggregate in the estate.

By Kumar Chandrachooda 16 Nov 2025 7 min read
Three differently shaped modules sitting inside one process boundary

The most expensive architectural mistake I see in modular systems is uniformity. Somebody decides the team will “do Clean Architecture”, and six months later a module whose entire job is to store a row and read it back has a Domain project, an Application project, an Infrastructure project, four interfaces, a command, a command handler and a mapper, all to move a bool from a request body to a column. The ceremony is not free, and the module that needed it — the one with the actual invariants — got the same ceremony and no more.

Part 1 introduced GroupFlights as a repository that publishes its own decisions. The first of those decisions is stated in a single README bullet, and it is the one most estates never make explicitly. Translated: depending on the level of complication and the class of problem being solved, the application architecture is CRUD, Clean Architecture, or CRUD plus a rich domain model. Three architectures, chosen per module, on purpose.

Eight contexts, three shapes

The eight modules are registered in Program.cs:19-26 and each one owns a Postgres schema. Sorted by lines of C#, the estate is dramatically lopsided:

Module Shape Projects LOC
Sales Clean Architecture, rich domain 6 5,056
Postsale Clean Architecture, encapsulated domain 6 3,548
Inquiries CRUD with a validator 2 1,715
TimeManagement CRUD, classic relational 3 922
Finance CRUD 3 835
WorkloadManagement CRUD plus a rich model 3 580
Backoffice CRUD 3 569
Communication stub 3 106

Sales and Postsale carry 59 per cent of the code between them, and they are the only two modules with a .Domain project. The other six are <Module>.Api plus <Module>.Core — one assembly holding models, EF configuration, a repository and a service class. Communication's Core is four types.

The distinction is not decoration. Open Inquiries.Core and there is no aggregate anywhere: Inquiry, Inquirer, InquiredFlight, PassengersData and PriorityChoice are data holders, and every rule the module enforces lives in InquiryValidator.cs and InquiryService.cs. Open Sales.Domain and there are twelve folders under Offers/ and Reservations/ alone — factories, policies, specifications, exceptions, domain services. Both are correct for their module. Inquiries takes a form off the internet and decides whether it is worth a cashier's time; Sales runs a multi-week process with deadlines, payments, contracts and money.

The rule the estate encodes is that architecture is a per-module decision, and the unit of decision is the bounded context. That is a strategic claim wearing tactical clothes. If your context boundaries are wrong, per-module architecture choices amplify the error, because you now have two different sets of ceremony sitting on top of one confused concept. GroupFlights can afford it because the boundaries came out of ADR 03 and ADR 04 before the projects existed — parts 3 and 10 are those two documents.

The nominated showcase does not showcase what you think

The README nominates TimeManagement as the exemplar of one of the three persistence approaches — “classic relational model”, pointing at DeadlineConfiguration. It is a fair nomination for persistence. It is a misleading one if you read it as an architecture nomination, because TimeManagement.Core/Models/Deadline.cs is not a domain model at all. The last line of the class is:

public bool? Fulfilled { get; set; }

A public settable nullable boolean, tri-state by convention: null means outstanding, true means met, false means missed. Nothing in the type prevents a caller writing false over a true. Every rule about what a deadline may do lives in DeadlineService, and the model is a bag of init properties with one mutable setter. That is exactly right for a module whose job is to hold dates and shout when they pass, and it is the honest CRUD end of the spectrum. But it is data, not a domain.

Now open the module nobody nominated. WorkloadManagement.Core/Models/ActiveWorkloads.cs is 99 lines and it is the only genuine aggregate root in the estate outside the two Clean Architecture modules:

internal class ActiveWorkloads
{
    private List<CashierWorkloadAssignment> _workloadAssignments = new();
    private List<WorkloadAssignmentChange> _pendingChanges = new();
    public Guid Id { get; private set; }

    private ActiveWorkloads() {}

    internal ActiveWorkloads(List<CashierWorkloadAssignment> existingAssignments)
    {
        _workloadAssignments = existingAssignments;
        Id = Guid.NewGuid();
    }

    public IReadOnlyCollection<WorkloadAssignmentChange> PendingChanges => _pendingChanges;

Excerpt from ActiveWorkloads.cs:8-22, GroupFlights at commit a19b337. Read what it is doing.

  • The collections are private fields, not properties. No caller can add an assignment except through AssignWorkload, and that method walks the existing assignments enforcing four separate rules in one pass: no duplicate assignment, no exceeding the cashier's configured limit, no assigning a workload already held by another cashier.
  • _pendingChanges is a hand-rolled Unit of Work. The aggregate records what changed rather than mutating rows, and the repository drains PendingChanges afterwards. That is the Memento shape, in a module the README never mentions.
  • The public constructor is internal and takes the existing assignment set. The aggregate is reconstituted as a whole, checked as a whole, and saved as a whole. It has a real consistency boundary.

The inversion is worth naming plainly. The module the README singles out for its model has no model; the module it never singles out has the estate's cleanest aggregate. I do not think this is a defect — WorkloadManagement is described as CRUD in the module inventory and it has one endpoint pair. It is a documentation gap, and it is the first of several in this series where the code is better than the record of it.

To be fair to the authors: a README bullet is a teaching pointer, not an inventory. The nomination says “look here for a classic relational mapping”, and DeadlineConfiguration is precisely that. The cost is that a reader following the README's map will walk past the best small aggregate in the repository without stopping.

The aggregate that reads the whole table

Praise and critique belong in the same object, so here is the cost of that aggregate. Its repository has to materialise it, and a consistency boundary drawn around all currently active workload assignments means the boundary is the table.

// Illustrative - the shape of the load, not the estate's code
public async Task<ActiveWorkloads> GetActive(CancellationToken ct = default)
{
    var assignments = await _db.CashierWorkloadAssignments.ToListAsync(ct);
    return new ActiveWorkloads(assignments);
}

Answering “may this cashier touch this inquiry?” therefore reads every assignment row in the module. At a travel agency with twelve cashiers that is free. At any scale where the question matters, it is a table scan behind a boolean. The aggregate boundary is right in the model — the “no cashier exceeds their limit” rule genuinely spans all of a cashier's assignments — and wrong at scale, because the invariant that needs the wide read is enforced on write while the cheap read-only question pays for it too.

The fix is not to break the aggregate. It is to notice that CanAccessWorkload is a query, not a command, and queries do not have to go through the write model. That divergence — write model and read model parting ways because encapsulation makes the aggregate unqueryable — is the whole spine of the companion series' persistence arc, and I will leave it there.

What the three-architecture decision actually buys

Two things, and they are worth separating.

The first is local proportionality. Finance's PaymentService is a service class with a validator and an EF context, and it is 130 lines that anyone can hold in their head. Sales' offer lifecycle needs a factory per deadline type and a specification per policy, and it gets them. Nobody paid Clean Architecture tax on the module that stores a payer's tax number.

The second is a per-module upgrade path that costs nothing to exercise. Because the modules are separated by assembly and by schema, promoting Inquiries from CRUD to a rich model is a change inside two projects. That is the real argument for the modular monolith and it survives the fact that this estate never exercises it. When the boundary is a ProjectReference and a schema, the blast radius of an architecture change is one module.

What it costs is consistency of reading. Eight modules with three shapes means a new developer learns three idioms before they can review anything, and the README bullet that announces the choice does not say which module got which. You have to open .csproj files and count. That is a five-minute cost paid once, against a ceremony tax paid on every commit — but it is a real cost, and it is the reason most teams pick uniformity even when uniformity is wrong.

Next, the context map they drew — ADR 03, the relationships it declares between these eight boxes, and the reasoning it gives for each one in its own words.