This article advocates for building "disposable" modular monoliths structured with Clean Architecture and Domain-Driven Design's Bounded Contexts, especially in the early stages of a project. It argues against premature microservice adoption and unstructured monoliths, highlighting how a well-architected modular monolith provides operational simplicity and clear boundaries, facilitating future extraction into microservices if truly needed. The core idea is to enforce strict data ownership at the module level, preventing direct database coupling between domains.
Read original on Dev.to #architectureThe article critiques the prevalent industry trend of immediately adopting microservices, often leading to increased complexity without proportional benefits, particularly for applications not yet operating at massive scale. It also addresses the pitfalls of traditional, unstructured monoliths where tight coupling hinders maintainability and scalability. The author proposes a Modular Monolith as a balanced approach, combining the operational simplicity of a monolith with the architectural clarity and decoupled domains typically associated with distributed systems.
A common anti-pattern, even in ostensibly modular codebases, is allowing modules to directly access and join tables owned by other modules. This creates a deeply coupled system where the database schema dictates the architecture, undermining any logical module boundaries defined in code or diagrams. Such coupling makes refactoring, independent development, and eventual microservice extraction extremely difficult and costly.
Drawing from Domain-Driven Design, the article emphasizes Bounded Contexts as the fundamental unit of modularity. Each bounded context (e.g., Orders, Inventory, Billing) is responsible for its own data and exposes functionality only through explicit, well-defined interfaces. The critical rule is: no module touches another module's tables directly. Inter-module communication must happen through code interfaces, not direct SQL queries or shared ORM models.
# inventory/domain/ports.py # The ONLY way another module is allowed to ask Inventory for anything.
from abc import ABC, abstractmethod
from dataclasses import dataclass
@dataclass(frozen=True)
class StockLevel:
sku: str
available_quantity: int
class InventoryQueryPort(ABC):
"""Public contract for the Inventory bounded context. Other modules depend on THIS, never on Inventory's internals."""
@abstractmethod
def get_stock_level(self, sku: str) -> StockLevel:
...
# orders/application/place_order.py # Orders depends on the Inventory PORT, not on Inventory's database,
# models, or internal services. It doesn't even know Inventory uses Postgres.
from inventory.domain.ports import InventoryQueryPort
class PlaceOrderUseCase:
def __init__(self, inventory: InventoryQueryPort):
self._inventory = inventory
def execute(self, sku: str, quantity: int) -> bool:
stock = self._inventory.get_stock_level(sku)
if stock.available_quantity < quantity:
return False
# ... proceed with order creation
return TrueThis approach ensures that module boundaries are enforced by code dependencies rather than just conventions. It allows teams to manage complexity and decouple concerns within a single deployment unit, deferring the operational overhead of distributed systems until justified by business needs or scale requirements. The result is a system that is easier to reason about, test, and evolve, providing a smoother path for potential microservice extraction later on.
Within each bounded context, Clean Architecture principles are applied, ensuring that business rules are at the core and remain independent of external frameworks, databases, or UI concerns. This 'dependency rule' (dependencies always point inwards) makes the core domain logic highly testable and agnostic to infrastructure details, further enhancing modularity and maintainability.