I’ve watched a four-person team spend two months building a service mesh for a product that had eleven total users. I’ve also watched a team stay on a single unstructured monolith long after every deploy meant hoping the checkout code didn’t break the reporting code, because both lived in the same process with no boundaries at all. The right answer for an early-stage team is almost never at either extreme, and it’s rarely the one that’s currently trendy on engineering Twitter.
Start with a monolith – the argument is mostly settled
For a new product with a small team, a single deployable application is the right default. You don’t have the traffic that justifies independent scaling, you don’t have the team size that justifies independent deploy schedules, and you don’t yet know where your actual service boundaries should be – that only becomes clear once real usage patterns emerge. Martin Fowler’s “MonolithFirst” essay makes this case directly: most successful microservice systems started as monoliths that were split once the boundaries were understood, not designed as services from day one (martinfowler.com/bliki/MonolithFirst.html).
The real cost you’re avoiding
Services aren’t free even when they’re the right call eventually – each one adds a network boundary, a separate deploy pipeline, its own on-call surface, and a distributed systems problem (partial failure, retries, eventual consistency) that a function call inside one process never has. Fowler’s later piece on microservice trade-offs is a useful antidote to the assumption that services are simply the more “correct” architecture (martinfowler.com/articles/microservice-trade-offs.html). At five engineers, paying that cost before you need it means paying it instead of building product.
A monolith doesn’t mean a mess
The mistake that makes “monolith” a dirty word isn’t the single deployable – it’s the lack of internal boundaries inside it. A modular monolith keeps one deploy unit but enforces separation between domains in code: billing doesn’t reach into the internals of the user module, it calls a defined interface, the same way it would call a service over the network, just without the network.
src/
billing/
api.py # public interface other modules call
internals.py # not imported outside this folder
users/
api.py
internals.py
shipping/
api.py
internals.py
This structure costs almost nothing to set up compared to actual services, and it does most of the work that people actually want from “microservices” – forcing explicit boundaries, making ownership clear, keeping one team’s changes from silently breaking another’s. It also makes the eventual split, if you need one, mechanical instead of archaeological: the module already has a defined interface and its own tables.
The middle path: extract one service on purpose
Splitting doesn’t have to mean an all-or-nothing rewrite into a services architecture. It’s common, and often correct, to pull exactly one piece out of an otherwise single-deployable monolith when it has a genuinely different profile – a video transcoding worker, an email-sending pipeline, a scheduled report generator – things that benefit from independent scaling or a different language, while the rest of the product stays as one deployable. This gets you the specific benefit you actually need without paying the coordination cost of a fully distributed system for the parts of the app that don’t need it.
Data ownership matters more than the deploy boundary
The part of this decision that’s hardest to undo isn’t the deploy topology, it’s the data model. A modular monolith where every module owns its own tables and never queries another module’s tables directly can be split into real services later with a manageable amount of work. A monolith where every module freely joins across the whole schema has to solve that problem first, before a service split is even possible – untangling shared tables after years of ad hoc joins is a much bigger project than extracting the code. If you take one thing from a modular-monolith approach, make it this.
Signals that it’s actually time to split
A few concrete signs are worth watching for, instead of splitting on a schedule or a feeling: one part of the system needs to scale independently and is forcing you to over-provision the whole app to compensate; deploys are getting risky because unrelated teams’ changes are landing in the same release; or a specific domain has a genuinely different operational profile – different language, different compliance requirements, different uptime target – that a shared deploy can’t serve well. A team size mismatch, more than roughly two teams working in one codebase, is an organizational signal worth taking seriously too, independent of the technical one.
What not to do
Don’t split along guessed boundaries before you’ve felt real pain at those boundaries – the seams you’d predict on day one are rarely the ones that matter once actual usage shows up. And don’t treat “microservices” as a hiring or credibility signal; AWS’s own introduction to the pattern is honest that it solves organizational and scaling problems, not code quality problems, and it introduces new ones of its own (aws.amazon.com/microservices).
The pragmatic path for a small team: one deployable, real module boundaries enforced in code from day one, and a genuine trigger – not a vibe – before you pay the cost of splitting anything out.