Skip to content

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
  1. What you pay for with microservices
  2. The middle path
  3. The rules that make it work
  4. Enforce the boundaries with tooling
  5. When to split a module out
  6. The takeaway

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.

Text
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.py

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

Python
# 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.

Python
# 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-linter in Python, or ESLint's no-restricted-imports in TypeScript) that fails CI when notifications imports billing.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.

Written by

Mayank Panchal

Software Developer & Full-Time Learner. Coding on the clock, learning around the clock. Software developer in Bengaluru - Next.js, FastAPI, PostgreSQL, AI-native tooling.

fig. B.1keep reading

fig. B.2contact

Let’s build something sharp.