Skip to content
kc@kumarChandrachooda.com:~$ cd /blog/two-services-two-architectures && read --section="top" 0%
Microservices

Two Services, Two Architectures

Trill's Stories service is four projects with a dependency-free domain at the bottom. Its Users service is two projects with everything in one assembly - same author, same week, same estate, opposite answers to what a layer is.

By Kumar Chandrachooda 19 Dec 2025 7 min read
One service built from four stacked layers beside another of equal size built from a single block

Every estate I have inherited has a service that is the “reference implementation” and a service that is “the one we did quickly”. The tell is never a README; it is the project count. One service has four .csproj files and a domain assembly with no package references, and the other has two, and the domain entity sits three folders away from the ASP.NET middleware that logs its requests. Trill has both, they were initialised four minutes apart, and comparing them is the cleanest lesson in the estate about what layering actually buys.

Trill is an open-source teaching estate by DevMentors — Piotr Gankiewicz and Dariusz Pawlukiewicz — eleven sibling MIT-licensed repositories: ten that implement a short-form social feed as microservices, and one that implements the same product again as a modular monolith. This series reads five of the domain services and the Blazor client: Trill.Services.Stories, Trill.Services.Users, Trill.Services.Ads, Trill.Services.Analytics, Trill.Services.Timeline and Trill.Web.

To be clear up front: I did not write Trill. I am a source-reader. Everything I quote is real code from the repositories at their last commit — net5.0 update, 2021-04-22 — with the file and line named, because in this estate the implementation is the story. Trill is a consumer of Convey, the same authors' microservices framework, which this corpus has already read end to end; where Convey's own behaviour matters I will point at that series rather than re-derive it. Trill is also a sibling of DShop and Pacco, two other DevMentors reference estates.

Four projects, and a domain that references nothing

Trill.Services.Stories has 121 C# files across four projects, and the reference chain is a straight line:

Trill.Services.Stories.Api
  └─> Trill.Services.Stories.Infrastructure
        └─> Trill.Services.Stories.Application
              └─> Trill.Services.Stories.Core

Two things about that chain are worth reading slowly.

Core has no package references at all. The whole file is seven lines:

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>net5.0</TargetFramework>
  </PropertyGroup>
</Project>

No Convey, no MongoDB driver, no Microsoft.Extensions.*. Story, StoryText, Rate, Visibility, StoryAuthorPolicy and thirteen domain exceptions compile against nothing but the BCL. That is the strictest form of the dependency rule, and it is enforced by a build error rather than by a review comment. Trill.Services.Stories.Infrastructure.csproj, by contrast, carries twenty-two Convey packages, Google.Protobuf, Grpc.Tools and IdGen.

Api references only Infrastructure. It cannot see Core at all. StoriesController is physically unable to construct a Story or catch a DomainException by type, because those types are not in its compilation. Whether that was deliberate or a happy accident of dotnet add reference, the effect is a compile-time guarantee that the HTTP edge speaks in commands and DTOs, and nothing else.

The layers are not symmetric in weight. Core holds seven entities, four value objects, thirteen exceptions, two factories, two policies, three repository interfaces and five domain events. Application holds two commands and their handlers, five DTOs, eight event types, five exceptions, two queries and eight service interfaces. Infrastructure holds the six decorators, the Mongo documents and repositories, the gRPC server, the correlation plumbing and a sixty-line composition-root facade. Api holds three controllers and a Program.cs.

Two projects, and everything in one of them

Trill.Services.Users has 94 C# files across two projects. Trill.Services.Users.Api contains exactly one file, Program.cs. Everything else lives in Trill.Services.Users.Core, and “everything” is the operative word. Inside that single assembly:

  • Domain/Entities/User.cs, Role.cs, RefreshToken.cs, Follower.cs — the aggregate roots.
  • Commands/Handlers/*.cs — eleven command handlers.
  • Mongo/Documents/*.cs, Mongo/Repositories/*.cs, Mongo/Queries/Handlers/*.cs — persistence and the read side.
  • Logging/LogContextMiddleware.cs — an ASP.NET Core middleware.
  • Services/JwtProvider.cs, PasswordService.cs, Rng.cs, TokenStorage.cs — the security machinery.
  • Extensions.cs — the composition root, 154 lines of AddCore() and UseCore().

Trill.Services.Users.Core.csproj carries twenty-four package references, including Convey.Auth, Convey.Persistence.MongoDB, Convey.Tracing.Jaeger and Microsoft.AspNetCore.Identity's password hasher. The assembly named Core is the least core thing in the estate: it is the only place where a domain invariant and a Jaeger span configuration share a compilation unit.

The folder names diverge as well, in a way that would defeat any cross-service tooling. Stories puts aggregates in Core/Entities and value objects in Core/ValueObjects. Users puts aggregates in Core/Domain/Entities and has no value objects whatsoever — Email, Name, Role and Funds are all primitives on User, with the validation inlined into the constructor. There is even a leftover MSBuild item pinning the divergence in place:

<ItemGroup>
  <Folder Include="Domain" />
</ItemGroup>

<Folder Include="..." /> is what the IDE emits for an empty folder so it survives a checkout. Domain has not been empty for a long time.

What the split actually bought, and what it did not

The honest scoreboard is more interesting than “layering good”.

What the four-project split bought Stories: a domain you can unit-test with no container, no config and no Mongo; a compile-time wall between the HTTP edge and the entities; and a clear home for the six cross-cutting decorators that part 5 takes apart. Users has none of those, and the cost shows in small ways — CreateUserHandler owns the email regex that ought to live on User, and User only checks the field is non-blank.

What it did not buy: correctness. The layered service is the one carrying the estate's worst data bug — a constructor called with two arguments transposed, in Trill.Services.Stories.Infrastructure/Mongo/Documents/StoryDocument.cs:39, present since the first commit, which part 3 traces. Layers put the mapping code in a sensible folder; they do not read it. The layered service is also the one with the half-finished domain-event pipeline of part 4, because four projects give a refactor four places to stop.

And in one respect Users is the better-modelled service. User.ChargeFunds genuinely protects an invariant:

public void ChargeFunds(decimal amount)
{
    if (Funds < amount)
    {
        throw new InsufficientFundsException(Id);
    }

    Funds -= amount;
    Version++;
}

Lock() and Unlock() return bool — “did this actually change?” — which the handlers use to suppress redundant events. Stories' User has none of that: Lock() is Locked = true, AddRating(int) is Rating += rating, no rule, no event. The service with four layers has an anaemic aggregate; the service with one assembly has a behavioural one. Layering is a dependency-management technique, not a modelling technique, and Trill demonstrates the difference by accident.

Where the series goes

Fifteen parts, reading the five domain services and the client:

  1. Two services, two architectures — this post.
  2. The factory that doesn't own construction — a validating factory with a public constructor and an implicit conversion beside it.
  3. Title and text, swapped since commit one — a transposed constructor call that the type system was talked out of catching.
  4. The refactor that stopped halfway — a domain-event pipeline built, wired, and never connected to the handler that needed it.
  5. Six decorators and one marker attribute — Scrutor, TryDecorate, and the attribute that stops a decorator registering as its own handler.
  6. An index with its keys backwards — a compound Mongo index whose prefix is the field nobody queries on.
  7. A million documents for one integer — a client-side Sum() on the browse path, next to the server-side one that already exists.
  8. Sign-in is impossible if you capitalise — asymmetric normalisation between write and read, one word apart.
  9. The identity abstraction nobody injected — the class that would have supplied the caller, registered and ignored.
  10. One format string, two meaningsToString("N") on a Guid and on a long, and the timeline it breaks.
  11. Fan-out on write, one round trip at a time — the best design in the estate, executed sequentially.
  12. Trending means all-time and frozen — a ranking engine in thirty-nine lines, with no time in it.
  13. An ad is a story with a flag — the entire commercial model, and the boolean it rests on.
  14. Seven lines of CSS for twenty components — what a design system's absence measures.
  15. Seven test projects, zero tests — the honest retrospective.

If you have ever argued about project counts in a code review, or shipped a service where the folder structure promised more discipline than the code delivered, the next fourteen parts are a long worked example of both. We start where the estate's cleverest mistake starts: with a factory that validates text it does not own.

Next, the factory that doesn't own construction.