Two kinds of micromonolith
· 18 min read
"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.
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 · 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.
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:
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.
Transactions
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.
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.
// 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.
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
systemdservice 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.targetThe 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
.protocontracts, 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.
| 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.
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:
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.
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.
UserStoreSettingssays where users live – local (the application's own database), Active Directory, LDAP or Entra ID.LoginSettingssays 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
ICacheport 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:
- A team boundary: a separate team needs to ship that part on its own schedule.
- A genuinely different scaling profile: one part needs 40 instances or GPUs while the rest needs two small VMs.
- A different runtime or release constraint: another language, a regulated component, a part that must not go down with the rest.
- 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
- Microservices and Kubernetes are scaling tools, not quality tools. They don't make your code better.
- Splitting is never free. Latency tails, a third failure state, sagas instead of transactions, mandatory tracing.
- Microservices answer an organisational question. No such question, no such answer.
- Enforce module boundaries at compile time – separate assemblies,
internal, architecture tests. Then extraction is a refactor. - Write a good monolith that is multi-node-ready from day one. After that, scaling is a load balancer and a few instances.
- Kubernetes is a separate axis. The question is workloads and operational maturity, not service count.
- 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.