Skip to content
kc@kumarChandrachooda.com:~$ cd /blog/invariants-enforced-by-comment && read --section="top" 0%
Architecture

Invariants Enforced by Comment

ModularMonolith's entities are public-setter data bags, its one immutability rule is a fixup with a comment, and its hardest domain question is asked in a comment and answered by nothing - a reading of domain modelling by annotation.

By Kumar Chandrachooda 13 Nov 2025 5 min read
A dashed comment line standing guard beside a solid shape it cannot touch

A domain rule can live in four places: the type system, the code path, the database, or a comment. Each is weaker than the last, and the weakest is the most common, because comments are where rules go when nobody has decided to enforce them yet. The DevMentors ModularMonolith estate — whose module boundaries make a strong architectural claim about domain separation — keeps its actual domain rules almost entirely in the fourth place. Part 11 read how the estate reports rule violations; this part reads where the rules themselves live, and mostly do not.

The entities have nothing to say

Here is Conference, the richest entity in the system, in full:

public class Conference
{
    public Guid Id { get; set; }
    public Guid HostId { get; set; }
    public string Name { get; set; }
    public string Description { get; set; }
    public string Location { get; set; }
    public DateTime From { get; set; }
    public DateTime To { get; set; }
    public int? ParticipantsLimit { get; set; }
}

Every property public, every setter open, no constructor, no methods. Host and Speaker are the same shape. Nothing prevents a conference whose To precedes its From, whose ParticipantsLimit is negative, whose Name is null — the entity will hold any combination of values the language allows. This is the anemic domain model, and in fairness it is the default shape EF Core nudged you toward in the net5.0 era: public setters and a parameterless constructor made the ORM happy, and richer models cost ceremony (backing fields, private constructors) that a sample may reasonably skip. But the estate's architecture — modules named after bounded contexts, contracts-by-copy as anti-corruption layers, id-only aggregate references — speaks fluent DDD, and its entities speak none. The boundaries claim strategic design; the model inside them is a property bag. The gap between those two statements is the whole finding.

Where did the rules go? One layer up and one layer out. Validation lives on DTOs — [Required], [StringLength(100, MinimumLength = 3)], enforced by [ApiController] at the HTTP door and nowhere else (part 9 showed the database enforcing nothing either). Behaviour lives in services. And the two rules that could not be expressed as attributes ended up as comments.

Rule one: the fixup

ConferenceService.UpdateAsync contains the estate's only immutability rule:

public async Task UpdateAsync(ConferenceDetailsDto dto)
{
    var conference = await GetConferenceAsync(dto.Id);
    var hostId = conference.HostId;
    if (conference is null)
    {
        throw new ConferenceNotFoundException(dto.Id);
    }

    Map(conference, dto);
    conference.HostId = hostId; // Host cannot be updated
    await _conferenceRepository.UpdateAsync(conference);
    // ...
}

Read it slowly, because every line teaches something.

  • The null check is doubly dead. GetConferenceAsync already throws when the conference is missing, so the if can never fire — and if it somehow could, the line above it (conference.HostId) would have thrown NullReferenceException first. A guard positioned after the dereference it guards is a fossil of an earlier code shape, kept by inertia.
  • The rule is real; the enforcement is a counter-move. “Host cannot be updated” is a legitimate invariant. But the entity cannot refuse a HostId write — every setter is public — so the service saves the old value, lets Map overwrite it with whatever the client sent, then writes the old value back. Invariant-by-fixup: the illegal state happens and is then repaired, rather than being unrepresentable.
  • The comment is the only witness. Delete that line and the code compiles, runs, and silently starts letting clients rehome conferences. No test exists to notice (part 13 counts what the missing tests cost elsewhere). The rule's entire durability is one developer reading one comment during one future refactor.

The rich-model version is not exotic, even by 2021 standards: make HostId a get-only property set in the constructor, and the invariant becomes unviolatable at compile time — the fixup, the comment, and the risk all evaporate. Today you would likely also use an init setter or a positional record. The point is not ceremony for its own sake; it is that this particular rule is exactly the kind the type system enforces for free.

Rule two: the question nobody answered

The second comment is more consequential, because it is not a rule — it is the absence of one, flagged and abandoned:

public async Task DeleteAsync(Guid id)
{
    // Can we delete a conference with tickets being already sold?
    var conference = await GetConferenceAsync(id);
    await _conferenceRepository.DeleteAsync(conference);
    // ...
}

The question is superb — it is the question, the cross-module consistency problem that justifies the entire architecture. And the code beneath it answers unconditionally: yes, always, delete proceeds. Nothing could answer better, because the machinery to ask does not exist: there is no ConferenceDeleted event, Tickets could not respond if there were (it has no data — it is a stub with one logging handler), and the estate has no saga or process manager to coordinate a decision that spans two modules' state. The comment marks the exact spot where the architecture runs out.

That is worth dwelling on, because deletion-with-dependents is where event-driven module separation earns or loses its keep. A joined-tables monolith answers this question with a foreign key and one transaction. A modular monolith with per-module schemas must answer it with choreography — publish ConferenceDeletionRequested, let Tickets veto or comply, compensate on failure — which is an order of magnitude more design. The estate, reasonably for a sample, priced that work in a comment and moved on. Production systems do the same thing with less self-awareness; I have written that comment, in other words, in other codebases. The kin series shows what the full answer costs when someone pays it: a saga that orchestrates a checkout across services.

Reading the pattern honestly

Ranked by durability, the estate's rule placements: conference name length — DTO attribute, enforced at one door; host immutability — service fixup plus comment; delete-with-sold-tickets — comment alone; everything else — nowhere. The gradient is the lesson. Every rule migrates toward the cheapest layer that will hold it, and comments are free precisely because they hold nothing. The discipline that resists the slide is asking, for each invariant: what is the strongest layer that could own this? Type system first, then code path, then schema, then — only with a ticket number attached — a comment.

The two comments in this part are also dated artefacts: one guards a code shape that no longer exists, one asks a question the roadmap never reached. Which makes them archaeology, and archaeology is the next part's whole method — two modules, three months of drift, the estate's conventions diverging in real time, and the honest ledger of everything that shipped broken.