Domain-Driven Design has a reputation for being abstract and intimidating. If you've read the DDD literature and come away thinking "okay, but how do I actually build this in C#?" -- you're not alone. The good news is that DDD bounded contexts in modular monolith C# is one of the most natural and approachable applications of DDD principles out there. You get the core benefits of DDD without the operational complexity of microservices.
This guide cuts through the theory and gets practical. You'll see real code from a working modular monolith project -- actual domain entities, integration events, and cross-context handlers. By the end, you'll know how bounded contexts map to modules, how to enforce context boundaries in C#, and how contexts communicate without polluting each other.
What Is a Bounded Context?
A bounded context is a boundary within which a specific domain model is defined and consistently applicable. Inside the boundary, every term, rule, and concept has a precise, agreed-upon meaning. Outside the boundary, the same word might mean something completely different -- and that's fine, because it lives in a different context.
Eric Evans introduced this concept in the original DDD book, but the idea is simpler than most people make it sound. Here's a concrete example: the word "user" means different things depending on who you ask. To your authentication context, a user is a set of credentials and permissions. To your notifications context, a user is an email address and a name. To your billing context, a user is an account with a payment method. These are the same human being in the real world, but they are different concepts in different contexts.
Forcing a single User class to serve all three contexts is the root cause of an enormous amount of complexity in enterprise systems. A bounded context says: define your model in isolation, within a clear boundary, and let different contexts have different (but internally consistent) views of the world.
The Module = Bounded Context Mapping
Here's the key insight that makes DDD practical in a modular monolith: each module in your system IS exactly one bounded context.
This isn't a loose analogy -- it's a structural rule. Every module in the modular monolith we're working with maps precisely to one bounded context:
Projectsmodule -- Projects bounded contextTasksmodule -- Tasks bounded contextUsersmodule -- Users bounded contextNotificationsmodule -- Notifications bounded context
Each module has its own Domain layer with its own domain model. No two modules share a domain entity. The module boundary IS the context boundary.
This makes the bounded context concept immediately tangible. Instead of drawing conceptual lines on a whiteboard, you draw them in your project structure and enforce them with C# internal access modifiers.
Defining Your Bounded Contexts
How do you figure out what your bounded contexts (and therefore your modules) should be? Three signals to look for:
- Cohesion -- Which concepts naturally belong together?
TaskItem,TaskStatus, and task assignment logic belong together. They don't belong in the same module as user authentication or project billing. - Language -- Which parts of your system have their own distinct vocabulary? If the word "task" means a completely different data structure in different parts of your codebase, those parts belong in separate contexts.
- Ownership -- Which team or feature area is responsible for what? Organizational boundaries often reveal natural context boundaries.
In our system, this analysis produces four distinct contexts. Projects owns the concept of a project and its lifecycle (active, completed, cancelled). Tasks owns individual work items and their assignment and completion workflow. Users owns identity and profile data. Notifications owns message delivery -- it doesn't care about business rules for projects or tasks, only that something happened that a user should know about.
Notice that Notifications is a consumer context. It reacts to events from other contexts but holds no business logic about those domains. That's a real bounded context -- just a thin one.
Domain Model Within a Context
Once you've identified your bounded contexts, each one gets its own domain model. In C#, this lives in the *.Domain project of each module. The critical rule: domain entities are internal to their context. Nothing outside the module can reference them directly.
Here's the Project entity from the Projects module:
namespace Projects.Domain;
internal sealed class Project
{
public Guid Id { get; private set; }
public string Name { get; private set; } = string.Empty;
public string Description { get; private set; } = string.Empty;
public ProjectStatus Status { get; private set; }
public DateTime CreatedAt { get; private set; }
private Project() { }
public static Project Create(string name, string description)
{
if (string.IsNullOrWhiteSpace(name))
{
throw new ArgumentException("Project name cannot be empty", nameof(name));
}
return new Project
{
Id = Guid.NewGuid(),
Name = name,
Description = description ?? string.Empty,
Status = ProjectStatus.Active,
CreatedAt = DateTime.UtcNow
};
}
public void Complete()
{
Status = ProjectStatus.Completed;
}
public void Cancel()
{
Status = ProjectStatus.Cancelled;
}
}
Notice several things here. The class is internal sealed -- it cannot be referenced from outside the Projects.Domain assembly. The constructor is private, forcing creation through the Create factory method. Private setters mean state changes only happen through explicit methods like Complete() and Cancel(). This is a pure C# class with zero framework dependencies.
No EF Core attributes. No [JsonProperty]. No data annotations. The domain model is completely isolated from infrastructure concerns. The Factory Method pattern used here enforces invariants at creation time -- you can never create a Project with an empty name.
The same pattern applies in the Tasks module:
namespace Tasks.Domain;
internal sealed class TaskItem
{
public Guid Id { get; private set; }
public Guid ProjectId { get; private set; }
public string Title { get; private set; } = string.Empty;
public string Description { get; private set; } = string.Empty;
public TaskStatus Status { get; private set; }
public Guid? AssignedUserId { get; private set; }
public DateTime CreatedAt { get; private set; }
private TaskItem() { }
public static TaskItem Create(Guid projectId, string title, string description)
{
if (string.IsNullOrWhiteSpace(title))
{
throw new ArgumentException("Task title cannot be empty", nameof(title));
}
if (projectId == Guid.Empty)
{
throw new ArgumentException("Project ID is required", nameof(projectId));
}
return new TaskItem
{
Id = Guid.NewGuid(),
ProjectId = projectId,
Title = title,
Description = description ?? string.Empty,
Status = TaskStatus.Todo,
CreatedAt = DateTime.UtcNow
};
}
public void Assign(Guid userId)
{
if (userId == Guid.Empty)
{
throw new ArgumentException("User ID is required", nameof(userId));
}
AssignedUserId = userId;
if (Status == TaskStatus.Todo)
{
Status = TaskStatus.InProgress;
}
}
public void Complete()
{
Status = TaskStatus.Completed;
}
}
TaskItem is internal sealed in the Tasks.Domain namespace -- completely separate from Project. These two domain entities live in separate bounded contexts and cannot reference each other. The Assign method encapsulates the business rule that assigning a task automatically moves it from Todo to InProgress. That rule lives here, in the domain object, not in a service or a controller.
The Ubiquitous Language Principle
One of the most powerful -- and most often ignored -- ideas in DDD is ubiquitous language. Every bounded context should have its own consistent vocabulary, and that vocabulary should be used uniformly in code, conversations, and documentation within that context.
This matters because the same word often means different things in different contexts. In the Tasks module, a "task" is a rich entity with status, assignments, descriptions, and a full lifecycle. In the Projects module, a "task" might just be a count ("this project has 5 open tasks") -- a simple integer derived from a query, not a domain object at all.
If you try to share a single concept of "task" across both contexts, you end up with a bloated entity that serves nobody well -- every property is optional, every method has awkward conditional logic, and every change to the task concept for one context breaks the other.
The solution is to let each context define its terms. If the Projects context only needs a task count, it works with a count. If the Tasks context needs a full task entity, it gets one. They don't need to agree on a shared Task class.
This is also why you might reach for patterns like the Builder pattern in C# when constructing complex domain objects -- your ubiquitous language should extend all the way down to how you construct and configure domain models within a context.
The Shared Kernel: What Goes in Contracts
If modules can't reference each other's domain objects, how do they communicate? This is where the *.Contracts projects come in. Contracts are the one thing modules DO share -- but only integration events and data transfer objects, never domain entities.
Here's the ProjectCreatedEvent from Projects.Contracts:
using MediatR;
namespace Projects.Contracts;
public sealed record ProjectCreatedEvent(Guid ProjectId, string Name, string Description) : INotification;
And from Tasks.Contracts:
using MediatR;
namespace Tasks.Contracts;
public sealed record TaskAssignedEvent(Guid TaskId, Guid ProjectId, Guid UserId, string TaskTitle) : INotification;
public sealed record TaskCompletedEvent(Guid TaskId, Guid ProjectId, string TaskTitle) : INotification;
Notice that domain entities are internal sealed class, while contracts are public sealed record. Contracts are deliberately public because they need to cross context boundaries. But they carry no behavior -- they're just data snapshots of what happened.
Why can contracts contain data from domain entities but not the entities themselves? Because domain entities carry business logic. If you pass Tasks.Domain.TaskItem to the Notifications module, you've given Notifications access to Assign() and Complete(). Notifications has no business calling those methods. Worse, if you change the TaskItem entity, you've potentially broken Notifications. The contracts layer is a deliberate firewall. It says: here's the data you're allowed to see, nothing more.
This connects closely to the Observer pattern -- the contracts are essentially the "event" type in an observer system, and MediatR's INotification is the mechanism that wires publishers to subscribers. Understanding how the Observer and Mediator patterns compare helps clarify exactly how this cross-context communication pattern works at the design level.
Context Mapping: How Contexts Communicate
Once you have contracts, contexts communicate by publishing events and subscribing to them. The publisher doesn't know who's listening -- it just fires the event. Subscribers react independently. This is how cross-context communication preserves context autonomy.
Here's the Notifications module's handler for TaskAssignedEvent:
using MediatR;
using Tasks.Contracts;
namespace Notifications.Application.Handlers;
internal sealed class TaskAssignedEventHandler : INotificationHandler<TaskAssignedEvent>
{
private readonly INotificationStore _notificationStore;
public TaskAssignedEventHandler(INotificationStore notificationStore)
{
_notificationStore = notificationStore;
}
public Task Handle(TaskAssignedEvent notification, CancellationToken cancellationToken)
{
var message = $"Task assigned: {notification.TaskTitle} assigned to user {notification.UserId}";
_notificationStore.Add(message);
return Task.CompletedTask;
}
}
The handler itself is internal sealed -- it lives inside the Notifications module and is invisible to the outside world. The only external dependency is Tasks.Contracts, which is explicitly allowed because contracts are the public API between contexts. There is zero reference to Tasks.Domain. The Notifications context knows only what it needs to know: that a task was assigned, which task, and to whom.
The Tasks module publishes the event. It doesn't know that Notifications is listening. This is a clean context map -- the relationship is called a Customer/Supplier or Conformist pattern in DDD terminology, where Notifications conforms to the events that Tasks publishes.
When you're thinking about how events flow between modules, it's worth keeping the distinction between Observer and Mediator patterns clear. MediatR acts as the mediator here -- but the conceptual model is event-driven, which is closer to observer.
Anti-Pattern: Sharing Domain Objects Across Contexts
Let's be explicit about what NOT to do, because this mistake is common.
Suppose you get tired of creating TaskAssignedEvent and you decide to just pass Tasks.Domain.TaskItem directly into the Notifications module. The handler signature becomes:
// ❌ DON'T DO THIS
public Task Handle(Tasks.Domain.TaskItem taskItem, ...)
{
// Notifications now knows about TaskStatus, Assign(), Complete()...
// None of which it should care about
}
You've now coupled Notifications to the Tasks domain model. Every change to TaskItem potentially breaks the Notifications handler. The internal access modifier on TaskItem should prevent this at compile time -- but if your project structure allows it (e.g., by referencing Tasks.Domain directly), the compiler won't save you.
The rule is simple: if it's in a *.Domain project, it's internal. If it needs to cross a context boundary, create a contract for it. The friction of writing a separate contract class is intentional -- it forces you to be deliberate about what you're exposing.
You might also encounter the anti-pattern of passing domain service interfaces across context boundaries. The Strategy pattern in C# is commonly used inside a single bounded context for polymorphic behavior -- but strategies that encode business logic should never escape their context boundary any more than domain entities should.
Practical DDD Without Going Overboard
DDD has a lot of concepts: aggregates, aggregate roots, value objects, repositories, domain services, domain events, factories. Not every project needs all of them. In fact, reaching for all of them at once is a reliable way to end up with over-engineered code that's harder to work with than what you started with.
The modular monolith approach gives you a practical starting point:
- Start with module boundaries -- Get the context boundaries right first. This is the highest-leverage decision in DDD. Everything else is refinement.
- Use entities and factory methods --
Project.Create(),TaskItem.Create(),User.Create()with validation logic inside. This is simple and it works. - Use integration events for cross-context communication -- MediatR's
INotificationwith*.Contractsprojects is all you need to start. - Add repositories when you need them -- A repository interface in the Application layer with an EF Core implementation in Infrastructure is the right structure. Don't skip this if you have meaningful persistence logic.
- Add value objects when you see string/primitive obsession -- If
Emailshows up in five places as a plainstring, wrap it in a value object with validation. Don't do it preemptively.
You don't need aggregate roots on day one. You don't need domain events (as opposed to integration events) on day one. The Decorator pattern is useful inside a context when you need to layer behaviors on top of domain services -- but again, only when the need is clear.
Start with the fundamentals: clean module boundaries, internal domain models, public contracts, and event-driven cross-context communication. You'll be in great shape, and you can add more DDD machinery as complexity demands it.
Frequently Asked Questions
What is the difference between a module and a bounded context in a modular monolith?
In the architecture shown here, they're the same thing. Each module IS exactly one bounded context. The module boundary (enforced by project references and internal access modifiers) is the bounded context boundary. This 1:1 mapping keeps things clear and manageable. In more complex systems, you might occasionally have a module that contains multiple bounded contexts, but that's an advanced scenario to grow into, not start with.
Should bounded contexts share a database schema?
They can -- and in a modular monolith, they often share a single database instance. But each context should own its own tables. The Projects module writes to the projects table. The Tasks module writes to the tasks table. Neither module queries the other's tables directly. Cross-context queries happen through integration events or explicit API calls, not through JOIN queries across module tables.
Can two bounded contexts reference the same database entity ID?
Yes, and this is normal. TaskItem in the Tasks context has a ProjectId (Guid) that references a project. It doesn't reference the Project entity -- it holds a foreign key as a primitive. If the Tasks context needs project information, it requests it through an event or API, not by loading the Project domain object.
How do I handle transactions that span multiple bounded contexts?
Short answer: you mostly don't. If an operation must be atomic across two contexts, that's usually a sign that those concepts actually belong in the same context. When you genuinely need cross-context consistency, use the outbox pattern (write the event to the same DB transaction as the domain change, process it asynchronously). This is eventual consistency by design -- and that's a feature, not a bug.
Do I need an anti-corruption layer (ACL) between contexts?
In a modular monolith where you control all the code, ACLs are usually not necessary. You control both the producer (event publisher) and consumer (event handler), so you can design the contracts cleanly. An ACL makes more sense when integrating with external systems where you don't control the data model and need to translate between external concepts and your own domain language.
Should I use MediatR for all cross-context communication?
MediatR is a good fit for in-process cross-context events. If your modular monolith might eventually split into services, you'll want to abstract this behind an event bus interface so you can swap MediatR for a message broker later. But for most monolith use cases, MediatR is a pragmatic choice that's easy to understand and test.
When does a modular monolith need value objects vs plain primitives?
Start with primitives. When you find yourself validating the same primitive type in multiple places -- checking that an email is non-empty AND properly formatted in three different methods -- that's when you create a value object. Value objects are not the first tool you reach for; they're the tool you introduce when primitive obsession starts creating real maintenance pain.
Conclusion
DDD bounded contexts in modular monolith C# is not about turning your codebase into a dissertation on domain theory. It's about one concrete, powerful idea: draw clean boundaries in your code, enforce them with C# access modifiers and project references, let each bounded context own its domain model completely, and communicate across boundaries only through public contracts.
The Projects, Tasks, Users, and Notifications modules in the reference app each demonstrate this clearly. Domain entities are internal sealed. Contracts are public sealed record. Event handlers are internal to their module but subscribe to public contract types. Context boundaries are structural, not just conceptual.
Start with the fundamentals -- modules as bounded contexts, internal domain models, public contracts, event-driven communication -- and add more DDD sophistication only as complexity demands it. That's practical DDD, and it works.

