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:
- Single deployment unit (one process, one database)
- Modules with clear boundaries and contracts
- Inter-module communication through well-defined interfaces
- Module independence (high cohesion, loose coupling)
- Ability to evolve into microservices if needed
When to Use Modular Monoliths
Ideal Scenarios:
- Early-stage products: Domain boundaries are unclear and will evolve
- Small to medium teams: Fewer than 50 engineers who can coordinate effectively
- Rapid iteration needs: Deployment simplicity enables faster cycles
- Cost-sensitive environments: Lower infrastructure and operational overhead
- Complex transactional workflows: ACID transactions are simpler in a monolith
When to Avoid:
- Extreme scale requirements: Different components need independent scaling characteristics
- Distributed teams: Teams working on different modules across time zones with minimal coordination
- Heterogeneous technology needs: Different modules genuinely require different tech stacks
- Regulatory isolation: Compliance requires physical separation of components
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:
- Go: Use internal packages (
payment/internal/) - Python: Use private modules (prefix with
_) - TypeScript: Use path aliases and export controls
2. Linting and Static Analysis
Use tools to enforce architectural rules:
- Go:
go-cleanarch, custom linters withgolangci-lint - Python:
import-linter,pydeps - TypeScript:
eslint-plugin-boundaries,dependency-cruiser
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:
- Identify extraction candidate: Choose a module with clear boundaries
- Extract database tables: Create separate schema for the module
- Add API layer: Replace in-process calls with HTTP/gRPC
- Deploy separately: Move module to its own service
- 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:
- Simpler deployment: Single artifact, no distributed system complexity
- ACID transactions: Cross-module consistency is straightforward
- Better IDE support: Easier refactoring and code navigation
- Lower operational overhead: One service to monitor, debug, and scale
- Faster local development: No need to run multiple services
Disadvantages:
- Scaling limitations: Cannot independently scale modules
- Technology lock-in: All modules use the same language/runtime
- Deployment coupling: Changes to any module require full deployment
- Team coordination: Shared codebase requires coordination on dependencies
- Database contention: All modules compete for database resources
Best Practices
- Start with modules, not microservices: Most systems don’t need distribution on day one
- Enforce boundaries from the start: Use linting and architecture tests
- Design modules as if they were services: Clear interfaces, no shared state
- Use domain-driven design: Modules should align with bounded contexts
- Invest in observability: Module-level metrics and tracing even in a monolith
- Regular boundary reviews: Ensure modules remain independent as the system evolves
- 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.