Part 1. Enforcing True Module Boundaries in a .NET Modular Monolith

There’s a moment that happens to most of us at some point in our careers. You’ve done “everything right”. You’ve split the solution into folders called Modules. You’ve got namespaces that look clean. You might even have separate projects. And yet, six months later, someone adds a reference they shouldn’t, calls into a repository they don’t own, and suddenly your carefully designed architecture is held together by nothing more than goodwill and tribal knowledge.
I’ve been building .NET systems for a long time now, and I’ve learned this the hard way, architecture that relies on discipline alone will eventually fail. Not because people are careless, but because people are busy. They’re under pressure. They’re trying to ship. And sometimes, that one little shortcut feels harmless.
This first post in the series is about how to stop relying on discipline and start enforcing boundaries. Not with dogma. Not with over-engineering. But with practical techniques that work in real .NET codebases.
Why “Modular” Usually Isn’t
Most so-called modular monoliths fail for one simple reason, nothing actually stops one module reaching into another.
At first, everything feels fine. The codebase is small. You remember where things live. You even review every PR yourself. But time passes. New features land. New developers join. Context fades.
Then you see it:
var user = _userRepository.GetById(userId);
That line doesn’t live in the Users module.
It lives in Billing.
Now Billing knows about Users’ persistence model. It knows how Users stores data. And worse, it now depends on Users being present, initialised, and shaped exactly the way Billing expects. The boundary is gone.
At that point, you don’t really have modules. You have folders with opinions.
What a Real Boundary Actually Means
A real boundary is not a folder, a namespace, or a naming convention. It is something you physically cannot cross by accident. If a developer can casually reach across it with a reference, an import, or a “quick” helper method, then the boundary is already broken. Real boundaries create resistance. They force you to slow down and make a conscious decision when you want to interact with another part of the system. The compiler should be your first line of defence. When boundaries are real, the compiler actively helps you stay honest by refusing to compile code that reaches into places it has no business touching. You are not relying on discipline, code reviews, or comments to enforce the architecture. The rules are encoded in the structure of the system itself, and violations show up immediately, not six months later during a refactor.
When a boundary is violated, it should be obvious and slightly painful. You should feel friction when you try to bypass it. If breaking a boundary feels easy, invisible, or harmless, then it was never a real boundary to begin with. At that point, it is purely decorative architecture, something that looks good on a diagram but collapses under day-to-day development pressure.
In practice, this means a module must own its data completely. No other part of the system should be able to reach directly into its tables, collections, or persistence models. The module also controls how others talk to it, exposing only explicit entry points that reflect real use cases rather than internal implementation details.
Most importantly, a module must hide its internals entirely. This is the part people most often get wrong. They allow “just this one” internal type to leak out, or they expose a repository or entity because it feels convenient. Every one of those shortcuts weakens the boundary. Once internals leak, the module stops being a module and starts becoming a shared code bucket with aspirations of structure.
The Shape of a Proper Modular Monolith
Before we get into enforcement, it’s worth being clear about the target shape.
Here’s the mental model I use.

Each module:
Exposes a public surface
Keeps everything else private
Communicates through contracts, not internals
No module reaches directly into another module’s database, repositories, or EF models. If it needs something, it asks.
Assemblies Are Your First Line of Defence
If you take only one thing from this post, let it be this:
Folders do not enforce boundaries. Assemblies do.
The moment you put everything in a single project, you’ve already lost most of your leverage. Yes, you can be disciplined. Yes, you can rely on conventions. But the compiler cannot help you anymore.
A solid baseline is one assembly per module.
/src
/Modules
/Users
Users.Application
Users.Domain
Users.Infrastructure
/Billing
Billing.Application
Billing.Domain
Billing.Infrastructure
/Api
Already, you’ve gained something important, explicit references. Billing does not magically see Users unless you tell it to.
But we’re not done yet.
Internal by Default, Public by Exception
One of the most underused features in .NET is the internal keyword. Most people default to public and never look back. That’s a mistake.
Inside a module, almost everything should be internal.
Entities. Repositories. EF configurations. Handlers. Services.
Public types should be rare and deliberate.
For example, in the Users module:
namespace Users.Application.Contracts;
public interface IUserLookup
{
Task<UserSummary> GetUserAsync(Guid userId);
}
That interface is public. It’s the door into the module.
This, on the other hand, is not:
internal sealed class UserRepository : IUserRepository
{
// implementation
}
Billing never sees this. Cannot see this. And that’s exactly the point.
Friend Assemblies: A Controlled Escape Hatch
“But what about tests?”
This always comes up.
You don’t make everything public just so tests can poke at it. You use friend assemblies.
In your module’s AssemblyInfo:
[assembly: InternalsVisibleTo("Users.Tests")]
Now your tests can see internals, but no other module can. This keeps the production boundary intact while still allowing deep testing.
Forcing Access Through Contracts
Forcing access through contracts is what turns an architectural idea into something that actually holds up under pressure. A boundary is only real if there is a single, sanctioned way in. The moment there are multiple paths, helper shortcuts, or back doors, the boundary starts to erode. People will always take the path of least resistance, especially when deadlines are tight. In a modular monolith, that single entry point is almost always a contract. Other modules do not reach in and grab what they want. They ask for something to be done. That shift, from data access to intention, is subtle but crucial. It forces you to think in terms of behaviour and outcomes rather than structures and tables.
Interfaces are one common way of expressing that contract. They define what a module is willing to do, not how it does it. Request and response models push this idea further by making interactions explicit and self-contained. Instead of passing around internal types, you exchange messages that represent a specific use case.
In-process messaging takes the same principle and applies it even more strictly. Rather than direct calls, modules communicate by sending requests or publishing events inside the same process. The module decides how to handle them, and everyone else is deliberately kept at arm’s length. The result is a system where access is intentional, boundaries are enforced by design, and accidental coupling becomes much harder to introduce.
Here’s a simple pattern that works well.
Users exposes a contract
public sealed record GetUserQuery(Guid UserId);
public sealed record UserResult(Guid Id, string Email);
public interface IUserQueries
{
Task<UserResult?> GetAsync(GetUserQuery query);
}
Users implements it internally
internal sealed class UserQueries : IUserQueries
{
public async Task<UserResult?> GetAsync(GetUserQuery query)
{
// EF Core, Dapper, whatever
}
}
Billing depends only on the contract
public sealed class InvoiceService
{
private readonly IUserQueries _users;
public InvoiceService(IUserQueries users)
{
_users = users;
}
}
Billing does not know how Users works. It cannot reach into it even if someone wants to.
Blocking “Just This Once” Shortcuts
The most dangerous phrase in software architecture is:
“It’s just this once.”
This is where enforcement comes in.
Assembly Reference Rules
Assembly reference rules are where modular boundaries stop being theoretical and start being enforceable. If a module exposes a single contracts assembly, that is the only thing other modules are allowed to reference. In this case, Billing should reference Users.Application.Contracts and nothing else. That rule should be obvious, and non-negotiable. The moment someone tries to add a reference to Users.Infrastructure or Users.Domain, something should break. Ideally the build fails. At the very least, alarms should go off loudly enough that the violation cannot slip through unnoticed. If those references are possible and nothing complains, then the boundary exists only by convention, and conventions are fragile under real-world pressure.
You enforce these rules first through solution structure. Projects are laid out so that the intended references are obvious and the forbidden ones feel unnatural. On top of that, you add explicit project reference rules, making it mechanically impossible to depend on the wrong assemblies without deliberately bypassing the design.
Then, you back it all up with architecture tests. These tests don’t care about business logic or behaviour. Their job is simply to assert that the dependency graph stays within the lines you have drawn. When someone crosses a boundary, the test fails, the build goes red, and the conversation happens immediately, not months later when the coupling has already spread.
Architecture Tests That Actually Help
Here’s a simple example using NetArchTest.
[Test]
public void Billing_Should_Not_Depend_On_Users_Infrastructure()
{
var result = Types.InAssembly(typeof(BillingRoot).Assembly)
.ShouldNot()
.HaveDependencyOn("Users.Infrastructure")
.GetResult();
result.IsSuccessful.Should().BeTrue();
}
This test doesn’t care about business logic. It cares about structure. When it fails, it fails loudly, and early.
That’s the kind of test that saves you months later.
Database Boundaries Are Non-Negotiable
If one module can read another module’s tables, you do not have a modular monolith. You have a shared database with delusions of grandeur.
Each module owns its schema.

The Cost of Boundaries (And Why It’s Worth Paying)
Let’s be honest. This approach has a cost.
You will:
Write more interfaces
Pass more data explicitly
Feel friction early on
But here’s what you get in return:
Predictable change
Safer refactoring
Modules you can actually extract later
I’ve seen systems where adding a feature meant touching 14 projects because everything was intertwined. I’ve also seen systems where a change stayed neatly inside one module. The difference was never intelligence. It was boundaries.
The Human Side of This
I’ll end on something less technical.
I do a lot of my thinking late at night. after everyone else is asleep. Sometimes after reading a bedtime story to my daughter and coming back with a cup of tea, knowing I’ve got an hour to make sense of a problem before sleep wins.
In those moments, the last thing I want is a system that fights me. I want code that tells me when I’m about to do something stupid. I want boundaries that protect future-me, not just present-me.
That’s what enforcement gives you.
What’s Next in the Series
Now that boundaries are enforced, the next question becomes:
How do you design inside a module?
That’s where vertical slices come in, and that’s where we’ll go next.





