fig. Bsystem-design
Start With a Modular Monolith: Why Microservices Can Wait
Most teams don't need microservices yet. See how a modular monolith gives you clean boundaries, simple deploys and a clear path to split services later.
- Written by
- Mayank Panchal
- Published
- Reading time
- 3 min
- Filed under
- system-design
- system-design
- architecture
- backend
On this page · 6
A common way to start a new backend is to draw boxes: an auth service, a billing service, a notifications service, each with its own repository and database. It looks scalable. For most products at the start, it mostly buys you problems.
This post argues for the boring alternative and shows how to build it so that splitting later is a mechanical job, not a rewrite.
What you pay for with microservices#
Splitting into services does not remove complexity. It moves it into the network, where it is harder to see:
- Every call can fail. A function call became an HTTP request with timeouts, retries and partial failure.
- No more transactions. Data owned by two services can't be updated atomically. You need sagas, outbox tables and idempotency keys.
- Operational weight. Each service needs its own pipeline, monitoring, secrets and on-call attention.
- Slower local development. Running the system means running ten processes and a message broker.
Those costs are worth paying when you have the problem they solve: many teams stepping on each other, or one component with wildly different scaling needs. A small team with one product usually has neither.
The middle path#
A modular monolith is one deployable application whose inside is divided into modules with strict boundaries. You keep the single deploy, the single database and the ordinary function calls, but you enforce the separation that makes services possible.
app/
modules/
billing/
api.py # the only file other modules may import
service.py
models.py
repository.py
accounts/
api.py
service.py
models.py
notifications/
api.py
...
main.pyThe rules that make it work#
1. Talk through the public interface. Other modules import billing.api, never billing.models or billing.repository. The module decides what it exposes.
# billing/api.py
from .service import create_invoice, get_balance
__all__ = ["create_invoice", "get_balance"]2. Don't reach into another module's tables. Each module owns its tables. If notifications needs a customer's email, it asks accounts through its API rather than joining against the users table. This is the rule that makes a later split cheap, and the one teams break first.
3. Pass data, not ORM objects. Return plain dataclasses or Pydantic models from a module's API so callers can't lazily load related rows across the boundary.
4. Keep dependencies pointing one way. If billing calls accounts, then accounts must not call billing. Cycles are the clearest sign your boundaries are wrong.
5. Use events for side effects. When an invoice is paid, billing publishes InvoicePaid and other modules subscribe, so billing doesn't need to know about emails or analytics.
# a tiny in-process event bus
from collections import defaultdict
from typing import Callable
_handlers: dict[type, list[Callable]] = defaultdict(list)
def subscribe(event_type: type, handler: Callable) -> None:
_handlers[event_type].append(handler)
def publish(event) -> None:
for handler in _handlers[type(event)]:
handler(event)Start in-process like this. If a module ever becomes a service, the same events move onto a real broker and the publisher doesn't change.
Enforce the boundaries with tooling#
Rules that live only in a document get broken within a month. Check them automatically:
- An import linter (for example
import-linterin Python, or ESLint'sno-restricted-importsin TypeScript) that fails CI whennotificationsimportsbilling.models. - A test that fails if module A's imports create a cycle.
When to split a module out#
Extract a service only when you can name the reason:
- A component needs to scale independently, for example a video-processing worker.
- A separate team needs to deploy it on its own schedule.
- It needs a different runtime or a hard isolation boundary, such as handling payment card data.
Because the module already has a narrow API, its own tables and event-based side effects, extraction is mostly swapping function calls for HTTP or messages at one seam.
The takeaway#
Microservices are an answer to organisational and scaling pressure, not a starting point. Build one deployable with firm internal walls and you keep nearly every benefit of microservices (clear ownership, replaceable parts) while avoiding the distributed-systems tax until you can actually justify it.
fig. B.1keep reading
Related posts
· 4 min
Rate Limiting a FastAPI App with Redis: The Token Bucket, Done Right
Build a production-ready token bucket rate limiter for FastAPI using Redis and a Lua script. Covers atomicity, 429 responses, Retry-After and proxies.
Read →· 4 min
Is the AI Boom a Bubble? A Developer's Read of the Numbers
Hundreds of billions are going into AI infrastructure. Here is what the spending, the revenue and the skeptics say, and what it means for developers.
Read →· 4 min
China's AI Data Center Bust: What Actually Happened (and What Didn't)
China built AI data centers faster than demand arrived. Here is what the idle capacity story says, what changed in 2026, and the lesson for builders.
Read →