Did you know that there's a way to save thousands on your Microsoft Azure bills? Archera turns cloud spend on its head with their cloud commitment insurance. They help you commit to reserved instances with confidence and write you an actual check if you underutilize your commitments. They also allow you to achieve more than the cost savings of native plans with a commitment as low as 30 days, instead of 1 to 3 years. Check out how it works with this video.
Build Your Own Token Visualizer in .NET. Want to peek under the hood of how code gets parsed? Learn how to use Roslyn to visualize tokens and better understand the structure of your C# code. Whether you're building a code analyzer, working on an IDE extension, or just diving deeper into how your tools work under the hood, this tutorial is packed with practical insights and clean, usable code examples. Check it out.
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 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; }
}
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.