← Blog

Two kinds of micromonolith

· 18 min read

Architecture.NETModular monolithMicroservices

"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.

sharpboundariesblurredone deployablewhat you deploymany deployablesModular monolithone deploy · sharp modulesthe good micromonolithMicroservicesmany deploys · own data, own teampays off at organisational scaleBig ball of mudone deploy · everything touches everythingbad, but at least cheap to runDistributed monolithmany deploys · shared DB · lockstep releasesthe bad micromonolithrefactor,not rewritesplit deploys,keep coupling
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.
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

A · Distributed monolithordersbillingemailone shared databasesplit deploys, shared data,released in lockstepB · A small monolith eachordersbillingorders DBbilling DBown deploy, own data, own team —that is what makes it a serviceC · Modular monolithone process · one deployordersbillingemailone databasesharp module boundaries,no network in between
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.
  • 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.

In-process callOrdersBilling~ nanosecondstwo outcomesreturns · throwsThe same call over the networkOrdersHTTP / gRPCserialise · network hop · deserialiseBilling0.5–2 ms per round trip, at bestthree outcomesreturns · throws · no answer“did it happen?” — the caller cannot knowFive to six orders of magnitude slower before any work is done — and one new failure mode.
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.

Every in-process call you put on the wire inherits the eight 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:

0%20%40%60%1.0%14.9%59.6%1018%2026%3039%5063%100services touched by one request — each one slow (its own p99) for 1 request in 100chance that the request hits at least one slow response
Fig. 4 · Computed as 1 − 0.99N, 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.

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.

OrdersPaymentsPOST /charge · order 42charges the card ✓✗response losttimeout: did it happen?retry: POST /charge · order 42charges the card again ✗The fix is an idempotency key the receiver deduplicates on — plus timeouts, backoff, circuit breakers, bulkheads.
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.

Transactions

Monolith: one transactionBEGIN TRANSACTIONreserve stockcharge paymentcreate shipmentCOMMITany failure → ROLLBACK, all of itthe database does the hard partAcross services: a sagareserve stockcharge paymentshipment fails ✗refund paymentrelease stockcompensating actions: code you write, test and operateplus an outbox, idempotency keys and a state machine to drive it
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.

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.

Many teams: the boundary pays for itselfTeam BerlinordersTeam AustinbillingTeam Punesearcheach team ships on its own scheduleOne team: all of the tax, none of the benefitone team of 6–830 services · 30 pipelines · one stand-up
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.

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.

one process · one deployOrdersContracts · publicimplementationinternal to its assemblyBillingContracts · publicimplementationinternal to its assemblyCatalogContracts · publicimplementationinternal to its assembly✗→ a module may use another module only through its Contracts⇢ reaching into internals: the compiler refuses, and an architecture test fails the build
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.
// 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:

[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 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.

clientsload balancerapp instance 1app instance 2app instance 3+3 for the peakdatabasecacheRedis or a DB tablefile storeNothing lives only in one instance’s memory — so any instance can serve any request.
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.

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 orchestratorKubernetes
MonolithIIS · systemd + nginx · one container · App Serviceperfectly valid: N replicas of one image
MicroservicesECS / Fargate · App Service · plain VMsthe 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

[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:

// 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.
Sync-only chain: every hop must be up, and fastcheckoutordersinventorypartner CRMdown for 30 minutesthe failure travels back up the chain — checkout fails tooWith a broker: the queue absorbs itcheckoutreturns at oncequeueorders workerCRM syncdown: messages wait10,000 checkouts in 10 sDB sustains 200 writes/squeue peaks at 8,000all written after 50 s0 orders lost
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.
SituationWithout a brokerWith a brokerWhat it buys
Sign-up sends a welcome e-mail; the mail provider is slowsign-up waits, or failssign-up completes; the mail goes when it cantemporal decoupling
A newsletter lands; checkouts spike far above DB write capacitytimeouts and lost ordersthe queue holds the burst and drains itload levelling
A partner API is down for maintenanceyour writes fail with itmessages wait and flow when it returnsfailure isolation
After an upload: thumbnail, indexing, notificationthe upload code calls all threeone FileUploaded event, three consumers; a fourth needs no change to the uploaderfan-out
"Generate the PDF", "transcode the video"the user watches a spinnerqueued; notified when donecommands 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.

Queue — RabbitMQ, Azure Service Busm3m2m1worker Agets m1worker Bgets m2one message → one worker; acknowledged = deletedfor commands and jobs: “do this once”Log — Kafka01234567analytics · replaying from 2billing · at 6messages stay; every group reads at its own offsetfor events and streams: “this happened” — replay is a seek
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.

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 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:

one process — no broker, no networkupload endpointreturns 202 at onceChannel<FileUploaded>in-memory queueBackgroundServicereads the channelthumbnailindexnotifyoutbox tablesurvives a crashTemporal decoupling and fan-out, as in Fig. 10 — without a broker to run or a network to fail.
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.
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 (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 III and IV – configuration from the environment; the database is an attached resource you plug in without a code change.
The domain never names a vendorswap an adapter = change config, restartdomain + applicationknows only interfaces (ports)userStoreSettings · where users livelocalActive DirectoryLDAPEntra IDloginSettings · how they sign inusername + passwordwith e-mail verificationActive DirectoryOAuth / OIDCMicrosoftGoogleOktaCustomCustom: PingID, anything elseIFileStore · from configAzure BlobAWS S3MinIOSharePointBoxGCP Storagelocal diskICache · from configRedisSQL ServerPostgreSQLMySQLin-memorysingle node onlydatabase · from configSQL ServerPostgreSQLMySQLOracleSQLitezero-config default · starts as is
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.

appsettings.json (illustrative)

{
  "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 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 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.