DEV Community

Cover image for From Zero to Modular Monolith in .NET Core: Master Event-Driven Architecture
hamza zeryouh
hamza zeryouh

Posted on

From Zero to Modular Monolith in .NET Core: Master Event-Driven Architecture

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-organizedwithout 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Add references between them:

Application → Domain
Infrastructure → Application, Domain
Enter fullscreen mode Exit fullscreen mode

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
);
Enter fullscreen mode Exit fullscreen mode

In EF Core:

modelBuilder.HasDefaultSchema("articles");
modelBuilder.Entity<Article>().ToTable("articles");
Enter fullscreen mode Exit fullscreen mode

⚙️ 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;
    }
}
Enter fullscreen mode Exit fullscreen mode

Then in Program.cs:

builder.Services.AddArticlesModule(builder.Configuration);
Enter fullscreen mode Exit fullscreen mode

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();
    }
}
Enter fullscreen mode Exit fullscreen mode

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
);
Enter fullscreen mode Exit fullscreen mode

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; }
}
Enter fullscreen mode Exit fullscreen mode
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;
    }
}
Enter fullscreen mode Exit fullscreen mode

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);
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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
);
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

Handler (in Notifications module):

public class SendNotificationHandler : IEventHandler<ArticlePublishedEvent>
{
    public async Task HandleAsync(ArticlePublishedEvent @event)
    {
        // Send an email, log to audit table, etc.
    }
}
Enter fullscreen mode Exit fullscreen mode

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)