Skip to main content
SEMastery
Architectureintermediate

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.

13 min readUpdated April 1, 2026

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.

A healthy modular monolith: one app, several modules, each owning its own data and showing a small public face.

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.

An overgrown Users context that quietly absorbed payments, notifications and reporting.

The warning signs

Here are the everyday clues that a context has grown too big.

Warning signWhat you notice in real life
Many reasons to changeOne module changes for billing, email and reports — all different reasons.
Small change, wide blastA tiny edit breaks tests in unrelated features.
Huge folderOne module has far more files than any other.
Mixed wordsThe same class talks about users, invoices and email templates at once.
Slow onboardingNew teammates cannot explain what the module is "for" in one sentence.
Merge painMany 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

Map
Group
Extract
Reroute
Isolate data
Clean up

Steps

1

Map

List everything the context does

2

Group

Cluster parts that change together

3

Extract

Move one chunk to a new module

4

Reroute

Call across modules via public API or events

5

Isolate data

Give the new module its own schema

6

Clean up

Delete old code, lock boundaries

Each step keeps the app working and tested.

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.

ClusterJobs inside itFuture module
Identityaccounts, login, logoutUsers
Billingstore card, charge cardPayments
Messagingwelcome email, reset emailNotifications
Analyticssign-up reportReporting
The overgrown context, regrouped into four focused modules.

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

Need an answer now?
Direct call
Just announcing?
Event

Steps

1

Need an answer now?

Yes: call the public API

2

Direct call

Inject the interface, await result

3

Just announcing?

Yes: publish an event

4

Event

Others react in their own time

Ask for the right tool for the job.

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.

Sign-up announced once; each module reacts in its own time.

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

Shared tables
New schema added
Dual write
Switch reads
Old tables retired

Steps

1

Shared tables

Both modules read the same data

2

New schema added

Payments gets its own schema

3

Dual write

Write to both old and new

4

Switch reads

Read from the new schema

5

Old tables retired

Drop the old columns

Switch reads and writes in safe stages.

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.

StageReads fromWrites toRisk
StartShared tablesShared tablesNone yet
Dual writeShared tablesBothLow
Switch readsNew schemaBothMedium
Cut overNew schemaNew schemaReversible if staged
DoneNew schemaNew schemaClean 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 internal keyword. 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 internal keyword, 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

Related Posts