Skip to content
kc@kumarChandrachooda.com:~$ cd /blog/crud-rich-clean-and-who-actually-got-which && read --section="top" 0%
.NET

CRUD, Rich, Clean — And Who Actually Got Which

Testing a README's three-tier architecture claim against the source, and finding that the module nobody mentions holds the only real aggregate in the estate.

By Kumar Chandrachooda 27 Nov 2025 6 min read
Eight module blocks sorted into three tiers, with one block in the wrong tier

Part 1 took the GroupFlights README at its word: eight modules, three application architectures, chosen per module by the complexity and class of problem each one solves. That is a claim, and claims in a teaching repository are the most testable thing in it. So I opened all eight and graded them.

The classification mostly holds. Where it does not, the mismatch is more instructive than the match — because the module the README never singles out has the only genuine aggregate in the estate, and the module it does single out has no domain model at all.

The grading, module by module

Module What the source actually shows Tier
Communication one class, one LogInformation call below CRUD
Backoffice DocumentFile, five public get/set properties, no methods CRUD, textbook
Finance Payer and Payment — positional records, zero methods CRUD
Inquiries Inquiry.Accept() / Reject() plus one invariant CRUD + rich, thinly
TimeManagement Deadline is data; public bool? Fulfilled { get; set; } CRUD
WorkloadManagement a real aggregate with a hand-rolled unit of work CRUD + rich, strongest
Sales four projects, rich domain, nine domain events Clean Architecture
Postsale four projects, fully encapsulated aggregate Clean Architecture

Backoffice is the honest end of the scale. DocumentFile (Backoffice.Core/Models/DocumentFile.cs) is five auto-properties — FileId, Content, Name, ContractId, Owner — with public setters and no behaviour. Nobody is pretending. Storing a contract PDF against a GUID does not need an aggregate, and giving it one would be the sort of ceremony that makes DDD unpopular in the first place.

Finance is one step up in shape and zero steps up in behaviour. Payer and Payment are C# positional records with a private parameterless constructor bolted on so EF can materialise them, and that is the entire model. Every rule — duplicate detection, payer lookup, secret minting, the webhook comparison — lives in PaymentService. Records-as-DTOs with a service layer is a defensible CRUD shape; naming it CRUD rather than “domain model” is the honest part.

Inquiries earns the plus. Inquiry is still a record, but it carries three private-set properties and two real transitions:

// src/Inquiries/GroupFlights.Inquiries.Core/Models/Inquiry.cs:27-47
public void Accept(Guid? offerToCreateId)
{
    EnsureNotAlreadyVerified();
    OfferId = offerToCreateId;
    VerificationResult = InquiryVerificationResult.Accepted;
}

public void Reject(string rejectionReason)
{
    EnsureNotAlreadyVerified();
    VerificationResult = InquiryVerificationResult.Rejected;
    RejectionReason = rejectionReason;
}

private void EnsureNotAlreadyVerified()
{
    if (VerificationResult is not null)
    {
        throw new InquiryAlreadyVerifiedException();
    }
}

One invariant, enforced in one place, guarding both transitions. That is rich-domain-model in its smallest honest form, and it is the right size for the problem. Note what it does not do: the group-size rule, the date-window rule and the airport rules all live outside the model, in a hand-written InquiryValidator, while adultCount < 1 lives in a value-object constructor. Three homes for rules with no stated boundary between them — a drift the module's own thinness makes easy to miss.

The inversion: the aggregate nobody mentioned

WorkloadManagement is 580 lines. It is never held up as an example of anything. It contains the best-designed model in the repository.

// src/WorkloadManagement/GroupFlights.WorkloadManagement.Core/Models/ActiveWorkloads.cs:8-22
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;

Read what that is doing. ActiveWorkloads is not an entity in the database; there is no ActiveWorkloads table. It is a consistency boundary drawn around a set of rows, constructed from them, and it exists purely so that three rules can be enforced together against the whole set:

  • a cashier may not hold the same assignment twice;
  • a cashier may not exceed the configured assignment limit;
  • a workload may not be assigned to two cashiers at once.

Those are exactly the rules you cannot enforce row-by-row, and exactly the reason aggregate boundaries exist. AssignWorkload walks every existing assignment once, accumulating the cashier's count while checking the other two conditions in the same pass, and only then appends.

Then it does the second good thing: it does not mutate the database, it records what changed.

_workloadAssignments.Add(cashierWorkloadAssignment);
_pendingChanges.Add(new (cashierWorkloadAssignment, AssignmentChangeType.Added));

And the repository replays that change set:

// src/WorkloadManagement/.../Data/Repositories/WorkloadRepository.cs:21-37
public async Task UpdateWorkloads(ActiveWorkloads workloads, CancellationToken cancellationToken)
{
    foreach (var changes in workloads.PendingChanges)
    {
        switch(changes.ChangeType)
        {
            case AssignmentChangeType.Added:
                await _dbContext.Workloads.AddAsync(changes.Assignment, cancellationToken);
                break;
            case AssignmentChangeType.Deleted:
                _dbContext.Remove(changes.Assignment);
                break;
        }
    }

    await _dbContext.SaveChangesAsync(cancellationToken);
}

That is a hand-rolled Unit of Work with a Memento, written in twenty lines by someone who could simply have called dbContext.Remove from inside the model and been done. The model stays free of EF; the repository stays free of rules; the change set is the contract between them. In a repository whose headline modules are Sales and Postsale, this quiet 580-line module is the cleanest tactical DDD in the estate.

And the same model loads the entire table

Now the other half, because praise without the bill is marketing.

// src/WorkloadManagement/.../Data/Repositories/WorkloadRepository.cs:16-19
public async Task<ActiveWorkloads> GetActiveWorkloads(CancellationToken cancellationToken)
{
    return new ActiveWorkloads(await _dbContext.Workloads.ToListAsync(cancellationToken));
}

There is one load path, and it is unfiltered. Every caller gets every assignment row in the system materialised into memory — including CanAccessWorkload, which exists to answer a single boolean question about a single cashier and a single workload id. To find out whether cashier X may touch offer Y, the module reads the whole table.

This is the aggregate boundary being right in the model and wrong at scale, and it is worth being precise about why. The invariant “a cashier may hold at most N assignments” genuinely does span every row for that cashier, so a per-row repository method could not enforce it. The boundary is real. What is missing is the second load path: a narrow AnyAsync for the read-only authorisation question, which needs no aggregate at all because it enforces nothing.

To be fair to the design: this is a teaching estate with a demo-sized table, the code is internal, and the honest fix is three lines. And the module already has the estate's only non-key indexes — CashierWorkloadAssignmentConfig.cs:19-20 declares two unique indexes, where Inquiries, Finance, Backoffice and TimeManagement declare none between them. Somebody was thinking about the database here. They just gave every question the same answer.

The showcase module that is data

TimeManagement is the module the README nominates as the demonstration of classic relational persistence. Its entity is this:

// src/TimeManagement/GroupFlights.TimeManagement.Core/Models/Deadline.cs:27-33
public DeadlineId Id { get; init; }
public CommunicationChannel CommunicationChannel { get; init; }
public IReadOnlyCollection<DeadlineParticipant> Participants { get; init; }
public Message Message { get; init; }
public DateTime DeadlineDateUtc { get; internal set; }
public IReadOnlyCollection<DeadlineNotification> Notifications { get; init; }
public bool? Fulfilled { get; set; }

A guarded constructor, and then no methods at all. Every state transition happens in DeadlineService by assignment. Fulfilled is a public settable bool? doing tri-state duty — null means active, true means fulfilled, false means overdue — which the module's own exception name confesses: DeadlineWasAlreadyFulfilledOrOverdueException.

That is not a criticism of the classification. TimeManagement is correctly labelled CRUD, and this post's grading agrees with the README. The observation is about emphasis: the module chosen to demonstrate a persistence strategy has no domain model, and the module with the best domain model demonstrates nothing. The two facts are unrelated in the source and instructive side by side.

There is also a defence, and Part 10 makes it properly: public bool? Fulfilled { get; set; } is exactly what makes Where(d => d.Fulfilled == null) a SQL predicate. The scheduler runs that query every five seconds. Encapsulate the field and you lose the query, and the query is the module's entire reason to exist. The anaemic model is not an oversight there; it is the price of being the only model in the estate that a background poller can interrogate.

Next, the two-class lifecycle — where Sales makes an illegal operation unrepresentable by having the transition method return a different type entirely.