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) { ... }
}
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) { ... }
}
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}");
}
}
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
}
}
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 moduleGradually 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; }
}
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);
After:
reportingService.GenerateMonthlyReport();
userService.DeactivateUser(userId);
Or via events:
_dispatcher.Dispatch(new MonthEndedEvent());
_dispatcher.Dispatch(new UserInactiveEvent(userId));
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.
Whenever you're ready, there are 4 ways I can help you:
(NEW) Pragmatic REST APIs: You will learn how to build production-ready REST APIs using the latest ASP.NET Core features and best practices. It includes a fully functional UI application that we'll integrate with the REST API.
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.
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.
Patreon Community: Join a community of 1,000+ 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.
Subscribe to my newsletter
Read articles from Milan Jovanović directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by

Milan Jovanović
Milan Jovanović
I'm a seasoned software architect and Microsoft MVP for Developer Technologies. I talk about all things .NET and post new YouTube videos every week.