DEV Community

Cover image for Modular Monolith First: Why It Beats Microservices on Day One
Mirac Aksu
Mirac Aksu

Posted on

Modular Monolith First: Why It Beats Microservices on Day One

Every few weeks someone asks why a new product isn't "built on microservices." My honest answer: on day one, you don't know where your service boundaries are β€” and microservices make wrong boundaries permanent. A modular monolith makes them cheap to fix.

This article walks through the reasoning with a complete working example in .NET β€” a small pet-adoption domain with two modules: compiler-enforced module boundaries, contract-only communication, in-process events at the seams, and architecture tests that fail the build when a boundary is violated.

All code is here, runnable with one command:

GitHub logo mrcaksu / modular-monolith-first

Modular monolith in .NET 8 β€” enforced module boundaries, contract-only communication, in-process events, architecture tests

modular-monolith-first

A working example of a modular monolith in .NET 8: compiler-enforced module boundaries, contract-only cross-module communication, in-process domain events and architecture tests that fail the build when a boundary is violated.

πŸ“„ Article: (dev.to link β€” added at publication) Β· ✍️ MiraΓ§ Aksu β€” Engineering Beyond Code

Quick start

dotnet test                        # includes the architecture (boundary) tests
dotnet run --project src/Api       # API on http://localhost:5000 (or shown port)
Enter fullscreen mode Exit fullscreen mode

Try the cross-module event flow:

curl localhost:5000/listings
# pick an id, start an adoption:
curl -X POST localhost:5000/adoptions \
  -H "Content-Type: application/json" \
  -d '{"listingId":"<id>","applicantId":"11111111-1111-1111-1111-111111111111"}'
# complete it β€” the AdoptionCompleted EVENT closes the listing in the Listings module:
curl -X POST localhost:5000/adoptions/<adoptionId>/complete
curl localhost:5000/listings       # β†’ that listing is now "Closed"
# try adopting the same listing again β†’ 400 "Listing is not available for adoption."
Enter fullscreen mode Exit fullscreen mode

What to look at

  • src/Modules/Listings/Listings.Contracts/ —…

The problem with "microservices first"

Microservices solve an organizational scaling problem: independent teams deploying independently. They charge for it with distributed-systems costs on every request β€” network failure modes, eventual consistency, distributed tracing, versioned contracts, infrastructure per service.

A solo builder or a small team pays those costs while getting the benefit of… independent deployment for teams that don't exist.

The deeper issue is boundary discovery. Service boundaries should follow domain boundaries, and domain boundaries reveal themselves only after the model meets reality. Move a misplaced boundary between two microservices and you're re-plumbing contracts, pipelines and data ownership. Move it inside a monolith and it's a refactor the compiler supervises.

What "modular" actually means

A modular monolith is not "a monolith with folders." It's a single deployable with enforced internal boundaries:

  • Each module owns a business capability (Listings, Adoptions).
  • A module exposes a tiny public contract; everything else is internal.
  • Modules never touch each other's data or domain types.
  • Cross-module communication goes through the contract or through in-process events β€” never through a shortcut reference to another module's internals.

Cross-module event flow: Adoptions publishes AdoptionCompleted, the event bus delivers it, Listings closes the listing

The structure:

src/
  Api/                      ← composition root, the ONLY project that sees everything
  BuildingBlocks/           ← Result, in-process event bus (~20 lines)
  Modules/
    Listings/
      Listings.Contracts/   ← public: IListingReader, ListingSummary
      Listings/             ← internal: Listing, store, handlers
    Adoptions/
      Adoptions.Contracts/  ← public: AdoptionCompleted event
      Adoptions/            ← internal: Adoption, handlers, repository
tests/
  ArchitectureTests/        ← the boundary police
Enter fullscreen mode Exit fullscreen mode

One namespace convention makes everything downstream trivial:

  • Modules.X.Contracts β†’ public surface, anyone may depend on it
  • Modules.X.Internal β†’ implementation, only module X itself may touch it

Enforcement, not discipline

Boundaries that rely on code-review discipline die in month two. This example enforces them twice.

First, the compiler. Module implementation types are internal, and modules only hold project references to each other's Contracts projects. The Adoptions module cannot reference a Listings repository β€” the reference doesn't exist and the type isn't visible:

// Listings.Contracts β€” the ONLY thing other modules can see
public interface IListingReader
{
    Task<ListingSummary?> GetActiveListing(Guid listingId, CancellationToken ct);
}

public sealed record ListingSummary(Guid Id, string PetName, ListingStatus Status);
Enter fullscreen mode Exit fullscreen mode
// Adoptions module β€” depends on the contract, never on Listings internals
internal sealed class StartAdoptionHandler(
    IListingReader listings,
    IAdoptionRepository adoptions)
{
    public async Task<Result<Guid>> Handle(StartAdoption cmd, CancellationToken ct)
    {
        var listing = await listings.GetActiveListing(cmd.ListingId, ct);
        if (listing is null || listing.Status != ListingStatus.Active)
            return Result.Failure<Guid>("Listing is not available for adoption.");

        var adoption = Adoption.Start(cmd.ListingId, cmd.ApplicantId);
        await adoptions.Add(adoption, ct);
        return Result.Success(adoption.Id);
    }
}
Enter fullscreen mode Exit fullscreen mode

Second, architecture tests. Compiler visibility can be bypassed with a lazy public or a sneaky project reference, so CI runs boundary tests with NetArchTest:

[Fact]
public void Adoptions_must_not_touch_Listings_internals()
{
    var result = Types.InAssembly(typeof(Modules.Adoptions.AdoptionsModule).Assembly)
        .That().ResideInNamespaceStartingWith("Modules.Adoptions")
        .ShouldNot().HaveDependencyOnAny("Modules.Listings.Internal")
        .GetResult();

    Assert.True(result.IsSuccessful);
}
Enter fullscreen mode Exit fullscreen mode

A boundary violation is now a failing build, not a code-review debate.

Small war note from writing these tests: dependency rules match namespaces by prefix. If you forbid "Modules.Listings" wholesale, you also forbid Modules.Listings.Contracts β€” and the test fails for the wrong reason. That's exactly why the Contracts / Internal namespace split exists: it makes the allowed and forbidden surfaces prefix-distinguishable.

Cross-module workflows without coupling

When an adoption completes, the listing must close. Calling into Listings from inside the adoption operation would couple the modules' failure modes. Instead, Adoptions publishes an event defined in its own contracts project, and Listings subscribes:

// Adoptions.Contracts β€” public event, the stable seam
public sealed record AdoptionCompleted(Guid AdoptionId, Guid ListingId);
Enter fullscreen mode Exit fullscreen mode
// Adoptions internal β€” publishes, knows nothing about who listens
internal sealed class CompleteAdoptionHandler(IAdoptionRepository adoptions, IEventBus bus)
{
    public async Task Handle(Guid adoptionId, CancellationToken ct)
    {
        var adoption = await adoptions.Get(adoptionId, ct)
                       ?? throw new AdoptionNotFound(adoptionId);

        adoption.Complete();
        await bus.Publish(new AdoptionCompleted(adoption.Id, adoption.ListingId), ct);
    }
}
Enter fullscreen mode Exit fullscreen mode
// Listings internal β€” reacts, knows nothing about Adoptions internals
internal sealed class CloseListingOnAdoptionCompleted(InMemoryListingStore store)
    : IEventHandler<AdoptionCompleted>
{
    public Task Handle(AdoptionCompleted e, CancellationToken ct)
    {
        store.Get(e.ListingId)?.Close();   // Close() is idempotent by design
        return Task.CompletedTask;
    }
}
Enter fullscreen mode Exit fullscreen mode

The event bus in the example is deliberately boring β€” about 20 lines over IServiceScopeFactory, no libraries. In a production system you'd likely reach for MediatR notifications, and for durability across restarts you'd add the outbox pattern (that's a future article in this series). The architectural point doesn't change with the transport: the day a module becomes a real service, its in-process contract events become integration events on a broker with the same shape. The seam was there all along.

You can watch the seam work:

dotnet run --project src/Api
# GET  /listings                      β†’ two active listings
# POST /adoptions                     β†’ starts an adoption for a listing
# POST /adoptions/{id}/complete       β†’ 204; the EVENT closes the listing
# GET  /listings                      β†’ that listing is now "Closed"
# POST /adoptions (same listing)      β†’ 400 "Listing is not available for adoption."
Enter fullscreen mode Exit fullscreen mode

That last line is the payoff: Adoptions rejected the request using only the public contract β€” it never looked inside Listings.

When a module earns extraction

"Modular monolith first" is not "modular monolith forever." A module earns service extraction when a measured pressure appears:

  1. Divergent scaling β€” one module needs 10Γ— the instances of the rest.
  2. Divergent deployment risk β€” a module changes daily while the rest is stable, and its deploys keep taking everything down.
  3. A real team boundary β€” a second team owns the module and needs an independent release cadence.
  4. A hard runtime mismatch β€” the module needs a different stack (a Python ML service beside a .NET core).

None of these are guesses; all are observable. Until one shows up, extraction is cost without benefit.

When NOT to use a modular monolith

  • The organization already has multiple teams that must deploy independently today β€” you'd be building the org's bottleneck.
  • The domain is genuinely trivial β€” a CRUD admin panel doesn't need module ceremony; a plain layered app is fine.
  • Regulatory isolation forces physically separated services and data stores from day one.

What building even this small example taught me

  • The namespace convention is the whole game. Contracts vs Internal sounds cosmetic until you write the architecture tests β€” then it's the difference between a one-line rule and an unenforceable one.
  • Make event handlers idempotent from the start. Listing.Close() ignores an already-closed listing. In-process buses can be replaced by at-least-once brokers later; handlers written idempotent today survive that migration untouched.
  • The composition root is a feature, not a smell. Exactly one project (Api) sees everything. When I ask "who can possibly touch this type?", the answer is checkable in seconds.

Summary

Start with a modular monolith: one deployable, compiler-and-CI-enforced boundaries, contract-only communication, events at the seams. You get microservices' most valuable property β€” clean boundaries β€” without paying distributed-systems rent before the domain has told you where the boundaries belong. Extract modules only when a measured pressure demands it.

Full working code: https://github.com/mrcaksu/modular-monolith-first β€” dotnet test && dotnet run --project src/Api

Next in this series: enforcing module isolation in .NET in depth β€” internals, architecture tests, and the tricks that keep boundaries honest.

Top comments (0)