Refactoring Overgrown Bounded Contexts in Modular Monoliths (.NET)
Learn how to spot and split an overgrown bounded context in a .NET modular monolith using safe, step-by-step refactoring, with diagrams, tables and code.
The overstuffed kitchen drawer
Think of the one drawer in your kitchen that holds everything. It started simple. It was just for spoons. Then someone dropped in the scissors. Then the spare keys, the rubber bands, the matchbox, the old phone charger, and a few coins.
Now every time you open it, you dig through a mess to find one spoon. Nobody planned this. Each small "I will just put it here for now" felt harmless. Over months, the spoon drawer became the everything drawer.
A bounded context in your software can grow the very same way. It starts as a clean home for one idea, like users. Then payments sneak in. Then notifications. Then reporting. Each addition felt small at the time. One day you open the module and it is a giant tangle, and a tiny change in one corner breaks something far away.
This article is about cleaning that drawer. We will learn how to spot an overgrown bounded context in a .NET modular monolith, and how to split it into smaller, tidy modules in safe steps, without breaking the app.
First, a quick word on the terms
Let us be sure about two words before we go further.
A modular monolith is one application that you build and deploy as a single unit, but inside it is divided into clear sections called modules. It is one building with many well-separated rooms.
A bounded context is a Domain-Driven Design idea. It is a boundary inside which words have one clear meaning and one team owns the rules. In a modular monolith, a module usually maps to one bounded context. So when we say "the context grew too big," we mean one module took on too many jobs.
The golden rule of a clean module is simple: a module owns its own data and shows only a small public face. Other modules talk to it through that public face, never by reaching into its private classes or tables.
How a context becomes overgrown
Nobody sets out to build a messy module. It happens one shortcut at a time.
You build a Users module. A teammate needs to store a credit card, and the User class is right there, so the card field goes onto the user. Next sprint, someone needs to send a welcome email, and again the user record is handy, so the email-sending logic lands there too. Then a manager wants a sign-up report, and the reporting query gets bolted on as well.
Now the Users module is responsible for users, payments, notifications, and reporting, all tangled together. This is the everything drawer.
The warning signs
Here are the everyday clues that a context has grown too big.
| Warning sign | What you notice in real life |
|---|---|
| Many reasons to change | One module changes for billing, email and reports — all different reasons. |
| Small change, wide blast | A tiny edit breaks tests in unrelated features. |
| Huge folder | One module has far more files than any other. |
| Mixed words | The same class talks about users, invoices and email templates at once. |
| Slow onboarding | New teammates cannot explain what the module is "for" in one sentence. |
| Merge pain | Many people edit the same module and keep clashing. |
If you tick three or more of these, your drawer is overstuffed. It is time to refactor.
The safe refactoring plan
The most important rule of refactoring is this: the app must keep working at every single step. We never do a big-bang rewrite. We move in small, reversible steps, and we run the tests after each one.
Here is the path we will follow.
Safe split of an overgrown context
Steps
Map
List everything the context does
Group
Cluster parts that change together
Extract
Move one chunk to a new module
Reroute
Call across modules via public API or events
Isolate data
Give the new module its own schema
Clean up
Delete old code, lock boundaries
Let us walk through each step.
Step 1: Map what the context really does
You cannot split what you do not understand. So first, write down every job the overgrown module performs. Do not judge yet. Just list.
For our Users module the list might be:
- create and update user accounts
- log a user in and out
- store and charge credit cards
- send welcome and reset emails
- build the weekly sign-up report
Now look for things that change together. Account creation and login change for the same reason: identity rules. Storing and charging cards change for billing reasons. Emails change for messaging reasons. Reports change for analytics reasons.
These natural clusters are your future modules.
Step 2: Group the parts that belong together
Group the jobs into clusters where everything inside a cluster changes for the same reason. This is the heart of finding a good boundary.
| Cluster | Jobs inside it | Future module |
|---|---|---|
| Identity | accounts, login, logout | Users |
| Billing | store card, charge card | Payments |
| Messaging | welcome email, reset email | Notifications |
| Analytics | sign-up report | Reporting |
We will pull these out one at a time. Pick the cluster that is least tangled first, so the early win is easy. Reporting is often a good first pick because it mostly reads data and rarely changes it.
Step 3: Extract one chunk into a new module
Now we move code. Let us extract Payments. We create a new module project and move the payment classes into it. At this point the new module can still read from the old shared tables. We are only moving code, not data, yet.
Before extraction, an order-side method might charge a card by reaching straight into payment internals living inside the user module. That is the tangle we want to remove. We start by giving Payments a clear public face.
// Payments module — the small PUBLIC face other modules may use.
public interface IPaymentService
{
Task<PaymentResult> ChargeAsync(
Guid userId,
decimal amount,
CancellationToken ct = default);
}
// Everything else in the module stays internal and hidden.
public sealed record PaymentResult(bool Succeeded, string? FailureReason);Inside the module, the real work stays internal, so no other module can see it:
// Payments module — internal implementation, invisible to other modules.
internal sealed class PaymentService : IPaymentService
{
private readonly PaymentsDbContext _db;
public PaymentService(PaymentsDbContext db) => _db = db;
public async Task<PaymentResult> ChargeAsync(
Guid userId, decimal amount, CancellationToken ct = default)
{
var card = await _db.Cards
.FirstOrDefaultAsync(c => c.UserId == userId, ct);
if (card is null)
return new PaymentResult(false, "No card on file");
// ... talk to the payment gateway here ...
return new PaymentResult(true, null);
}
}The internal keyword is doing real work. It means only IPaymentService is visible from outside the module. Nobody can poke at PaymentService or the Cards table directly.
Step 4: Reroute the calls across the boundary
Now any module that used to charge a card by hand must call through the public door instead.
There are two clean ways to talk across a module boundary, and you pick based on whether you need an answer right now.
Choosing how modules talk
Steps
Need an answer now?
Yes: call the public API
Direct call
Inject the interface, await result
Just announcing?
Yes: publish an event
Event
Others react in their own time
A direct call is best when you must wait for the result. The Orders module injects IPaymentService and awaits the charge:
// Orders module calling the Payments public API directly.
internal sealed class PlaceOrderHandler
{
private readonly IPaymentService _payments;
public PlaceOrderHandler(IPaymentService payments) => _payments = payments;
public async Task<bool> HandleAsync(PlaceOrder cmd, CancellationToken ct)
{
var result = await _payments.ChargeAsync(cmd.UserId, cmd.Total, ct);
return result.Succeeded;
}
}An integration event is best when you only want to announce that something happened and you do not care who reacts. When a user signs up, Users publishes an event. Notifications sends a welcome email and Reporting updates its counts, each in its own time.
// A public integration event — the contract that crosses modules.
public sealed record UserSignedUp(Guid UserId, string Email, DateTime SignedUpAt);
// Notifications module reacts on its own.
internal sealed class SendWelcomeEmail : IIntegrationEventHandler<UserSignedUp>
{
public Task Handle(UserSignedUp e, CancellationToken ct)
{
// queue a welcome email for e.Email
return Task.CompletedTask;
}
}One important timing rule: publish an integration event after the database transaction commits. You do not want other modules reacting to a change that later gets rolled back.
A quick note on tools. The popular libraries MediatR and MassTransit are now commercially licensed for many uses, so check the license before adopting them on a paid project. For an in-process event bus you can also use a free option like Wolverine, or write a small dispatcher yourself. The pattern matters more than the brand.
Step 5: Give the new module its own data
So far Payments still reads the old shared tables. Real modularity arrives only when each module fully owns its own data and no one else touches its tables.
Do this move carefully, because data is the riskiest part. Use a transitional phase where both contexts read from the same underlying data. Only switch the write path to the new schema when your confidence is high.
Moving data without downtime
Steps
Shared tables
Both modules read the same data
New schema added
Payments gets its own schema
Dual write
Write to both old and new
Switch reads
Read from the new schema
Old tables retired
Drop the old columns
Once the data lives in its own schema, the Payments module is truly independent. Other modules can no longer join across to its tables, even by accident. If they need payment data, they must ask through IPaymentService or listen to a payment event.
| Stage | Reads from | Writes to | Risk |
|---|---|---|---|
| Start | Shared tables | Shared tables | None yet |
| Dual write | Shared tables | Both | Low |
| Switch reads | New schema | Both | Medium |
| Cut over | New schema | New schema | Reversible if staged |
| Done | New schema | New schema | Clean boundary |
Step 6: Clean up and lock the boundary
The last step is to delete the old payment code from the Users module and remove any dead columns. Now Users is small again and only does identity work. The everything drawer is finally just the spoon drawer.
But how do we stop it filling up again? Good intentions are not enough. We add guardrails that the build enforces for us.
- A project per module. Put each module in its own C# project. The compiler then physically refuses a reference that should not exist.
- The
internalkeyword. Keep implementation types internal so only the public API is visible across the boundary. - Architecture tests. Use a tool like NetArchTest to write a test that fails the build if one module references another module's internals.
// An architecture test that protects the boundary.
[Fact]
public void Users_module_must_not_depend_on_Payments_internals()
{
var result = Types.InAssembly(UsersAssembly)
.That()
.ResideInNamespace("Company.Users")
.ShouldNot()
.HaveDependencyOn("Company.Payments.Internal")
.GetResult();
Assert.True(result.IsSuccessful, "Users reached into Payments internals!");
}With these three guards in place, the next person who tries to drop scissors into the spoon drawer gets a red build instead of a silent mess.
A small reality check
Splitting a context is good, but do not overdo it. Tiny modules that constantly call each other can be as painful as one giant module. Aim for boundaries where most changes stay inside one module. If two modules always change together, they probably belong as one. The goal is calm, not a high module count.
Refactor when the pain is real: many reasons to change, wide blast radius, slow onboarding. If a context is large but still cohesive and easy to reason about, leave it alone. Refactoring is a tool, not a trophy.
Quick recap
- A bounded context grows overgrown the way a kitchen drawer fills with junk: one harmless shortcut at a time.
- Warning signs include many reasons to change, small edits with a wide blast radius, a huge folder, and slow onboarding.
- Refactor in small, safe steps, keeping the app working and tested after each one.
- Map the jobs, group the parts that change together, extract one chunk into a new module, reroute the calls, isolate the data, then clean up.
- Cross-module talk uses a direct call when you need an answer now, or an integration event when you only want to announce something. Publish events after the transaction commits.
- Move data last and carefully, using a transitional dual-write phase before the final cut over.
- Lock the boundary with a project per module, the
internalkeyword, and architecture tests so the context cannot quietly overgrow again. - Do not over-split. Aim for boundaries where most changes stay inside one module.
References and further reading
- Modular monoliths — Microsoft .NET architecture guidance
- Refactoring Overgrown Bounded Contexts in Modular Monoliths — Milan Jovanović
- Modular Monolith Architecture in .NET — Complete Guide — Milan Jovanović
- Modular Monolith with DDD — sample repository by Kamil Grzybek
- Passing data between bounded contexts — The Reformed Programmer
Related Posts
Modular Monolith Communication Patterns in .NET (2026 Guide)
Learn how modules talk to each other in a .NET modular monolith using public APIs and integration events, with simple diagrams, code, and clear rules.
How to Keep Your Data Boundaries Intact in a Modular Monolith (.NET)
Learn simple, practical ways to keep data boundaries strong in a .NET modular monolith using separate schemas, one DbContext per module, and events instead of cross-module joins.
Internal vs Public APIs in Modular Monoliths (.NET Guide)
Learn the difference between internal and public APIs in a .NET modular monolith, why module boundaries matter, and how to expose only safe contracts to other modules.
Where Vertical Slices Fit Inside the Modular Monolith
A simple guide to how vertical slices live inside the modules of a modular monolith in .NET, with diagrams, code, tables, and everyday examples.
Monolith to Microservices: How a Modular Monolith Helps
Learn how a modular monolith makes the move from monolith to microservices safe and easy in .NET, using clean boundaries, the Strangler Fig pattern, and small steps.
Breaking It Down: How to Migrate Your Modular Monolith to Microservices
A friendly, step-by-step guide to safely move from a .NET modular monolith to microservices using the Strangler Fig pattern, without a risky big-bang rewrite.