Skip to content
ZYNQORALABS

The modular monolith before microservices

Microservices solve scaling and organizational problems, and create network and consistency ones. How to build a modular monolith that can be split later.

By ZynqoraLabs · · 3 min read

ShareLinkedInXWhatsApp

Many projects start with microservices because it looks like the "right" architecture for growth. Months later, the team spends more time debugging calls between services, incompatible contract versions and half-finished transactions than building features.

Microservices solve real problems. But they are specific problems, and the bill arrives before the benefits. For most new systems we start with a modular monolith: a single deployable application, internally divided into modules with boundaries as strict as those between separate services.

What microservices cost

Splitting a system into services turns function calls into network calls. With that come:

  • Partial failures. One service answers, another doesn't. Every call needs a timeout, a retry policy and a plan for when it fails.
  • Distributed consistency. An operation that used to fit in one database transaction now spans services and requires sagas, outboxes and compensations.
  • Versioned contracts. Changing the shape of data means coordinating versions across teams and deployments.
  • Operations. More pipelines, more monitoring, distributed tracing just to understand a single request.

Those costs pay off when there's a concrete reason: parts of the system with very different scaling needs, independent teams that must deploy without coordination, or requirements for fault and security isolation.

What a modular monolith is

A modular monolith is an application organized into domain modules (orders, billing, catalog, identity) with three rules:

  1. Each module exposes a public interface and hides everything else. Other modules only call that interface.
  2. Each module owns its data. No module reads or writes another module's tables directly, even in the same database.
  3. Dependencies between modules are explicit and tool-checked, not left to convention.
src/modules/
  orders/
    index.ts          ← the module's public interface
    internal/         ← rules, repositories, tables: invisible to other modules
  billing/
    index.ts
    internal/
  catalog/
    index.ts
    internal/

Boundaries enforced by tooling

Conventions erode with time and deadline pressure. A CI rule that fails when one module imports another module's internals keeps the boundary real.

.dependency-cruiser.cjs
module.exports = {
  forbidden: [
    {
      name: "no-reaching-into-other-modules",
      comment: "Modules talk to each other only through their public index.ts",
      severity: "error",
      from: { path: "^src/modules/([^/]+)/" },
      to: {
        path: "^src/modules/([^/]+)/internal/",
        pathNot: "^src/modules/$1/internal/",
      },
    },
  ],
};

The same applies to the database: each module uses its own schema (for example, orders.* and billing.* in PostgreSQL), and each module's credentials can be restricted to its own schema.

Communication between modules

Inside the monolith, modules can communicate in two ways, and choosing well makes a future extraction easier:

  • Synchronous calls through the public interface, when the caller needs the answer to continue.
  • Domain events published by one module and consumed by others, when the reaction can happen later. For example, order.created makes billing issue a charge.

If events already go through an outbox and are handled idempotently, turning them into broker messages on extraction day is a change of transport, not of architecture.

When to extract a service

With well-kept boundaries, extracting a module becomes a technical decision rather than a rewrite. Good reasons:

  • The module's load profile differs sharply from the rest (heavy batch processing, isolated traffic spikes).
  • A team needs to deploy at a pace the rest of the system can't match.
  • The module requires isolation: sensitive data, regulatory requirements, dependencies with their own risks.

Bad reasons: "microservices scale better" without a measurement showing the bottleneck, or "the code is messy". A messy monolith becomes messy microservices, just with a network in between.

Summary

A modular monolith delivers most of the organizational benefits of microservices, such as clear boundaries, data ownership and low coupling, without the network and distributed-consistency costs. With tool-enforced boundaries and communication through interfaces and events, extracting a service later is a planned operation, done when there is a measurable reason.