Why a modular monolith beats microservices for small teams: draw internal seams by rate of change, enforce module boundaries in code, and split later without a rewrite.
A founder sketched his architecture on a napkin for me over coffee in Dubai last year, and I felt tired just looking at it. Seven microservices, a message bus, service discovery, a Kubernetes cluster — for an app with roughly 200 daily users and a team of two. He was proud of it. He was also six weeks behind on the one feature his customers actually asked for, because shipping it meant coordinating a change across four repos and three deploys. He'd bought the architecture of a company he hoped to become, and paid the tax before he had the revenue.
I've built enough 0-to-1 products to have strong opinions here, and mine is boring: for a small team, the right first architecture is almost always a modular monolith. One deployable, one database, one place to reason about — but with internal seams drawn deliberately, so that the day you genuinely need to split a piece off, you're cutting along a line you already marked, not sawing through the middle of a live system. This post is about where I'd draw those seams, why, and the two or three I'd actually cut early. If you're weighing monolith vs microservices for an early-stage product, this is the decision framework I wish someone had handed me before I paid for a few of these lessons the expensive way.
Microservices are sold as a technical decision. They're really an organizational one. Splitting a system into independently deployable services lets independent teams ship without stepping on each other — that's the whole point of the pattern, and it's essentially Conway's Law turned into infrastructure. If you have one team of four, you don't have that problem. You've bought the solution to a problem you don't have, and it comes with a bill.
Here's what's on the bill, and nobody itemizes it before you sign:
None of this is theoretical. On one project I inherited, a "simple" order-status update touched three services, and about 30% of the on-call pages traced back to one of those hops timing out under load — a class of failure that literally cannot exist inside a single process. We didn't have a scaling problem. We had a topology problem we'd inflicted on ourselves. This is the core trap of premature microservices: you take on the full operational cost of a distributed system to buy team autonomy you don't need yet, while your actual constraint is shipping features fast enough to find product-market fit.
The instinct behind microservices — separation of concerns, clear ownership, isolated blast radius — is correct. The mistake is thinking the network is the only tool that gives you those things. It isn't. A well-drawn module boundary inside one codebase gives you 90% of the benefit at 10% of the cost.
The asymmetry that matters: module boundaries are reversible, network boundaries are not. If you get a module split wrong, you move some files and change some imports on a Tuesday afternoon. If you get a service split wrong, you've got two deploy pipelines, a shared data problem, and a chatty API contract that's now load-bearing for other teams. Undoing it is a project, not a refactor.
So the strategy writes itself. Do the cheap, reversible thing first — draw hard module boundaries. Only convert a module boundary into a network boundary when you have a specific, present reason: this piece scales on a different curve, needs a different runtime, needs independent deploy cadence, or has a hard security isolation requirement. "We might need to scale it someday" is not a reason. It's a fear, and you can address the fear by making the boundary clean, not by paying the network tax today for a bill that may never come due.
This is the same logic Martin Fowler popularized as "MonolithFirst," and it's the one Shopify has publicly leaned into with its "majestic modular monolith": you almost never know the right service boundaries until the domain has stabilized, and the cheapest place to be wrong about a boundary is inside one process where moving it costs an afternoon.
The most common way small teams carve up a system is by noun. User service, Product service, Order service, Payment service. It feels tidy. It's usually wrong, because nouns don't tell you where the real fault lines are.
Draw seams by rate of change and reason for change instead — this is the domain-driven design idea of a bounded context, minus the ceremony. Group things that change together, for the same reasons, driven by the same people. Separate things that change on different clocks. Two questions cut most boundaries cleanly:
Noun-based splitting produces the classic anti-pattern: rendering one screen requires calling five services, because the data a single view needs is scattered across five "clean" object boundaries. You optimized for a tidy diagram and pessimized for the actual access pattern. On a recent build we caught this early — an early cut had "User" and "Profile" and "Preferences" as separate modules, and literally every screen needed all three. We merged them into one Identity module and the noise dropped immediately. They changed together, they were read together; they were one thing wearing three hats.
A quick gut-check I use: if two proposed modules appear together in almost every use case and almost every database query, they are one module. High coupling and high cohesion between two "separate" things is the system telling you the seam is in the wrong place.
A module boundary you can't enforce is a comment, not a boundary. If any file can import any other file, "modularity" degrades into a big ball of mud within a quarter — I've watched it happen. The whole value of the modular monolith is that the seams are real, so you have to make crossing them cost something.
Concretely, three rules:
SELECT against it. Break this rule and your "future service split" quietly becomes impossible without a data migration.In Dart, I lean on package structure and analyzer rules to make this bite rather than trusting discipline. The pattern I use is one Dart package per module, each with a single public barrel file and everything real living under src/:
# analysis_options.yaml — make cross-module reach-ins a build erroranalyzer: errors: invalid_use_of_internal_member: error depend_on_referenced_packages: error# Everything under a module's src/ is off-limits from outside.# Callers may only import the package's public barrel file, so a# reach-in past the boundary fails the build instead of the review.
Dart's package: layout gives you this almost for free: anything under lib/src/ is conventionally private to the package, and a barrel file in lib/ re-exports only the public surface. Tools like import_lint or a custom custom_lint rule can promote "you imported past another module's barrel" from a convention into a CI failure. In other ecosystems the equivalents are ArchUnit (JVM), eslint-plugin-boundaries (TypeScript), or internal-package visibility in Go — the principle is identical, only the enforcement mechanism changes.
And here is the shape of a module's public surface, so callers depend on an abstraction, not on rows in a table:
// billing/billing.dart — the ONLY thing other modules may import.// Everything under billing/src/ is internal.abstract interface class BillingFacade { Future<Invoice> chargeOrder(OrderId orderId, Money amount); Future<PaymentStatus> statusOf(InvoiceId id);}// Orders depends on THIS interface — never on a billing table,// never on a concrete class from billing/src/. When billing becomes// a service later, this interface is the only thing that changes.That last comment is the whole game. If Orders only ever knew Billing through BillingFacade, then extracting Billing into its own service is swapping the in-process implementation for an HTTP or gRPC client behind the same interface. Callers don't change. If Orders knew Billing through shared tables and reached-in helpers, extraction is a rewrite. The facade is doing the same job a service's API contract will do later — you're just writing it years early, for free, in a language your compiler can check.
I said modular monolith, and I meant it — but "monolith" doesn't mean "literally one process for everything." A few things earn their own runtime early, because they fail the module test on axes a module can't paper over: they run on a fundamentally different resource curve, cadence, or trust boundary. In my experience it's usually these three.
Notice what's not on this list: your core domain. Orders, catalog, users, the actual product. That stays in the monolith the longest, because it's the part that changes together most and benefits most from cheap in-process calls and real transactions. The stuff worth extracting early sits at the edges — the async, the untrusted-input, the foreign-runtime. The core is the last thing you split, if you ever do.
The promise of the modular monolith is that "split later" is a real option, not a comforting lie. It's only real if you kept the interfaces honest. Here's the hygiene that keeps the option open:
Order object wired to the database session, that object cannot survive a trip over the network. Hand over an OrderId and a plain data record. Make the in-process contract already look like something serializable — because one day it will have to be.When the day comes, the split is mechanical:
// The ONLY change at extraction time: a new implementation of the// same facade. Every caller in the app keeps importing BillingFacade// and never learns that Billing now lives across a network hop.final class RemoteBillingClient implements BillingFacade { RemoteBillingClient(this._http); final HttpClient _http; @override Future<Invoice> chargeOrder(OrderId orderId, Money amount) async { final res = await _http.post('/billing/charge', body: { 'orderId': orderId.value, 'amountMinor': amount.minorUnits, 'currency': amount.currency, }); return Invoice.fromJson(res.json); // IDs and value objects, not entities } // ... statusOf() likewise becomes one HTTP call}If steps 2 and 3 are hard, that's not a splitting problem — it's a diagnosis. It means the boundary was never clean, and you're finding that out under a deadline instead of in a code review months earlier. The strangler-fig approach — stand up the new implementation behind the flag and migrate callers gradually — only works when there's a single interface to strangle. That interface is the facade you wrote on day one.
Let me be honest about one I got wrong, because the theory above is partly scar tissue.
On an early product, we split notifications into its own service almost immediately. The reasoning sounded airtight: notifications are async, they call third-party providers (push, email, SMS), and "everyone" extracts notifications. So we did it in week three.
What actually happened:
We folded it back into the monolith as a module about four months later. Same code, minus the HTTP layer, plus a facade. Sends got faster and more reliable overnight, because the preferences lookup was a function call again instead of a network gamble. What we should have extracted was the delivery worker — the async, provider-calling, retry-heavy part that genuinely scales on its own curve — while leaving the decision to notify, and all the user data it needs, inside the core. We split by noun ("notifications") when we should have split by rate of change and resource profile (the bursty delivery pool). The seam was in the wrong place, so the cut just bled.
That's the lesson under all of this. Splitting too late costs you some refactoring. Splitting too early — or along the wrong seam — costs you a distributed system you have to operate while you're still trying to find product-market fit. One of those is a Tuesday afternoon. The other is your on-call rotation for a year.