Boundaries the Compiler Enforces
Trill's monolith makes every module type internal and gives each one an explicit friend list, so a cross-module reach is not a code-review conversation - it is a build error.
The standard objection to a modular monolith is that its walls are made of promises. Nine separate repositories cannot accidentally call each other's internals because there is a network in the way; six folders in one solution can, and the only thing stopping them is somebody noticing during review. Discipline decays; process boundaries don't.
Part 1 established that the Trill monolith was written after the distributed build was finished, by people who knew exactly which seams mattered. This is where that knowledge shows up first, and the answer to the objection is better than usual: in this repository the walls are not promises, they are compile errors.
Two mechanisms, applied without exception
The enforcement is built from two ordinary C# features used with unusual consistency.
The first is internal. Every type in every module is internal — the module class itself (internal class AdsModule : IModule), the domain entities, the repositories, the handlers, the documents, even the MVC controllers. Nothing in Trill.Modules.Users.Core is public. Compare the same file in the distributed build: Trill.Services.Users.Core.Domain.Entities.User is public class User, because in a service that ships alone the difference is cosmetic.
The second is an explicit friend list per assembly. Trill.Modules.Ads.Core/Extensions.cs:10 is a single line:
[assembly: InternalsVisibleTo("Trill.Modules.Ads.Api")]
That is the entire outward-facing surface of the Ads domain. One assembly may see it. Stories, which has four layers rather than two, needs a longer list — Trill.Modules.Stories.Core/Extensions.cs:7-12:
[assembly: InternalsVisibleTo("Trill.Modules.Stories.Api")]
[assembly: InternalsVisibleTo("Trill.Modules.Stories.Application")]
[assembly: InternalsVisibleTo("Trill.Modules.Stories.Infrastructure")]
[assembly: InternalsVisibleTo("Trill.Modules.Stories.Tests.Unit")]
[assembly: InternalsVisibleTo("Trill.Modules.Stories.Tests.Integration")]
[assembly: InternalsVisibleTo("DynamicProxyGenAssembly2")]
- Every name is inside Stories. Three sibling layers and two test projects; nothing from Users, Ads, Analytics or Timeline appears.
DynamicProxyGenAssembly2is the NSubstitute escape hatch. Castle's dynamic proxy generator needs to see internal interfaces to mock them; without this line,Substitute.For<IStoryRepository>()fails at runtime. It is the price of testing internal seams and worth knowing before you spend an afternoon on it.- Test projects are named individually, not covered by a wildcard, because there isn't one. Adding a test project is a deliberate act recorded in source.
Across the whole repository there are exactly thirty-one InternalsVisibleTo declarations, and every single one names either a sibling layer within the same module, a test project for that module, the proxy generator, or Trill.Bootstrapper. Not one module opens itself to another module.
The reference graph is the real proof
Attributes are only half of it, because InternalsVisibleTo is meaningless without an assembly reference to go with it. So the stronger claim is structural, and it holds across all twelve module .csproj files: there is no ProjectReference from any module to any other module.
The Ads domain project's entire reference list is two lines pointing at the shared kernel, and it carries zero PackageReference elements at all. The Stories domain project references one thing: Trill.Shared.Abstractions. The bootstrapper references the six module .Api projects and Trill.Shared.Infrastructure, and nothing else.
That combination is what makes the boundary real. To have Trill.Modules.Timeline.Core call into Trill.Modules.Users.Core, someone would have to add a project reference and add an InternalsVisibleTo line to the Users assembly. Both changes appear in the diff, both in files whose entire purpose is to declare boundaries, and neither can be done from inside the calling code. A cross-module reach in this codebase cannot be an accident; it can only be a decision somebody wrote down.
This is the same technique the DevMentors conference sample used, and that series covered it on a three-module estate. The interesting difference is scale: three modules is a demonstration, six modules with four different internal architectures is a load test, and the technique survives it.
The one place the shared kernel opens up
The framework itself plays by the same rules, with three named exceptions at Trill.Shared.Infrastructure/Extensions.cs:41-43:
[assembly: InternalsVisibleTo("Trill.Bootstrapper")]
[assembly: InternalsVisibleTo("Trill.Tests.Benchmarks")]
[assembly: InternalsVisibleTo("Trill.Shared.Tests.Integration")]
Trill.Shared.Infrastructure is almost entirely internal, and the class holding AddInfrastructure and UseInfrastructure is itself internal static class Extensions (:46). The effective consequence is worth stating plainly: only the bootstrapper can compose the application. A module cannot call AddInfrastructure; it can only implement IModule and be handed an IServiceCollection. The framework's composition API is private to the host while its extension points — IModule, IContractRegistry, AddMongoRepository, the HTTP verb helpers, AddExceptionToMessageMapper<T> — are public. That is a real framework-author decision, and it is the reason the six modules cannot drift into six different bootstrapping styles.
The benchmark project's presence on that list is a small delight. Trill.Tests.Benchmarks needs to see the internal MessagePackModuleSerializer and JsonModuleSerializer in order to compare them, so the friend list records a testing need rather than a leak. That benchmark is part 8.
Where the discipline frays
Two honest caveats, because a wall that is presented as perfect stops being useful as evidence.
The reference topology is inconsistent. Four of the six modules reference both shared projects from their core; Trill.Modules.Stories.Core references only Trill.Shared.Abstractions, which is the cleanest of the six; Trill.Modules.Users.Core and Trill.Modules.Saga reference only Trill.Shared.Infrastructure, skipping abstractions entirely. Same question, three answers, no architecture decision record anywhere. Stories is right — a domain layer that depends only on marker interfaces and value-object base types can be lifted out of the process tomorrow — and the fact that the strictest module is also the best-structured one is not a coincidence.
The infrastructure dependency is the thing that would block an extraction. A module core that references Trill.Shared.Infrastructure drags along the whole mini-framework — the Mongo helpers, the error middleware, the module registry, the HTTP verb extensions — for the sake of a couple of registration methods. That is the same infrastructure bleed the conference sample had, at a larger scale. It does not weaken the boundary between modules at all; it weakens the boundary between a module and its host.
The trick that keeps controllers invisible
One small piece of framework integration deserves naming, because without it the whole scheme leaks. ASP.NET Core's MVC controller discovery only finds public types. If Ads wants an AdsController and also wants every type in the module to be internal, those two requirements collide.
The resolution is Trill.Shared.Infrastructure/Api/InternalControllerFeatureProvider, a subclass of MVC's ControllerFeatureProvider that relaxes the visibility check, registered during AddInfrastructure alongside the application-part manipulation:
manager.FeatureProviders.Add(new InternalControllerFeatureProvider());
Four modules never need it, because they use the framework's own minimal-API style helpers (part 9). Ads and Analytics do, and rather than promoting two controllers to public and putting a hole in the wall, the repository extends the framework. Surgical, twenty lines, and it is the difference between “every type is internal” being true and being nearly true.
What the distributed build enforces instead
It is worth being fair to the other column. The microservices build has boundaries too, and they are stronger in one specific sense: a bad reference cannot even be expressed, because Trill.Services.Timeline has no idea Trill.Services.Users exists as code. There is nothing to reference.
But that strength is bought by giving up the compiler entirely in the other direction. When a shared concern changes, nine repositories have to be updated by hand — which is why CorrelationIdFactory.cs exists eight times and MessageBroker.cs five times, one of those five still naming its loop variable @event while iterating commands. In the monolith the same change is one edit and the build breaks in every consumer immediately.
The trade is not “boundaries versus no boundaries.” It is which direction you want the compiler pointing — at the walls between modules, or at the walls between deployables. The monolith gets consistency enforcement for free and pays for isolation with vigilance; the distributed build gets isolation for free and pays for consistency with copy-paste.
That trade has a second act, though, because code isolation is not the only kind. Next, one database and eleven string literals — where the compiler stops helping and a const string takes over.