Skip to content

Modular Monolith

Building well-structured single applications that are ready to evolve

What is a Modular Monolith?

A modular monolith is a single deployable application built as a collection of independent, loosely-coupled modules. It combines the simplicity of monolithic deployment with the modularity and team autonomy typically associated with microservices. Each module encapsulates a specific business capability with clear boundaries and minimal dependencies on other modules.

At Synapse, we build modular monoliths by default. This approach gives us the development speed and operational simplicity of a monolith while maintaining the option to extract modules into separate services if and when the need arises.

Why Modular Monolith First?

The Microservices Trap

Starting with microservices often means solving distributed systems problems before you understand your domain boundaries. As Martin Fowler notes, "almost all the successful microservice stories have started with a monolith that got too big and was broken up" (MonolithFirst).

Premature distribution brings immediate costs:

  • Network complexity and latency
  • Distributed transactions and data consistency challenges
  • Operational overhead of multiple deployments
  • Debugging across service boundaries
  • Versioning and backwards compatibility

The Monolith Advantage

Stefan Tilkov argues that starting with a poorly structured monolith makes it harder to move to microservices later (Don't start with a monolith). But a well-structured modular monolith offers the best of both worlds:

Development Velocity

  • Single codebase to navigate
  • Refactoring across module boundaries
  • Atomic deployments
  • Simple local development

Operational Simplicity

  • One application to deploy and monitor
  • No network calls between modules
  • Consistent versioning
  • Simplified debugging

Future Flexibility

  • Clear module boundaries enable extraction
  • Proven domain boundaries through actual use
  • Gradual migration path to services

Core Principles

1. High Cohesion Within Modules

Each module should encapsulate a complete business capability. All code related to that capability—domain logic, use cases, API endpoints, and data access—lives within the module. This is an application of the coupling and cohesion principle: things that change together should live together.

2. Loose Coupling Between Modules

Modules communicate through well-defined interfaces, never through shared databases or internal implementation details. This controlled coupling ensures modules can evolve independently even within the same codebase.

3. Independent Module Development

Teams should be able to work on different modules with minimal coordination. Each module has its own:

  • Domain model
  • Use cases
  • API contracts
  • Data storage (logical separation, even if same database)
  • Test suite

4. Module Boundaries = Future Service Boundaries

Design module boundaries as if they were service boundaries. This makes future extraction straightforward when the time comes.

Module Architecture

Module Structure

Each module follows a consistent internal structure:

modules/
├── orders/
│   ├── domain/           # Business rules and entities
│   ├── use-cases/        # Application business logic
│   ├── infrastructure/   # Controllers, repositories, adapters
│   ├── api/             # Public module interface
│   └── tests/           # Module-specific tests
├── inventory/
│   ├── domain/
│   ├── use-cases/
│   ├── infrastructure/
│   ├── api/
│   └── tests/
└── shipping/
    └── ...

Module Boundaries

Public API Each module exposes a public API that other modules can depend on. This API is the only allowed point of coupling between modules. The public API defines the contract—what operations the module provides and what data it accepts and returns.

Private Implementation Everything except the public API is private to the module. Other modules cannot:

  • Import internal classes
  • Access the module's database tables
  • Depend on internal data structures

Inter-Module Communication

Direct Method Calls Within the monolith, modules communicate through direct method calls via their public APIs. This is simple and performant—no network overhead, no serialization, just function calls. The key is that these calls go through the public API, maintaining module boundaries.

Events For truly decoupled communication, modules can publish and subscribe to domain events. This allows modules to react to changes without direct dependencies. The publishing module doesn't know or care who's listening, and new subscribers can be added without modifying the publisher.

Shared Kernel Truly shared concepts (like Money, Email, or common types) can live in a shared kernel, but this should be minimal and stable.

Data Management

Logical Data Separation

Even when using the same database, modules should maintain logical separation:

  • Separate schemas or table prefixes
  • No foreign keys between modules
  • No cross-module joins
  • Each module owns its data completely
sql
-- Orders module tables
CREATE SCHEMA orders;
CREATE TABLE orders.orders (...);
CREATE TABLE orders.order_items (...);

-- Inventory module tables  
CREATE SCHEMA inventory;
CREATE TABLE inventory.products (...);
CREATE TABLE inventory.stock_levels (...);

Data Consistency

Without distributed transactions, maintain consistency through:

  • Eventual consistency via events
  • Saga patterns for multi-module operations
  • Compensating transactions for failure handling

Evolution Path

Starting Simple

Begin with a small number of coarse-grained modules. It's easier to split modules than to merge them.

Initial:
├── core/          # User management, auth
├── commerce/      # Orders, inventory, pricing
└── fulfillment/   # Shipping, delivery

Recognizing Module Boundaries

Signs that you've found the right boundaries:

  • Teams can work independently
  • Changes rarely cross module boundaries
  • Modules have different scaling needs
  • Clear business capability ownership

Extracting to Services

When a module needs to become a service:

  1. Strangler Fig Pattern: Route traffic to the new service gradually
  2. Database Separation: Migrate module data to its own database
  3. API Gateway: Replace direct calls with HTTP/gRPC
  4. Independent Deployment: Deploy the extracted service separately

The modular structure makes this extraction mechanical rather than architectural.

Benefits at Synapse

Speed to Market

We can build and iterate quickly without distributed systems complexity.

Team Autonomy

Teams own modules end-to-end while sharing operational concerns.

Gradual Scaling

We can scale the whole application until specific modules need independent scaling.

Proven Architecture

Module boundaries are tested in production before committing to service boundaries.

Operational Efficiency

One application to deploy, monitor, and debug reduces operational overhead.

Common Pitfalls

Shared Database Mutations

Never let modules directly modify another module's data. Always go through the public API.

Leaky Abstractions

Don't expose internal implementation details through the public API.

Premature Extraction

Resist extracting services until you have clear evidence of need (scaling, team boundaries, deployment cadence).

Insufficient Module Isolation

Modules that are too tightly coupled defeat the purpose. Enforce boundaries through tooling and code review.

When to Extract Services

Consider extraction when:

  • Scaling needs diverge: One module needs 100x the resources of others
  • Team boundaries solidify: Separate teams with different deployment cadences
  • Technology requirements differ: One module needs a different tech stack
  • Compliance demands isolation: Regulatory requirements for data separation
  • Performance requires it: Synchronous coupling becomes a bottleneck

Until these pressures exist, the modular monolith provides the best balance of simplicity and flexibility.

Applied In

Further Reading