Two Modules, Three Months of Drift
Conferences and Speakers disagree on layout, naming, id policy, exception visibility, mapping style and route constraints - plus a 200 that returns nothing and a broker that threw NotImplementedException for four months. The honest ledger.
Independent modules promise independent evolution, and the promise is kept more thoroughly than anyone intends: conventions evolve independently too. The DevMentors ModularMonolith estate has two fully-built modules, written by the same hands roughly three months apart, and diffing them is like comparing geological strata — every layer records what its author believed about structure, naming and identity that month. This part reads the drift, then opens the estate's honest ledger: the bugs that shipped, verified against source and history. Part 12 closed on comments as dated artefacts; here the whole codebase is one.
The drift catalogue
Six conventions, two answers each — all from files sitting in the same solution:
| Convention | Conferences (Jan–Feb 2021) | Speakers (Apr 2021) |
|---|---|---|
| DbContext location | DAL/ConferencesDbContext.cs |
DAL/EF/SpeakersDbContext.cs |
| Repository naming | ConferenceDatabaseRepository in DAL/EF/ |
SpeakersRepository in DAL/Repositories/ |
| Repository interface home | top-level Repositories/ namespace |
beside the implementation in DAL/Repositories/ |
| Id policy | server mints - dto.Id = Guid.NewGuid() |
client supplies - ExistsAsync(speaker.Id) then insert |
| Exception visibility | internal, non-sealed |
public, one sealed, one not |
| DTO mapping | private static Map/Map<T>/MapDetails in the service |
extension methods AsDto()/AsEntity() in Mappings/ |
| Route id constraint | [HttpGet("{id}")] |
[HttpGet("{id:guid}")] |
None of these is individually wrong; several of the Speakers-side answers are arguably better (the :guid constraint, the extension-method mappers). The finding is the divergence itself: a module template that lives only in the author's head gets re-derived every time, and re-derivation drifts. Two rows have teeth beyond aesthetics. The id policy split means POST /conferences ignores any client id while POST /speakers requires one — a client that omits Id inserts Guid.Empty, and the second such client receives speaker_already_exists. And the PUT conventions are opposite: Conferences' controller does dto.Id = id (route wins), while Speakers declares {id:guid} in the route and never binds it — PUT /speakers-module/speakers/{A} with body id B updates speaker B. Same verb, same estate, contradictory identity semantics. An ADR or a dotnet new module template — the fix is that mundane — would have pinned all seven rows in January.
The ledger
Now the defects proper, stated plainly because they are verified, and kindly because this is teaching code that taught its authors in public.
The 200 that returns nothing. SpeakersController.Get(Guid id):
var speaker = await _speakersService.GetAsync(id);
if (speaker is null)
{
return NotFound();
}
return Ok();
Ok() — not Ok(speaker). The service fetches the speaker, the null check works, and the caller receives 200 with an empty body, always. One missing token. What elevates it from typo to lesson is the feedback loop that failed to exist: Speakers.rest, the module's only executable documentation, contains exactly one request — GET {{url}}, the home endpoint. The by-id endpoint was never once exercised by the tooling sitting next to it. The same file carries @accessToken = secret, a variable nothing uses in an estate with no authentication anywhere — copy-paste residue from a sibling DevMentors codebase, a vestigial organ recording which repo this file was born in.
The broker that threw for four months. Commit b06fe7a (2021-04-14) added the Tickets module, the event abstractions — and this:
internal sealed class InMemoryMessageBroker : IMessageBroker
{
public Task PublishAsync(params IMessage[] messages)
{
throw new System.NotImplementedException();
}
}
The same commit wired ConferenceService.AddAsync to call it: save the conference, then publish. So from 2021-04-14 until 60840b2 on 2021-08-23 — the entire summer — POST /conferences inserted the row and returned 500. The commit message says “dummy message broker”, so the stub was intentional; what was not intended is the arming: a dummy is safe until a live code path calls it, and this one was called by the estate's primary write endpoint in the very commit that created it. git show b06fe7a verifies both halves. A dummy that must exist before its implementation should be inert (return Task.CompletedTask; and a debug log), not explosive — NotImplementedException belongs on paths you believe are unreachable, and this one was reached by the happy path.
The small print. SpeakersService.BrowseAsync null-guards a repository result (entities?.Select(...)) that ToListAsync guarantees is never null. SpeakerNotFoundException's message has a wandering quote: $"Speaker with id '{id} was not found.'". Trivia individually; collectively they say the module was written quickly and never re-read — which the Ok() bug then proves at higher stakes.
The scaffold that promised
Every defect above shares one enabling condition, and it was built in the first week. tests/ModularMonolith.Tests.EndToEnd appeared in the skeleton commit, 2021-01-13: an xunit project with Microsoft.NET.Test.Sdk, xunit.runner.visualstudio and coverlet.collector — and zero source files, zero project references, forever. It is a promise-artefact: it makes the solution look tested at a glance, satisfies the folder convention, and holds nothing.
The cruelty of the specific bugs is how cheap their tests were. The Ok() bug: POST a speaker, GET it, assert the body is non-empty — the seams are ideal, controllers thin, IMessageBroker trivially fakeable. The broker era: any test touching POST /conferences fails instantly for four months of history. The contract rename: ten lines of serialisation round-trip. Three shipped defects, three sub-hour tests, an empty project waiting for all of them since day one. An empty test project is not neutral — it is a claim, and every commit that ships beside it without tests turns the claim into cover.
One more honest note: for a learner, this ledger is invisible. Nothing marks the Ok() as a bug rather than a choice, the dummy broker as a stub rather than a pattern, the drift as accident rather than options. Teaching code carries an extra duty ordinary code does not — to make its intended simplifications distinguishable from its mistakes — and the absence of that marking is this estate's deepest gap, deeper than any individual defect.
The ledger closes the reading of what the estate is. What remains is what it could become — which module could leave the process tomorrow, what blocks the others, and what this station on the DevMentors line finally teaches about the road between monolith and microservices. The retrospective is next.