Introduction
When starting with microservices, developers often encounter complexity in deployment, data consistency, and testing. But what if you could structure your app like microservices—modular, isolated, well-organized—without the overhead of distributed systems?
That’s the idea behind a Modular Monolith.
This guide will teach you how to build a modular monolith in .NET Core, with:
- Database-per-module schema
- Per-module configuration
- Outbox & Inbox event patterns
- Domain event dispatching
- Beginner-friendly advice and examples
1. What Is a Modular Monolith?
A Modular Monolith is a single application broken into feature-based modules, each with:
- Its own domain logic
- Its own data schema
- Its own application services
- Its own event handlers
Everything is in one codebase and deployment, but behaves as if they’re separate services.
2. Solution Structure
Your solution should separate concerns clearly per module:
/src
/ModularMonolith.WebApi → entry point (host)
/Modules
/Articles
/Application → use cases & services
/Domain → entities, value objects, domain events
/Infrastructure → EF Core, repositories
/API → controllers (optional)
/Outbox → outbox logic
3. Project Setup
dotnet new sln -n ModularMonolithDemo
mkdir src
cd src
dotnet new webapi -n ModularMonolith.WebApi
dotnet sln ../ModularMonolithDemo.sln add ModularMonolith.WebApi
Now, create class libraries:
dotnet new classlib -n Modules.Articles.Domain
dotnet new classlib -n Modules.Articles.Application
dotnet new classlib -n Modules.Articles.Infrastructure
Add references between them:
Application → Domain
Infrastructure → Application, Domain
Best Practice: Think in Modules
Ask yourself:
- What domain language do I need? (e.g. "Article", "Publish", "Author")
- What does each module own?
- Can this module be tested without others?
4. Per-Module Database Schema
Use PostgreSQL schemas or SQL Server schemas to isolate tables.
SQL (PostgreSQL):
CREATE SCHEMA articles;
CREATE TABLE articles.articles (
id UUID PRIMARY KEY,
title TEXT NOT NULL,
content TEXT NOT NULL,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP
);
In EF Core:
modelBuilder.HasDefaultSchema("articles");
modelBuilder.Entity<Article>().ToTable("articles");
⚙️ 5. Module-Level Configuration
Use an extension method to isolate module config.
ArticlesModule.cs
:
public static class ArticlesModule
{
public static IServiceCollection AddArticlesModule(this IServiceCollection services, IConfiguration config)
{
services.AddDbContext<ArticlesDbContext>(options =>
options.UseNpgsql(config.GetConnectionString("DefaultConnection")));
services.AddScoped<IArticleService, ArticleService>();
return services;
}
}
Then in Program.cs
:
builder.Services.AddArticlesModule(builder.Configuration);
6. Create an Article (Service Example)
In the Application layer:
public class CreateArticleCommand
{
public string Title { get; set; }
public string Content { get; set; }
}
public class ArticleService : IArticleService
{
private readonly ArticlesDbContext _db;
private readonly IDomainEventDispatcher _dispatcher;
public async Task CreateArticleAsync(CreateArticleCommand command)
{
var article = new Article(command.Title, command.Content);
_db.Articles.Add(article);
await _dispatcher.AddAsync(new ArticleCreatedEvent(article.Id, article.Title));
await _db.SaveChangesAsync();
}
}
7. Outbox Pattern (Event Publishing)
Why Outbox?
To guarantee that events aren't lost, especially when publishing to a message bus.
Step-by-step:
1. Create Outbox Table:
CREATE TABLE articles.outbox_messages (
id UUID PRIMARY KEY,
type VARCHAR(250) NOT NULL,
payload TEXT NOT NULL,
occurred_on TIMESTAMP NOT NULL,
processed_on TIMESTAMP NULL
);
2. Add Message to Outbox:
public class OutboxMessage
{
public Guid Id { get; set; }
public string Type { get; set; }
public string Payload { get; set; }
public DateTime OccurredOn { get; set; }
public DateTime? ProcessedOn { get; set; }
}
public class DomainEventDispatcher : IDomainEventDispatcher
{
private readonly ArticlesDbContext _db;
public Task AddAsync<T>(T domainEvent) where T : class
{
var message = new OutboxMessage
{
Id = Guid.NewGuid(),
Type = domainEvent.GetType().Name,
Payload = JsonSerializer.Serialize(domainEvent),
OccurredOn = DateTime.UtcNow
};
_db.OutboxMessages.Add(message);
return Task.CompletedTask;
}
}
8. Background Worker for Outbox Dispatch
public class OutboxPublisherWorker : BackgroundService
{
private readonly IServiceScopeFactory _scopeFactory;
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ArticlesDbContext>();
var messages = await db.OutboxMessages
.Where(x => x.ProcessedOn == null)
.ToListAsync();
foreach (var message in messages)
{
// Here you would publish to RabbitMQ, Kafka, etc.
Console.WriteLine($"[Outbox] Publishing event {message.Type}");
message.ProcessedOn = DateTime.UtcNow;
}
await db.SaveChangesAsync();
await Task.Delay(3000);
}
}
}
9. Inbox Pattern for Idempotent Event Handling
Add InboxMessage
table:
CREATE TABLE articles.inbox_messages (
id UUID PRIMARY KEY,
message_type VARCHAR(250),
received_on TIMESTAMP,
processed_on TIMESTAMP
);
Before processing any event:
- Check if it exists in
inbox_messages
- If yes → skip
- If not → process and save to inbox
10. Events Between Modules
Example:
ArticlePublishedEvent
(in Domain):
public record ArticlePublishedEvent(Guid Id, string Title) : IDomainEvent;
Handler (in Notifications module):
public class SendNotificationHandler : IEventHandler<ArticlePublishedEvent>
{
public async Task HandleAsync(ArticlePublishedEvent @event)
{
// Send an email, log to audit table, etc.
}
}
Advice for Junior Developers
Start with 2 modules, like:
Articles
Notifications
Focus on:
- Separation of responsibilities
- Using domain language
- Consistent naming (
Domain
,Application
, etc.)
Add unit tests per module using xUnit
Don’t jump to microservices too early—modular monolith scales better initially
Resources
📌 Final Thoughts
In this guide, you’ve learned how to:
✅ Build a Modular Monolith in .NET Core
✅ Structure your app using Clean Architecture principles
✅ Implement Outbox/Inbox patterns for reliable event handling
✅ Keep your app modular, testable, and future-proof
💡 This architecture gives you the best of both worlds—the simplicity of a monolith with the boundaries and scalability of microservices, without the distributed headaches.
🤝 Stay Connected
If you found this helpful and want more content like this (clean .NET, architecture tips, and backend patterns), feel free to connect and follow me:
🔗 LinkedIn – Hamza Zeryouh
💻 GitHub – @hamzazeryouh
🧠 I regularly share insights and examples on .NET Core, clean architecture, and real-world dev practices.
Top comments (0)