Skip to content
Kumar Chandrachooda
Microservices

One Template, Nine Times

Nine DShop services share one skeleton stamped out by hand - and the places they quietly disagree teach more than the places they match. A drift catalogue read from the real source. Part 2 of Nine Services and a Message Bus.

By Kumar Chandrachooda 30 Jul 2025 6 min read
One stencilled outline traced nine times, each tracing slightly off

The first thing you notice reading the nine DShop services is how little there is to notice. Open Part 1's topology in a code editor and the repositories are near-clones: the same Program.cs, the same Startup.cs in the same order, the same five folders. A template stamped out nine times. The second thing you notice — and this is the interesting part — is that a template stamped by hand never comes out identical. Each copy carries the fingerprints of the afternoon it was made. This part is the drift catalogue.

The skeleton every service shares

Here is the shape, using Orders as the exemplar. The Program.cs is five fluent calls:

public static IWebHostBuilder CreateWebHostBuilder(string[] args) =>
    WebHost.CreateDefaultBuilder(args)
        .UseStartup<Startup>()
        .UseLogging()
        .UseVault()
        .UseLockbox()
        .UseAppMetrics();

Every service has exactly this, give or take a call. The Startup.cs is longer but just as uniform: register MVC, Consul, Jaeger and OpenTracing into the framework container; build an Autofac container; scan the assembly for handlers; add the dispatchers, RabbitMQ, and Mongo; then in Configure, subscribe to the bus.

var builder = new ContainerBuilder();
builder.RegisterAssemblyTypes(Assembly.GetEntryAssembly())
    .AsImplementedInterfaces();
builder.Populate(services);
builder.AddDispatchers();
builder.AddRabbitMq();
builder.AddMongo();
builder.AddMongoRepository<Order>("Orders");

RegisterAssemblyTypes(...).AsImplementedInterfaces() is the load-bearing line: it finds every command handler, event handler, and repository in the assembly by the interfaces they implement, and registers them without a single explicit builder.RegisterType<...>(). Convention over configuration, and it is why every service's Startup is the same length regardless of how many handlers it has. The template's whole economy is that adding a handler requires editing no registration code — you drop a class in a folder and the assembly scan finds it.

That single design choice is also where the first drift lives.

Drift one: which assembly do you scan?

Eight of the nine services scan Assembly.GetEntryAssembly(). Discounts scans typeof(Startup).Assembly:

// eight services:
builder.RegisterAssemblyTypes(Assembly.GetEntryAssembly()).AsImplementedInterfaces();

// Discounts:
builder.RegisterAssemblyTypes(typeof(Startup).Assembly).AsImplementedInterfaces();

At a glance this looks like sloppiness — two idioms for the same thing. It is the opposite: it is the one service that thought about the difference. GetEntryAssembly() returns whatever executable started the process. Under dotnet run that is the service. Under a test runner — and Discounts is the only service with real integration tests that boot the app — the entry assembly is the test host, and the scan would find no handlers at all. typeof(Startup).Assembly is deterministic: it is always the service assembly, whoever launched the process. The drift is a correctness fix that only Discounts needed, because only Discounts is tested that way. We come back to that lonely test suite in The Test Folder That Lies.

Drift two: sealed, sometimes

sealed on a handler class is a micro-decision: it says “nobody derives from this,” lets the JIT devirtualise, and signals intent. Across the services it is applied at random:

Service Sealed handlers
Products all five
Orders four
Customers none
Discounts none

There is no rule here — Products' author sealed by habit, Customers' author didn't, and nobody reconciled them because nothing forces reconciliation. This is the purest kind of hand-stamp drift: a stylistic bit that flips depending on who held the pen. On its own it costs nothing. As a signal, it tells you the nine services were not written by one person in one sitting, and that no linter or template generator sat between the author and the repository. Keep that in mind for every later part — the drift that costs something rides in on the same absence of a shared gate.

Drift three: who builds the rejected event?

When a command handler fails — an order can't be created, a product can't be reserved — the estate publishes a rejected event so the caller finds out. There are two idioms for producing it, and both survive in the estate.

The common idiom lets the bus do it. The SubscribeCommand call takes an onError factory, and the framework invokes it when the handler throws:

.SubscribeCommand<CreateOrder>(onError: (c, e) =>
    new CreateOrderRejected(c.Id, c.CustomerId, e.Message, e.Code))

The handler just throws a DShopException(code, message); the subscription turns that into a CreateOrderRejected and publishes it. Clean: the failure taxonomy lives in one place, next to the subscription. Products and Orders do it this way throughout — and this two-lane error model (domain exception becomes a rejected event; infrastructure exception becomes a retry) is the framework mechanism dissected in Rejected Events, a Two-Lane Error Taxonomy.

The other idiom builds the rejected event by hand inside the handler and publishes it directly. Discounts carries both — the manual version is present but commented out, the onError version live. You are reading someone converging on the framework idiom and leaving the old one in the file. And notice one more subscription in Orders that has no onError at all:

.SubscribeCommand<CreateOrderDiscount>()

CreateOrderDiscount is the command the discount saga sends when a customer earns a discount. It gets no rejection path — if it fails, it fails silently, no event, no caller notification. That is not a bug so much as a shrug, and it matters because the whole discount feature turns out to be a Console.WriteLine; we get there in Rich Domain, Honest Stubs.

Drift four: the advanced Dockerfile nobody could build

The best drift is the one that is uniform — the same mistake in all seven services that carry it, which tells you it was copied, not written. Each service ships three Dockerfiles as a teaching ladder: plain, multistage, and Dockerfile.multistage.advanced. The advanced one names its build stage build:

FROM mcr.microsoft.com/dotnet/core/sdk AS build
WORKDIR /app
# ... restore, publish ...

FROM mcr.microsoft.com/dotnet/core/aspnet:2.2
WORKDIR /app
COPY --from=builder /app/${service}/out/ .

Read the two stage names. The first stage is AS build. The final stage copies --from=builder. There is no stage called builder, so the “advanced” image fails to build in all seven services that ship it — the same typo, copy-pasted across the estate. A single-source Dockerfile template would have this wrong in one place or right in all; seven identical copies of the same wrong string is the signature of stamp-and-drift.

There are two more time bombs in the same file, worth naming because they are the kind that pass CI and detonate in production. The base image FROM mcr.microsoft.com/dotnet/core/sdk carries no tag — it floats to whatever latest means the day you build, so the image is not reproducible. And the ENTRYPOINT dotnet DShop.Services.Orders.dll is shell form, which wraps the process in /bin/sh -c; the shell, not .NET, becomes PID 1, so a container SIGTERM never reaches the runtime and graceful shutdown — including Consul deregistration — never runs. None of these three break the plain Dockerfile the estate actually deploys with; they break the one labelled “advanced,” which is a small tragedy of documentation.

What the drift is telling you

Step back from the four items and they are one finding wearing four hats: nine services were stamped from a template by hand, with no generator and no shared lint gate, so every micro-decision drifted independently. Most of the drift is harmless — a sealed here, an idiom there. But the same absence of a gate that lets sealed wander is the absence that lets a broken COPY --from=builder propagate to seven repositories, and — as the next part shows — lets an event contract lose a field between the service that publishes it and the service that consumes it.

That is the drift that actually costs something. Next: OrderCreated in Three Shapes, where the same event is defined three times, and one of the three quietly forgets what the customer ordered.