software developer, web developer, programmer

Fowler’s 2015 “MonolithFirst” note observed that almost every successful microservice system he knew of began as a monolith that grew too big, and that most systems built as microservices from scratch ran into serious trouble.

 

That is anecdote, not measured data. It still points to a lower-risk start: one deployable unit with strict internal modules. This article shows how to build one in .NET, how to keep its boundaries honest, and when to extract a service.

 

Why start with a monolith

  • YAGNI: early on you don’t know if anyone wants the product. You need fast feedback, and the “microservice premium” slows a small team.
  • Unstable boundaries: a new domain keeps changing. Early splits create arbitrary seams in a distributed system.
  • Operational load: microservices add service discovery, tracing, inter-service auth, gateways and orchestration. A monolith has one pipeline, one log destination and one process to debug.
  Monolith Modular monolith Microservices
Deployables 1 1 Many
Debugging One process One process Cross-network, needs tracing
Consistency One transaction possible Transactions inside a module; across modules, an outbox and eventual consistency Eventual consistency
Moving boundaries Cheap, easy to tangle Cheap Expensive

One 2026 vendor guide suggests a modular monolith under about 15 developers and microservices at 50 or more.

Those are heuristics, not rules. A 2014 article, “Don’t start with a monolith,” argued that a team that can’t build a well-structured monolith can’t build well-structured services. The real risk is poor modularity.

engineer, engineering, structural engineer
Image by This_is_Engineering from Pixabay

Build a modular monolith in .NET

Requires .NET 8+ (C# 12 primary constructors). Packages: Microsoft.EntityFrameworkCore.SqlServer, Microsoft.Extensions.Http.Resilience, xUnit. Each class library needs usings for Microsoft.EntityFrameworkCore, Microsoft.Extensions.DependencyInjection, Microsoft.Extensions.Hosting, Microsoft.Extensions.Logging, System.Text.Json and System.Net.Http.Json as needed.

 

src/
  Host/               # minimal API; references modules and contracts
  SharedKernel/       # IDomainEvent, IEventHandler, event bus
  Orders.Contracts/   # IOrdersApi, DTOs, OrderPlaced (public)
  Orders/             # internal DbContext, logic
  Billing/            # references Orders.Contracts only
tests/ArchitectureTests/

Modules talk through contracts or events.

Each module has its own DbContext and schema, so there is no automatic transaction across modules.

To avoid losing events, Orders writes the event to an outbox table in the same SaveChanges as the order.

A background dispatcher publishes it afterwards.

Delivery is at-least-once, so handlers must be idempotent. A broker will give you the same contract later.

 

// SharedKernel
public interface IDomainEvent { }
public interface IEventHandler<in T> where T : IDomainEvent
{
    Task HandleAsync(T e, CancellationToken ct);
}
public sealed class InProcessEventBus(IServiceProvider sp)
{
    public async Task PublishAsync<T>(T e, CancellationToken ct) where T : IDomainEvent
    {
        foreach (var h in sp.GetServices<IEventHandler<T>>())
            await h.HandleAsync(e, ct);
    }
}

// Orders.Contracts
public sealed record OrderDto(Guid Id, decimal Total, string Status);
public sealed record OrderPlaced(Guid OrderId, decimal Total) : IDomainEvent;
public interface IOrdersApi
{
    Task<OrderDto?> GetOrderAsync(Guid id, CancellationToken ct);
    Task<Guid> PlaceOrderAsync(decimal total, CancellationToken ct);
}

// Orders (internal)
internal sealed class OrderEntity
{
    public Guid Id { get; set; }
    public decimal Total { get; set; }
    public string Status { get; set; } = "Placed";
}
internal sealed class OutboxMessage
{
    public Guid Id { get; set; }
    public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
    public string Payload { get; set; } = "";
    public DateTime? ProcessedAt { get; set; }
}
internal sealed class OrdersDbContext(DbContextOptions<OrdersDbContext> options) : DbContext(options)
{
    public DbSet<OrderEntity> Orders => Set<OrderEntity>();
    public DbSet<OutboxMessage> Outbox => Set<OutboxMessage>();
    protected override void OnModelCreating(ModelBuilder b) => b.HasDefaultSchema("orders");
}
internal sealed class OrdersApi(OrdersDbContext db) : IOrdersApi
{
    public Task<OrderDto?> GetOrderAsync(Guid id, CancellationToken ct) =>
        db.Orders.AsNoTracking().Where(o => o.Id == id)
            .Select(o => new OrderDto(o.Id, o.Total, o.Status))
            .FirstOrDefaultAsync(ct);

    public async Task<Guid> PlaceOrderAsync(decimal total, CancellationToken ct)
    {
        var order = new OrderEntity { Id = Guid.NewGuid(), Total = total };
        db.Orders.Add(order);
        db.Outbox.Add(new OutboxMessage
        {
            Id = Guid.NewGuid(),
            Payload = JsonSerializer.Serialize(new OrderPlaced(order.Id, total))
        });
        await db.SaveChangesAsync(ct); // order and event commit together
        return order.Id;
    }
}
internal sealed class OutboxDispatcher(IServiceScopeFactory scopes, ILogger<OutboxDispatcher> log) : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken ct)
    {
        using var timer = new PeriodicTimer(TimeSpan.FromSeconds(2));
        while (await timer.WaitForNextTickAsync(ct))
        {
            using var scope = scopes.CreateScope();
            var db = scope.ServiceProvider.GetRequiredService<OrdersDbContext>();
            var bus = scope.ServiceProvider.GetRequiredService<InProcessEventBus>();
            var pending = await db.Outbox.Where(m => m.ProcessedAt == null)
                .OrderBy(m => m.CreatedAt).Take(20).ToListAsync(ct);
            foreach (var m in pending)
            {
                try
                {
                    await bus.PublishAsync(JsonSerializer.Deserialize<OrderPlaced>(m.Payload)!, ct);
                    m.ProcessedAt = DateTime.UtcNow;
                    await db.SaveChangesAsync(ct);
                }
                catch (Exception ex) when (ex is not OperationCanceledException)
                {
                    log.LogError(ex, "Outbox message {Id} failed; will retry", m.Id);
                }
            }
        }
    }
}
public static class OrdersModule
{
    public static IServiceCollection AddOrdersModule(this IServiceCollection s, string cs)
    {
        s.AddDbContext<OrdersDbContext>(o =>
            o.UseSqlServer(cs, sql => sql.MigrationsHistoryTable("__EFMigrationsHistory", "orders")));
        s.AddScoped<IOrdersApi, OrdersApi>();
        s.AddHostedService<OutboxDispatcher>();
        return s;
    }
}

Billing reacts to OrderPlaced and sees only Orders.Contracts:

// Billing (internal; references Orders.Contracts and SharedKernel)
internal sealed class Invoice { public Guid OrderId { get; set; } public decimal Amount { get; set; } }
internal sealed class BillingDbContext(DbContextOptions<BillingDbContext> options) : DbContext(options)
{
    public DbSet<Invoice> Invoices => Set<Invoice>();
    protected override void OnModelCreating(ModelBuilder b)
    {
        b.HasDefaultSchema("billing");
        b.Entity<Invoice>().HasKey(i => i.OrderId);
    }
}
internal sealed class CreateInvoiceOnOrderPlaced(BillingDbContext db) : IEventHandler<OrderPlaced>
{
    public async Task HandleAsync(OrderPlaced e, CancellationToken ct)
    {
        if (await db.Invoices.AnyAsync(i => i.OrderId == e.OrderId, ct)) return; // idempotent
        db.Invoices.Add(new Invoice { OrderId = e.OrderId, Amount = e.Total });
        await db.SaveChangesAsync(ct);
    }
}
public static class BillingModule
{
    public static IServiceCollection AddBillingModule(this IServiceCollection s, string cs)
    {
        s.AddDbContext<BillingDbContext>(o =>
            o.UseSqlServer(cs, sql => sql.MigrationsHistoryTable("__EFMigrationsHistory", "billing")));
        s.AddScoped<IEventHandler<OrderPlaced>, CreateInvoiceOnOrderPlaced>();
        return s;
    }
}

Wire it up in Host/Program.cs. Create migrations for each DbContext before running.

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddScoped<InProcessEventBus>();
builder.Services
    .AddOrdersModule(builder.Configuration.GetConnectionString("Orders")!)
    .AddBillingModule(builder.Configuration.GetConnectionString("Billing")!);

var app = builder.Build();
app.MapGet("/orders/{id:guid}", async (Guid id, IOrdersApi api, CancellationToken ct) =>
    await api.GetOrderAsync(id, ct) is { } o ? Results.Ok(o) : Results.NotFound());
app.MapPost("/orders", async (PlaceOrderRequest req, IOrdersApi api, CancellationToken ct) =>
    Results.Ok(await api.PlaceOrderAsync(req.Total, ct)));
app.Run();

record PlaceOrderRequest(decimal Total);

Where boundaries really leak

Billing’s types are internal and in another project, so the compiler already blocks direct access to them. The real leaks are raw SQL against another schema, a shared DbContext, and a stray project reference or InternalsVisibleTo.

Block the SQL leak in the database. Give each module its own login, use one connection string per module, and run migrations with a separate admin account:

CREATE LOGIN orders_app WITH PASSWORD = '<secret>';
CREATE USER orders_app FOR LOGIN orders_app;
GRANT SELECT, INSERT, UPDATE, DELETE ON SCHEMA::orders TO orders_app;

Catch the reference leak in CI. The test project must reference every module assembly:

public class ModuleBoundaryTests
{
    static readonly Assembly[] Modules =
        [typeof(OrdersModule).Assembly, typeof(BillingModule).Assembly];

    [Fact]
    public void Module_assemblies_were_found()
    {
        // Guards against a vacuous pass if a module is renamed
        Assert.Equal(["Billing", "Orders"], Modules.Select(m => m.GetName().Name!).Order());
    }

    [Fact]
    public void Modules_do_not_reference_each_other()
    {
        var names = Modules.Select(m => m.GetName().Name!).ToHashSet();
        foreach (var module in Modules)
        {
            var leaked = module.GetReferencedAssemblies()
                .Select(a => a.Name!).Where(n => names.Contains(n) && n != module.GetName().Name);
            Assert.Empty(leaked);
        }
    }
}

This covers every module pair and passes only if they depend on each other’s .Contracts assemblies alone. Add a module to the array when you create it.

When to extract a service

Valid reasons are concrete: independent scaling, separate teams, different deployment cadences, a different tech stack, compliance isolation, or fault isolation with circuit breakers. Trends and résumé-driven design are not on the list. Before extracting, check that:

  • the boundary has stayed stable for months,
  • only this module owns its data, with no cross-module joins,
  • you have measured the bottleneck,
  • you have capacity for tracing, auth, deployment and on-call.

Then pick the cleanest module, put an HTTP client behind the existing interface, move its schema to its own database, and keep a rollback path.

internal sealed class HttpOrdersApi(HttpClient http) : IOrdersApi
{
    public async Task<OrderDto?> GetOrderAsync(Guid id, CancellationToken ct)
    {
        using var response = await http.GetAsync($"orders/{id}", ct);
        if (response.StatusCode == HttpStatusCode.NotFound) return null;
        response.EnsureSuccessStatusCode();
        return await response.Content.ReadFromJsonAsync<OrderDto>(ct);
    }

    public async Task<Guid> PlaceOrderAsync(decimal total, CancellationToken ct)
    {
        using var response = await http.PostAsJsonAsync("orders", new { total }, ct);
        response.EnsureSuccessStatusCode();
        return await response.Content.ReadFromJsonAsync<Guid>(ct);
    }
}

public static class OrdersClientExtensions
{
    public static IServiceCollection AddOrdersHttpClient(this IServiceCollection s, Uri baseAddress)
    {
        s.AddHttpClient<IOrdersApi, HttpOrdersApi>(c => c.BaseAddress = baseAddress)
         .AddStandardResilienceHandler(o => o.Retry.DisableForUnsafeHttpMethods());
        return s;
    }
}

The standard handler adds timeouts, retries and a circuit breaker. Disabling retries for POST avoids duplicate orders. Decide how callers see an open circuit or a 5xx.

 

The outbox dispatcher then has to publish to a broker instead of the in-process bus. Hosting prices and limits change often, so check the provider’s current documentation.

 

Key takeaways

  • Start with one deployable unit and optimize for learning speed.
  • Modularity matters more than the deployment model. Enforce it with contracts, per-schema DB permissions and tests.
  • Cross-module consistency is not free. Use an outbox and idempotent handlers.
  • Extract only for reasons you can name or measure.

FAQ

Can I use a single database and still call it modular?

Yes, if each module owns its schema and nothing else touches it. Enforce that with per-module database permissions, not just convention.

 

Is a modular monolith overkill for a two-person team?

 

This is judgement, not evidence. While you are still validating the idea, a plain monolith is fine. Contracts projects and one schema per module cost little, though, and make a later split easier.

 

Sources and further reading

Images: Image by Innovalabs from Pixabay; Image by This_is_Engineering from Pixabay.

Leave a Reply

Your email address will not be published. Required fields are marked *