﻿# Two kinds of micromonolith

> Microservices and Kubernetes are scaling and organisational tools, not quality tools. What splitting an application really costs, why the modular monolith is the good micromonolith, and how to build one that scales without either.

Source: https://laszlonemes.com/blog/micromonoliths · Published: 2026-09-20 · Author: László Nemes

September 20, 2026 · 18 min read

`Architecture` `.NET` `Modular monolith` `Microservices`

"Micromonolith" means something different to everyone who says it. Before arguing about whether it is good or bad, it is worth noticing that the same question sits behind every reading of the word: **where do you draw the line between monolith and microservice, and how do you organise the application around that line?** It is a spectrum, not a switch.

> **Fig. 1** · The whole argument on one page. There are two micromonoliths. The bad one cut up the deploy instead of the responsibility. The good one cut the responsibility cleanly instead of the deploy. The red arrow is the mistake: splitting a ball of mud. The blue arrow is the only cheap road to microservices – it starts from a monolith whose boundaries are already sharp.
>
> *Diagram:* A two-by-two of how many things you deploy against how sharp the boundaries are. One deploy with sharp modules is the modular monolith, the good micromonolith. Many deploys with a shared database and lockstep releases is the distributed monolith, the bad one. Microservices are many deploys with sharp boundaries; the big ball of mud is one deploy with none.

> **The thread through this post:** microservices and Kubernetes are scaling and organisational tools, not quality tools. Your code does not get better because it runs in thirty containers. Splitting an application is never free – so prove the seam first, then cut.

## Three things called a micromonolith

> **Fig. 2** · Same word, three architectures – in Fig. 1's terms, A is the bottom-right corner, B the top-right and C the top-left. A and C look similar on a whiteboard – several named boxes – and are opposites in practice: A pays for a network between the boxes and shares the data anyway; C shares a process and keeps the data apart.
>
> *Diagram:* Three things people mean by micromonolith. A: a distributed monolith, several services deployed together around one shared database. B: every microservice is a small monolith with its own deploy and its own data. C: a modular monolith, one process with sharply separated modules and no network between them.

- **A · The distributed monolith.** Many "services", one shared database, deployed together. This is what you get when you draw the line in the wrong place: you split the deploy but not the responsibility or the data. You pay the full price of distribution and get nothing back.
- **B · Every microservice is a small monolith.** Structurally true – inside, a service is a small self-contained application. But a microservice is not defined by its *size*. It is defined by its independence: its own deploy, its own data, a team boundary around it.
- **C · The modular monolith with micro-modules.** One deploy; inside, sharp, microservice-like boundaries. This is the one I build, deliberately. It isn't "I don't dare do microservices": I get the bounded contexts – modules, dependency injection, clean interfaces – without paying for a network. If a module later genuinely outgrows the process, extracting it is a refactor, not a rewrite.

## The bill for splitting

Every argument for the monolith rests on this section. Each item is the same story: what happens when a method call becomes a network call.

> **Fig. 3** · One arrow, before and after. Latency numbers are the usual orders of magnitude: a method call is nanoseconds, a round trip inside one data centre is about half a millisecond before serialisation.
>
> *Diagram:* An in-process method call takes nanoseconds and has two outcomes: it returns or it throws. The same call over the network takes half a millisecond to two milliseconds at best and has a third outcome: no answer, so the caller cannot know whether it happened.

Every in-process call you put on the wire inherits the [eight fallacies of distributed computing](https://en.wikipedia.org/wiki/Fallacies_of_distributed_computing): the network is reliable, latency is zero, bandwidth is infinite, the network is secure, topology doesn't change, there is one administrator, transport cost is zero, the network is homogeneous. In a monolith, *none* of these is your problem.

### It is the tail, not the average

A millisecond per hop sounds harmless. The trouble is the tail. Every service is occasionally slow – a garbage collection, a cold cache, a noisy neighbour. Say that happens to one request in a hundred. A user request that touches N services is slow if *any* of them is:

> **Fig. 4** · Computed as 1 − 0.99^N, assuming the services are slow independently. With thirty services on the path, a quarter of all user requests experience somebody's worst case. This is the effect Dean and Barroso describe in [The Tail at Scale](https://research.google/pubs/the-tail-at-scale/).
>
> *Diagram:* If every service is slow for one request in a hundred, the chance that a request touching N services hits at least one slow response is one minus 0.99 to the power of N: 1 percent for one service, 10 percent for ten, 26 percent for thirty and 63 percent for a hundred.

### A third outcome

In a monolith a call either returns or throws. It is binary. On a network there is a third state: *I don't know whether it happened.* Everything below follows from that one fact.

> **Fig. 5** · Timeout → retry → duplicated side effect. The machinery that prevents this – idempotency keys, deduplication, backoff, circuit breakers, bulkheads, in .NET typically Polly – is complexity that exists only because the call was split.
>
> *Diagram:* A sequence between an orders service and a payments service: the charge request succeeds, the response is lost, the caller times out without knowing whether it happened, retries, and the card is charged twice unless the receiver deduplicates with an idempotency key.

### Transactions

> **Fig. 6** · Left: the database guarantees all-or-nothing. Right: you do, by hand. A distributed monolith manages the worst of both – it takes on the saga while still sharing one database, so it doesn't even get isolation in exchange.
>
> *Diagram:* In a monolith, reserving stock, charging the payment and creating the shipment are one ACID transaction that the database rolls back as a whole. Across services the same flow is a saga: when the shipment fails, you must run compensating actions, refunding the payment and releasing the stock, in code you write yourself.

### The observability tax

In a monolith, the stack trace tells you what happened. Across services, one logical operation lives scattered over N services' logs, so you need distributed tracing (OpenTelemetry, Jaeger), a correlation ID threaded through every hop, and log aggregation. That is not an option you add later. It is the entry fee.

## The real reason: Conway’s law

If splitting costs this much, why does anyone do it? Because microservices are an architectural answer to an **organisational** question. Conway observed in 1968 that systems end up mirroring the communication structure of the organisation that builds them. Service boundaries map onto team boundaries so that many teams – different offices, different time zones – can deploy without waiting for each other.

> **Fig. 7** · The same architecture, two organisations. If you have no organisational question, a service-per-box architecture is a wrong answer to a problem you don't have.
>
> *Diagram:* With many teams, a service per team lets each team ship on its own schedule, so the boundary pays for itself. With one team of six to eight people owning thirty services, the team pays the full cost of distribution and gets no independence in return.

## The good micromonolith, mechanically

A modular monolith is not a "big ball of mud with folders". The difference is that the module boundaries are **enforced at compile time**, not agreed on in a wiki.

> **Fig. 8** · Each module is a pair of projects: a small public Contracts assembly and an implementation whose types are internal. Other modules can only reference Contracts, so a shortcut across the boundary is not a code-review discussion – it doesn't build.
>
> *Diagram:* One process containing three modules. Each module has a public Contracts part and an internal implementation. Modules may depend only on another module's Contracts. Reaching into another module's internals is a compile error, and an architecture test fails the build.

```csharp
// Shop.Billing.Contracts: the only Billing project other modules may reference
public interface IBillingService
{
    Task<InvoiceId> CreateInvoiceAsync(OrderId order, CancellationToken ct);
}

// Shop.Billing: a separate project. `internal` = invisible outside this assembly,
// so "just call the repository directly" from Orders does not compile.
internal sealed class BillingService(BillingDbContext db) : IBillingService
{
    public async Task<InvoiceId> CreateInvoiceAsync(OrderId order, CancellationToken ct)
    {
        /* … */
    }
}
```

Project references and `internal` stop most violations before they exist. For the rules the compiler can't express – layering inside a module, "the domain knows nothing about EF Core" – architecture tests do the same job in CI. I use [NetArchTest](https://github.com/BenMorris/NetArchTest):

```csharp
[Fact]
public void Orders_domain_stays_clean()
{
    var result = Types.InAssembly(typeof(Order).Assembly)
        .That().ResideInNamespace("Shop.Orders.Domain")
        .ShouldNot().HaveDependencyOnAny(
            "Microsoft.EntityFrameworkCore",   // no infrastructure in the domain
            "Shop.Billing",                    // no shortcut into another module
            "Shop.Catalog")
        .GetResult();

    Assert.True(result.IsSuccessful);          // a violation fails the build, not a review
}
```

This is what makes the top arrow in Fig. 1 cheap. When a module really does need to become a service, its seam already exists: the Contracts interface becomes the API, the in-process call becomes a client. Martin Fowler's [MonolithFirst](https://martinfowler.com/bliki/MonolithFirst.html) makes the same case – monolith first, extract when the seam has earned it.

## Scaling needs neither

The strongest point, and the most often forgotten: **horizontal scaling requires neither microservices nor Kubernetes.** A stateless monolith runs as N identical instances behind a load balancer, and that is it.

> **Fig. 9** · Multi-node-ready from day one. The only requirement is discipline about state. The dashed instances are a seasonal peak: two predictable weekends a year don't justify running an orchestrator all year.
>
> *Diagram:* Horizontal scaling without microservices or Kubernetes: a load balancer in front of several identical stateless instances of the monolith, with all state, the database, the cache and the file store, kept outside the instances. Extra instances are added for a peak and removed afterwards.

In practice that means no in-process-only cache or session state: the cache is Redis or a database table, chosen in configuration – the in-memory default is for single-node installations only. Application settings that live in the database are synchronised across nodes by a background job that polls; about a minute of eventual consistency is perfectly fine for configuration. Yes, Postgres has `LISTEN`/`NOTIFY` – but polling is trivially robust, works on every database, and adds no infrastructure. That is appropriate simplicity, not laziness.

## Kubernetes is a separate axis

Microservices and Kubernetes are two independent decisions. Whoever merges them has already made a mistake.

|  | No orchestrator | Kubernetes |
| --- | --- | --- |
| Monolith | IIS · systemd + nginx · one container · App Service | perfectly valid: N replicas of one image |
| Microservices | ECS / Fargate · App Service · plain VMs | the combination everyone pictures |

My estimate is that well over nine out of ten applications don't need Kubernetes. It gets forced in because someone needed the project, or because it is fashionable. What hosting actually takes, most of the time:

- **Windows:** host it in IIS. Done.
- **Linux:** a `systemd` service with nginx in front. Done.
- **Either:** one Docker container. Done.

/etc/systemd/system/shop.service

```ini
[Unit]
Description=Shop (ASP.NET Core)
After=network.target

[Service]
WorkingDirectory=/opt/shop
ExecStart=/usr/bin/dotnet /opt/shop/Shop.dll
Restart=always
Environment=ASPNETCORE_URLS=http://127.0.0.1:5000

[Install]
WantedBy=multi-user.target
```

The honest edge, before someone else points it out. The choice isn't only "Kubernetes or bare VMs": Azure App Service autoscale and ECS/Fargate scheduled scaling give you elastic scaling without owning a control plane. And manually adding instances is ideal for *predictable, rare* peaks; when peaks are unpredictable and frequent, shuffling VMs by hand is operational toil, and automating exactly that is what an autoscaler is for.

Where Kubernetes is genuinely right: many workloads, declarative self-healing, sophisticated rollouts – and the numbers work out in the spreadsheet. Then it is very good. But the real question is never "do we have microservices?". It is: **do we have enough workloads, and enough operational maturity, to feed a control plane?**

## Two things called async

Two unrelated things are both called "async", and arguments go wrong when they are mixed up. One is a thread's I/O model. The other is how services talk to each other.

### The I/O model: async/await

In a production .NET application all I/O is `async`/`await` – monolith or microservice. That isn't an architectural choice, it is table stakes: a thread that isn't blocked waiting for the database can serve another request. Blocking on async code is the classic way to lose that:

```csharp
// blocks a thread-pool thread while the database works; under load the pool starves
var user = db.Users.FirstAsync(u => u.Id == id).Result;

// hands the thread back until the database answers
var user = await db.Users.FirstAsync(u => u.Id == id, ct);
```

In ASP.NET Core the symptom is thread-pool starvation under load; in environments with a synchronisation context (classic ASP.NET, desktop UI) the same line can deadlock outright. Note that this helps I/O-bound work – CPU-bound work doesn't get faster from `await`. And it is entirely orthogonal to the monolith question.

### Communication between services

A mature microservice system is *not* a lot of small REST APIs calling each other. If it consists only of synchronous calls, it is a distributed monolith wired together with HTTP. There are two axes here, and they shouldn't be blurred:

- **Synchronous request/response** – the caller needs the answer *now*. REST (readable, browser-friendly) or gRPC (typed `.proto` contracts, binary protobuf over HTTP/2, a faster synchronous transport). gRPC is not a queue: it is broker-less, point-to-point RPC.
- **Asynchronous messaging through a broker** – RabbitMQ, Kafka, NATS, Azure Service Bus. Not a "third option" next to REST and gRPC but a different axis: **temporal decoupling**. The producer doesn't wait for the consumer.

> **Fig. 10** · A sync-only mesh is rigid: a slow or dead component propagates up the chain. The broker is the shock absorber. Arithmetic for the burst: 1,000 orders/s arrive for 10 s while 200/s are written, so 8,000 are queued at the peak, and the remaining backlog drains in 40 s.
>
> *Diagram:* In a synchronous chain of services, one slow or dead component fails every caller upstream. With a broker, the producer returns immediately and the queue holds the messages: a burst of ten thousand checkouts in ten seconds against a database that writes two hundred per second peaks at eight thousand queued messages and is fully written after fifty seconds, with nothing lost.

| Situation | Without a broker | With a broker | What it buys |
| --- | --- | --- | --- |
| Sign-up sends a welcome e-mail; the mail provider is slow | sign-up waits, or fails | sign-up completes; the mail goes when it can | temporal decoupling |
| A newsletter lands; checkouts spike far above DB write capacity | timeouts and lost orders | the queue holds the burst and drains it | load levelling |
| A partner API is down for maintenance | your writes fail with it | messages wait and flow when it returns | failure isolation |
| After an upload: thumbnail, indexing, notification | the upload code calls all three | one FileUploaded event, three consumers; a fourth needs no change to the uploader | fan-out |
| "Generate the PDF", "transcode the video" | the user watches a spinner | queued; notified when done | commands vs. queries |

The last row has a counterpart: "what is my balance?" needs an answer *now*. That is a query, and it stays synchronous. Don't over-correct into "everything is a queue" – the naive mistake is all-sync, the mature system **mixes**: sync for queries that need a response, a broker for commands, events and reactions that tolerate eventual consistency.

> **Fig. 11** · Not every broker is the same thing. Choose by semantics: if you need replay and retention, a log; if you need a work queue with acknowledgements, a queue. NATS sits beside both: core NATS is lightweight fire-and-forget, JetStream adds persistence.
>
> *Diagram:* A queue such as RabbitMQ or Azure Service Bus hands each message to one of several competing workers and deletes it once acknowledged. A log such as Kafka retains messages; each consumer group keeps its own offset, so one group can be at offset six while another replays from offset two.

Two honest caveats. A broker is **not faster per message** – a direct gRPC call has lower latency. The broker wins on behaviour under load and on resilience, not on raw speed. And it has its own price: eventual consistency; at-least-once delivery, so **idempotent consumers are mandatory** (exactly-once across a network is not something you can buy); no ordering guarantee by default; dead-letter queues and poison messages; and the broker itself becomes critical infrastructure. In .NET, [MassTransit](https://masstransit.io/) over RabbitMQ or Azure Service Bus, with its built-in outbox against dual writes, is the usual toolkit.

### The twist that leads back to the monolith

You need a broker between services *because you split the application*. Inside a monolith you get the same decoupling without a broker and without a network:

> **Fig. 12** · An in-memory channel gives you temporal decoupling and fan-out inside one process. It is not durable – whatever is in the channel is lost if the process dies – so work that must not be lost is written to an outbox table in the same transaction as the business data.
>
> *Diagram:* The same decoupling inside one process: the upload endpoint writes an event to an in-memory channel and returns immediately, a background service reads the channel and fans out to thumbnail, index and notify handlers. Because a channel is lost on a crash, durable work also goes through an outbox table.

```csharp
builder.Services.AddSingleton(Channel.CreateBounded<FileUploaded>(capacity: 1_000));
builder.Services.AddHostedService<FileUploadedWorker>();

// producer: the endpoint returns as soon as the event is queued
app.MapPost("/files", async (IFormFile file, IFileStore store,
                             Channel<FileUploaded> channel, CancellationToken ct) =>
{
    var id = await store.SaveAsync(file, ct);
    await channel.Writer.WriteAsync(new FileUploaded(id), ct);
    return Results.Accepted($"/files/{id}");
});

// consumer: same process, no broker, no network
internal sealed class FileUploadedWorker(
    Channel<FileUploaded> channel,
    IEnumerable<IFileUploadedHandler> handlers) : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken ct)
    {
        await foreach (var uploaded in channel.Reader.ReadAllAsync(ct))
            foreach (var handler in handlers)      // thumbnail, index, notify
                await handler.HandleAsync(uploaded, ct);
    }
}
```

## How I build: ports, providers, one deploy

What I build has a name: a modular monolith with hexagonal infrastructure, in the clothes of the .NET provider model, packaged as an on-premises product. None of it is new:

- **Provider model** – native .NET heritage, from the ASP.NET Membership providers to today's `IDistributedCache`, EF Core providers and authentication schemes.
- **[Hexagonal / ports and adapters](https://alistair.cockburn.us/hexagonal-architecture/)** (Cockburn) – every external dependency is a port with a replaceable adapter behind it.
- **Clean / Onion** – domain in the middle, infrastructure at the edge, dependencies pointing inwards.
- **[Twelve-factor](https://12factor.net/) III and IV** – configuration from the environment; the database is an attached resource you plug in without a code change.

> **Fig. 13** · Micro-level modularity and replaceable ports, monolithic deploy. Each group is a port in the application and one section of configuration for the customer – who never sees, or needs, a different build. The highlighted adapters are the defaults: with an empty configuration the application starts on them right away.
>
> *Diagram:* Ports and adapters: the domain and application code in the middle knows only interfaces. Configuration selects the adapter behind each port: one of five databases, one of seven file stores, the cache, which is in-memory, Redis, or a SQL Server, PostgreSQL or MySQL table, the user store, which is local, Active Directory, LDAP or Entra ID, and separately the login method: username and password with e-mail verification, Active Directory, or OAuth with Microsoft, Google, Okta or a custom provider such as PingID. The highlighted adapters are the zero-configuration defaults the application starts with: SQLite, local disk, the in-memory cache, which only works on a single node, the local user store and username and password login.

appsettings.json (illustrative)

```json
{
  "Database":  { "Provider": "PostgreSql", "ConnectionString": "Host=db;Database=app" },
  "FileStore": { "Provider": "S3", "Bucket": "customer-documents" },
  "Cache":     { "Provider": "Redis" },
  "UserStoreSettings": { "Provider": "Local" },
  "LoginSettings": {
    "Password":        { "Enabled": true, "EmailVerification": true },
    "ActiveDirectory": { "Enabled": false },
    "OAuth":           { "Provider": "Okta", "Authority": "https://login.customer.example", "ClientId": "shop" }
  }
}
```

- **Database from configuration:** SQL Server, PostgreSQL, MySQL, SQLite or Oracle, each with its own set of code-first migrations.
- **Migrations as a CLI:** the application binary can start in CLI mode to list migrations and step them forwards and backwards by hand. Before a downgrade the application brings the database to the target state itself, so the way back is safe. Always backwards compatible.
- **Users and login from configuration, as two separate concerns.** `UserStoreSettings` says where users live – local (the application's own database), Active Directory, LDAP or Entra ID. `LoginSettings` says how they sign in: username and password with e-mail verification; Active Directory, against the matching user store; or OAuth, with separate options for Microsoft, Google and Okta plus a *Custom* option for everything else – PingID and the like. Keeping the two apart is what lets them combine, and in practice it covers almost any identity set-up a customer walks in with.
- **File store from configuration:** Azure Blob, AWS S3, MinIO, SharePoint, Box, GCP Storage or local disk – through my open-source [Cloud File Storage Manager](https://github.com/NemesLaszlo/Cloud.File.Storage.Manager) packages.
- **Cache from configuration:** one `ICache` port with in-memory as the default – the fastest option, and nothing to install – and Redis or a SQL Server, PostgreSQL or MySQL table behind the same interface. The default comes with a hard limit: an in-memory cache lives inside one process, so on a multi-node deployment it is not usable – every instance would hold its own copy and they would drift apart, which is exactly the in-process state Fig. 9 rules out. Going multi-node means switching the cache to Redis or a database table first; that is a configuration change and a restart, not a code change.
- **Zero-config start-up:** the default configuration must make the application start immediately. There is no first run that refuses to boot without setup. With an empty configuration you get SQLite, local disk, the in-memory cache, the local user store and username-and-password login, and the application creates and migrates its own database on start-up from the code-first migrations. Later you point it at the real database or identity provider and restart. The rule behind it: whatever the application can do for itself, it does.

> **Scope, stated plainly:** this is for a boxed, on-premises product, where the *customer* owns the runtime and the application has to fit into their infrastructure. If you are building a pure cloud SaaS that needs exactly one Postgres, five database providers would be over-engineering. Different use case, different answer.

It also has limits worth admitting. "Supported" does not mean "behaves identically": transactions, locking and SQL dialects differ, and targeting five databases pulls you towards the lowest common denominator – no casual use of Postgres JSONB. EF Core hides a lot; where it matters I allow a provider-specific path. And "we support everything" is only as true as the test matrix behind it: the credible version runs migrations – including the `down` ones – and the data layer against every claimed provider in CI, which [Testcontainers](https://dotnet.testcontainers.org/) makes practical. Only promise the providers that have a real deployment behind them; keep the rest as a clean seam rather than a maintained adapter.

## When to split after all

None of this says "never". It says the burden of proof is on the cut. Extract a module into a service when at least one of these is true – and you can name it:

1. **A team boundary:** a separate team needs to ship that part on its own schedule.
2. **A genuinely different scaling profile:** one part needs 40 instances or GPUs while the rest needs two small VMs.
3. **A different runtime or release constraint:** another language, a regulated component, a part that must not go down with the rest.
4. **The seam has already proven itself:** the module boundary has been stable in the monolith for a while, and its Contracts interface is what you would publish as an API anyway.

If none applies, what you would be building is reading A from Fig. 2: the tax without the benefit.

## Takeaways

1. **Microservices and Kubernetes are scaling tools, not quality tools.** They don't make your code better.
2. **Splitting is never free.** Latency tails, a third failure state, sagas instead of transactions, mandatory tracing.
3. **Microservices answer an organisational question.** No such question, no such answer.
4. **Enforce module boundaries at compile time** – separate assemblies, `internal`, architecture tests. Then extraction is a refactor.
5. **Write a good monolith that is multi-node-ready from day one.** After that, scaling is a load balancer and a few instances.
6. **Kubernetes is a separate axis.** The question is workloads and operational maturity, not service count.
7. **Sync for queries, a broker for commands and events** – and inside a monolith, a channel and an outbox give you the same decoupling for free.

There are two micromonoliths. The bad one cut up the deploy instead of the responsibility. The good one cut the responsibility cleanly instead of the deploy. Build the second one.
