Modular Monolith Architecture: The Middle Ground Between Monoliths and Microservices

Modular Monolith Architecture: The Middle Ground Between Monoliths and Microservices

Introduction

The industry has swung from monolithic architectures to microservices and back, often painfully. The modular monolith pattern offers a pragmatic middle ground: the organizational benefits of microservices with the operational simplicity of a monolith. This architecture style has gained traction at companies like Shopify, Basecamp, and GitHub for good reasons.

What is a Modular Monolith?

A modular monolith is a single deployable unit organized into well-defined, independent modules with explicit boundaries and contracts. Unlike traditional monoliths where components freely access each other’s internals, modular monoliths enforce strict module boundaries through architectural constraints and tooling.

Key Characteristics:

When to Use Modular Monoliths

Ideal Scenarios:

When to Avoid:

Implementation Patterns

Module Structure

A well-designed module contains:

payment-module/
├── api/           # Public interface (contracts)
├── domain/        # Business logic
├── infrastructure/ # Database, external services
└── tests/

Go Implementation Example

// payment/api/interface.go - Public contract
package api

type PaymentService interface {
    ProcessPayment(ctx context.Context, req PaymentRequest) (*PaymentResult, error)
    RefundPayment(ctx context.Context, paymentID string) error
}

type PaymentRequest struct {
    OrderID    string
    Amount     decimal.Decimal
    Currency   string
    CustomerID string
}

// payment/domain/service.go - Implementation (private)
package domain

import "myapp/payment/api"

type service struct {
    repo       repository
    gateway    PaymentGateway
    eventBus   EventBus
}

func NewService(repo repository, gateway PaymentGateway, bus EventBus) api.PaymentService {
    return &service{repo, gateway, bus}
}

func (s *service) ProcessPayment(ctx context.Context, req api.PaymentRequest) (*api.PaymentResult, error) {
    // Business logic here
    // Other modules CANNOT access this directly
}

// main.go - Wiring
func main() {
    db := setupDatabase()
    
    // Each module exposes only its interface
    paymentSvc := payment.NewService(...)
    orderSvc := order.NewService(paymentSvc, ...) // Dependency injection
    
    // orderSvc can ONLY use paymentSvc through api.PaymentService interface
}

Python Implementation Example

# payment/api.py - Public contract
from abc import ABC, abstractmethod
from dataclasses import dataclass
from decimal import Decimal

@dataclass
class PaymentRequest:
    order_id: str
    amount: Decimal
    currency: str
    customer_id: str

class PaymentService(ABC):
    @abstractmethod
    async def process_payment(self, req: PaymentRequest) -> PaymentResult:
        pass
    
    @abstractmethod
    async def refund_payment(self, payment_id: str) -> None:
        pass

# payment/domain/service.py - Implementation
from payment.api import PaymentService, PaymentRequest

class PaymentServiceImpl(PaymentService):
    def __init__(self, repo: Repository, gateway: Gateway):
        self._repo = repo  # Private
        self._gateway = gateway
    
    async def process_payment(self, req: PaymentRequest) -> PaymentResult:
        # Implementation details hidden
        pass

# Enforce module boundaries with import rules in pyproject.toml
[tool.import-linter]
    [[tool.import-linter.contracts]]
    name = "Module independence"
    type = "forbidden"
    source_modules = ["order"]
    forbidden_modules = ["payment.domain"]  # Can only import payment.api

ReactJS Frontend Modular Structure

// modules/payment/types.ts - Public types
export interface PaymentModule {
  processPayment: (request: PaymentRequest) => Promise<PaymentResult>;
  PaymentForm: React.ComponentType<PaymentFormProps>;
}

// modules/payment/index.ts - Public API
export { createPaymentModule } from './factory';
export type { PaymentModule } from './types';
// Internal components NOT exported

// modules/payment/factory.ts
import { PaymentService } from './internal/service';
import { PaymentForm } from './internal/components/PaymentForm';

export function createPaymentModule(apiClient: ApiClient): PaymentModule {
  const service = new PaymentService(apiClient);
  
  return {
    processPayment: service.processPayment.bind(service),
    PaymentForm: PaymentForm,
  };
}

// app/main.tsx - Module composition
import { createPaymentModule } from '@/modules/payment';
import { createOrderModule } from '@/modules/order';

const apiClient = createApiClient();
const paymentModule = createPaymentModule(apiClient);
const orderModule = createOrderModule(apiClient, paymentModule);

// orderModule can only access payment through PaymentModule interface

Enforcing Module Boundaries

1. Package/Directory Structure

Organize code so module internals are not publicly accessible:

2. Linting and Static Analysis

Use tools to enforce architectural rules:

3. Build-Time Verification

Fail CI builds if boundaries are violated:

# Example: Go architecture test
go test -tags=architecture ./...

# In architecture_test.go
func TestModuleBoundaries(t *testing.T) {
    assert.NoImportsFromPackage(t, "order", "payment/domain")
    assert.NoImportsFromPackage(t, "order", "payment/infrastructure")
}

Communication Between Modules

Synchronous (In-Process)

For strong consistency needs:

// Direct interface calls with transaction support
func (o *OrderService) CreateOrder(ctx context.Context, req CreateOrderRequest) error {
    return o.db.Transaction(func(tx *gorm.DB) error {
        order := createOrder(req)
        if err := o.orderRepo.Save(ctx, tx, order); err != nil {
            return err
        }
        
        // Call payment module within same transaction
        paymentReq := toPaymentRequest(order)
        _, err := o.paymentService.ProcessPayment(ctx, paymentReq)
        return err
    })
}

Asynchronous (Event-Driven)

For eventual consistency and decoupling:

# payment/domain/service.py
class PaymentServiceImpl:
    async def process_payment(self, req: PaymentRequest) -> PaymentResult:
        result = await self._gateway.charge(req)
        
        # Publish event instead of direct call
        await self._event_bus.publish(
            PaymentProcessedEvent(
                payment_id=result.id,
                order_id=req.order_id,
                amount=req.amount
            )
        )
        return result

# order/domain/event_handlers.py
class OrderEventHandlers:
    async def on_payment_processed(self, event: PaymentProcessedEvent):
        # Update order status asynchronously
        await self._order_service.mark_paid(event.order_id)

Evolution Path to Microservices

One of the strongest advantages of modular monoliths is the extraction path:

  1. Identify extraction candidate: Choose a module with clear boundaries
  2. Extract database tables: Create separate schema for the module
  3. Add API layer: Replace in-process calls with HTTP/gRPC
  4. Deploy separately: Move module to its own service
  5. Monitor and iterate: Observe performance and operational impact

The well-defined boundaries mean extraction is primarily infrastructure work, not architectural redesign.

Trade-offs and Considerations

Advantages:

Disadvantages:

Best Practices

  1. Start with modules, not microservices: Most systems don’t need distribution on day one
  2. Enforce boundaries from the start: Use linting and architecture tests
  3. Design modules as if they were services: Clear interfaces, no shared state
  4. Use domain-driven design: Modules should align with bounded contexts
  5. Invest in observability: Module-level metrics and tracing even in a monolith
  6. Regular boundary reviews: Ensure modules remain independent as the system evolves
  7. Extract only when necessary: Microservices should solve actual problems, not be the default

Conclusion

The modular monolith architecture offers a pragmatic approach for most systems. It provides clear boundaries and maintainability without the operational complexity of distributed systems. For principal engineers, this pattern enables teams to move quickly while maintaining architectural optionality. Start with a modular monolith, enforce strict boundaries, and extract to microservices only when you have concrete evidence that distribution solves a real problem.

The best architecture is the one that lets your team ship value fastest while maintaining quality. For most teams, that’s a modular monolith—not a microservices architecture.