Skip to content
kc@kumarChandrachooda.com:~$ cd /blog/the-compiler-guards-the-module-border && read --section="top" 0%
Architecture

The Compiler Guards the Module Border

ModularMonolith makes every controller internal and teaches MVC to find them anyway - compile-time module isolation from one feature provider, an InternalsVisibleTo chain, and the one place the wall has a hole.

By Kumar Chandrachooda 06 Nov 2025 4 min read
A wall between two modules, with exactly one gated opening

Module boundaries drawn with folders die by autocomplete. The moment OrdersService is public and the Payments project can reference it, somebody under deadline will, and your modular monolith quietly becomes a monolith with extra folders. Part 1 toured the DevMentors ModularMonolith estate; this part reads the mechanism that stops that decay — the cheapest real module isolation available in .NET, built from one keyword, one MVC extension point, and a handful of assembly attributes.

Internal controllers, and the provider that finds them

Every controller in the estate is internal. Here is the whole public surface of the Conferences module, as the compiler sees it from a sibling module: nothing.

[ApiController]
[Route(BasePath + "/[controller]")]
internal abstract class BaseController : ControllerBase
{
    protected const string BasePath = "conferences-module";
}

internal class ConferencesController : BaseController { /* ... */ }

That should not work. ASP.NET Core's built-in ControllerFeatureProvider only discovers public types, so internal controllers are normally invisible to routing. The estate's answer is InternalControllerFeatureProvider in Shared.Infrastructure/Api/, which subclasses the MVC provider and re-implements IsController with one check deleted:

internal class InternalControllerFeatureProvider : ControllerFeatureProvider
{
    protected override bool IsController(TypeInfo typeInfo)
    {
        if (!typeInfo.IsClass) return false;
        if (typeInfo.IsAbstract) return false;
        if (typeInfo.ContainsGenericParameters) return false;
        if (typeInfo.IsDefined(typeof(NonControllerAttribute))) return false;

        return typeInfo.Name.EndsWith("Controller", StringComparison.OrdinalIgnoreCase) ||
               typeInfo.IsDefined(typeof(ControllerAttribute));
    }
}

The base implementation's IsPublic test is gone; everything else mirrors stock MVC. It is registered once, in AddInfrastructure:

services.AddControllers()
    .ConfigureApplicationPartManager(manager =>
    {
        manager.FeatureProviders.Add(new InternalControllerFeatureProvider());
    });

Eleven lines, and suddenly internal is a viable default for an entire HTTP surface. Module API surfaces become invisible to sibling modules at compile time while remaining fully routable at runtime. Speakers cannot instantiate, subclass, or even name ConferencesController; the only way into the Conferences module from outside is HTTP or a published event — exactly the constraint a future service boundary would impose, enforced years early and for free.

The InternalsVisibleTo topology

internal alone would also lock out the Bootstrapper, which needs to call each module's registration facade. The estate opens precisely the doors it needs with assembly attributes, declared at the top of each module's facade file:

[assembly:InternalsVisibleTo("ModularMonolith.Bootstrapper")]
namespace ModularMonolith.Modules.Conferences.Api
{
    internal static class ConferencesModule
    {
        public static IServiceCollection AddConferencesModule(this IServiceCollection services)
        {
            services.AddCore();
            return services;
        }
        // Use... returns app unchanged
    }
}

Trace the grants across the estate and a deliberate topology appears:

Assembly Opens internals to
Conferences.Api, Speakers.Api, Tickets.Api ModularMonolith.Bootstrapper
Speakers.Core, Tickets.Core their own .Api project
Shared.Infrastructure ModularMonolith.Bootstrapper

Each .Core is visible only to its own .Api; each .Api is visible only to the Bootstrapper; the Bootstrapper's .csproj references the three .Api projects and nothing deeper. Composition is static project references plus runtime assembly scanning — no plugin loader, no MEF, no reflection-driven discovery of modules themselves. The chain reads like a corridor with two locked doors per module, and the compiler is the doorman.

The same file shows the seam for the other direction: UseConferencesModule just returns the app. All three Use{X}Module hooks are no-ops today — speculative generality, but of the cheapest kind, reserving the place where per-module middleware would go.

The URL is a border too

The BasePath constant in each module's BaseController does quiet architectural work. Conferences answers under conferences-module/..., Speakers under speakers-module/... — the module name is baked into every route. If Speakers ever became a standalone service behind a gateway, its public paths would not change; the reverse proxy rule writes itself. It is the same forward-compatibility trick as schema-per-module in the database (part 9): make the logical boundary visible in every addressing scheme you own, so the physical split is a config change rather than a migration.

Where the wall has a hole

The recipe is consistent enough that its one deviation jumps out. Speakers' and Tickets' AddCore methods are internal, guarded by the InternalsVisibleTo grants above. Conferences' is not:

namespace ModularMonolith.Modules.Conferences.Core
{
    public static class Extensions
    {
        public static IServiceCollection AddCore(this IServiceCollection services)
        {
            // registers services and repositories
        }
    }
}

A public static class with a public extension method, in the module written first — before the convention hardened. Any project that references Conferences.Core can compose the module's entire innards. No file in the repo exploits it, but that is precisely how encapsulation decays in bigger estates: the leak sits harmless until a deadline finds it. The lesson is that a boundary scheme is only as strong as its least-guarded module, and the guard here is a visibility keyword that one file forgot.

Worth being fair on two counts. First, InternalsVisibleTo is a coarse instrument — it opens all internals to the named assembly, not just the facade — so the Bootstrapper could technically reach anything in an .Api project. The discipline holds because the Bootstrapper contains eight lines of composition and nothing else; the design leans on keeping the trusted assembly tiny. Second, in a 2021 net5.0 codebase this attribute-and-keyword approach was essentially the only tool; today you would add an architecture test — NetArchTest or similar asserting “no module references another module's Core” — so the rule survives contributors who have never read the facade files.

What this buys, and what it cannot

The compile-time story is genuinely strong: wrong-direction references fail the build, which is the fastest feedback loop architecture governance can have. What the compiler cannot see is everything that crosses the border legitimately — events published through the in-memory broker, matched by class name and translated through JSON. Those contracts have no compiler at all, and the estate will demonstrate the consequence vividly in part 7.

The runtime story is equally honest: one process means one failure domain. The wall between modules stops references, not exceptions — a Tickets handler that throws can still fail a Conferences HTTP request, as part 8 shows. Compile-time isolation is necessary for a modular monolith; it is nowhere near sufficient.

Next: the recipe those facades implement — Add, Use, Repeat — and what plugging in a fourth module would actually take.