DEV Community

Cover image for Refactoring Overgrown Bounded Contexts in Modular Monoliths
Milan Jovanović
Milan Jovanović

Posted on • Originally published at milanjovanovic.tech on

Refactoring Overgrown Bounded Contexts in Modular Monoliths

When you're building a modular monolith, it's easy to let bounded contexts grow too large over time. What started as a clean domain boundary slowly turns into a dumping ground for unrelated logic. Before you know it, you have a massive context responsible for users, payments, notifications, and reporting - all tangled together.

This article is about tackling that mess. We'll walk through how to identify an overgrown bounded context, and refactor it step-by-step into smaller, well-defined contexts. You'll see practical techniques in action, with real .NET code and without theoretical fluff.

Identifying an Overgrown Context

You know you have a problem when:

  • You're afraid to touch code because everything is interconnected
  • The same entity is used for 4 unrelated use cases
  • You see classes with 1000+ lines or services that do too much
  • Business logic from different subdomains bleeds into each other

Here's a classic example.

We start with a BillingContext that now handles everything from notifications to reporting:

public class BillingService
{
    public void ChargeCustomer(int customerId, decimal amount) { ... }
    public void SendInvoice(int invoiceId) { ... }
    public void NotifyCustomer(int customerId, string message) { ... }
    public void GenerateMonthlyReport() { ... }
    public void DeactivateUserAccount(int userId) { ... }
}
Enter fullscreen mode Exit fullscreen mode

This service has no clear boundaries. It mixes Billing , Notifications , Reporting , and User Management into a single, bloated class. Changing one feature could easily break another.

Step 1: Identify Logical Subdomains

We start by breaking this apart logically. Think like a product owner.

Just ask: "What domains are we really working with?"

Group the methods:

  • Billing : ChargeCustomer, SendInvoice
  • Notifications : NotifyCustomer
  • Reporting : GenerateMonthlyReport
  • User Management : DeactivateUserAccount

Code within a bounded context should model a coherent domain. When multiple domains are jammed into the same context, your architecture becomes misleading.

You can validate these groupings by checking:

  • Which parts of the system change together?
  • Do teams use different vocabulary for each area?
  • Would you give each domain to a different team?

If yes, it's a sign you're dealing with distinct contexts.

Step 2: Extract One Context at a Time

Don't try to do it all at once. Start with something low-risk.

Let's begin by extracting Notifications.

Why Notifications? Because it's a pure side-effect. It doesn't impact business state, so it's easier to decouple safely.

Create a new module and move the logic there:

// New module: Notifications
public class NotificationService
{
    public void Send(int customerId, string message) { ... }
}
Enter fullscreen mode Exit fullscreen mode

Then simplify the original BillingService:

public class BillingService
{
    private readonly NotificationService _notificationService;

    public BillingService(NotificationService notificationService)
    {
        _notificationService = notificationService;
    }

    public void ChargeCustomer(int customerId, decimal amount)
    {
        // Charge logic...
        _notificationService.Send(customerId, $"You were charged ${amount}");
    }
}
Enter fullscreen mode Exit fullscreen mode

This works. But now Billing depends on Notifications. That's a coupling we want to avoid long-term.

Why? Because a failure in Notifications could block a billing operation. It also means Billing can't evolve independently.

Let's decouple with domain events:

public class CustomerChargedEvent
{
    public int CustomerId { get; init; }
    public decimal Amount { get; init; }
}

// Module: Billing
public class BillingService
{
    private readonly IDomainEventDispatcher _dispatcher;

    public BillingService(IDomainEventDispatcher dispatcher)
    {
        _dispatcher = dispatcher;
    }

    public void ChargeCustomer(int customerId, decimal amount)
    {
        // Charge logic...
        _dispatcher.Dispatch(new CustomerChargedEvent
        {
            CustomerId = customerId,
            Amount = amount
        });
    }
}

// Module: Notifications
public class CustomerChargedEventnHandler : IDomainEventHandler<CustomerChargedEvent>
{
    public Task Handle(CustomerChargedEvent @event)
    {
        // Send notification
    }
}
Enter fullscreen mode Exit fullscreen mode

Now Billing doesn't even know about Notifications. That's real modularity. You can replace, remove, or enhance the Notifications module without touching Billing.

Step 3: Migrate Data (If Needed)

Most monoliths start with a single database. That's fine. But real modularity comes when each module controls its own schema.

Why? Because the database structure reflects ownership. If everything touches the same tables, it's hard to enforce boundaries.

You don't have to do it all at once. Start with:

  • Creating a separate DbContext per module
  • Gradually migrate the tables to their own schemas
  • Read-only projections or database views for cross-context reads
// Module: Billing
public class BillingDbContext : DbContext
{
    public DbSet<Invoice> Invoices { get; set; }
}

// Module: Notifications
public class NotificationsDbContext : DbContext
{
    public DbSet<NotificationLog> Logs { get; set; }
}
Enter fullscreen mode Exit fullscreen mode

This separation enables independent schema evolution. It also makes testing faster and safer.

When migrating, use a transitional phase where both contexts read from the same underlying data. Only switch write paths when confidence is high.

Step 4: Repeat for Other Areas

Apply the same playbook. Target a clean split per subdomain.

Next up: Reporting and User Management.

Before:

billingService.GenerateMonthlyReport();
billingService.DeactivateUserAccount(userId);
Enter fullscreen mode Exit fullscreen mode

After:

reportingService.GenerateMonthlyReport();
userService.DeactivateUser(userId);
Enter fullscreen mode Exit fullscreen mode

Or via events:

_dispatcher.Dispatch(new MonthEndedEvent());
_dispatcher.Dispatch(new UserInactiveEvent(userId));
Enter fullscreen mode Exit fullscreen mode

The goal here isn't just technical cleanliness - it's clarity. Anyone looking at your solution should know what each module is responsible for.

And remember: boundaries should be enforced by code, not just by folder structure. Different projects, separate EF models, and explicit interfaces help enforce the split.Architecture tests can also help ensure that modules don't break their boundaries.

Takeaway

Once you've finished the refactor, you'll have:

  • Smaller services focused on one job
  • Decoupled modules that evolve independently
  • Better tests and easier debugging
  • Bounded contexts that actually match the domain

This is more than structure, it's design that supports change. You get loose coupling, testability, and clearer mental models.

You don't need microservices to get modularity. You need to treat your monolith like a set of cooperating, isolated parts.

Start with one module. Ship the change. Repeat.

Want to go deeper into modular monolith design? My full video course, Modular Monolith Architecture, walks you through building a real-world system from scratch - with clear boundaries, isolated modules, and practical patterns that scale. Join 1,800+ students and start building better systems today.

That's all for today.

See you next Saturday.


P.S. Whenever you're ready, there are 3 ways I can help you:

  1. Pragmatic Clean Architecture: Join 4,000+ students in this comprehensive course that will teach you the system I use to ship production-ready applications using Clean Architecture. Learn how to apply the best practices of modern software architecture.

  2. Modular Monolith Architecture: Join 1,800+ engineers in this in-depth course that will transform the way you build modern systems. You will learn the best practices for applying the Modular Monolith architecture in a real-world scenario.

  3. Patreon Community: Join a community of 1,050+ engineers and software architects. You will also unlock access to the source code I use in my YouTube videos, early access to future videos, and exclusive discounts for my courses.

Dynatrace image

Observability should elevate – not hinder – the developer experience.

Is your troubleshooting toolset diminishing code output? With Dynatrace, developers stay in flow while debugging – reducing downtime and getting back to building faster.

Explore Observability for Developers

Top comments (0)

DevCycle image

Ship Faster, Stay Flexible.

DevCycle is the first feature flag platform with OpenFeature built-in to every open source SDK, designed to help developers ship faster while avoiding vendor-lock in.

Start shipping

👋 Kindness is contagious

Sign in to DEV to enjoy its full potential—unlock a customized interface with dark mode, personal reading preferences, and more.

Okay