A monolith isn’t inherently bad; it’s just a single, large codebase that can become a tangled mess if you don’t actively manage its complexity.

Let’s see what this looks like in the wild. Imagine a typical e-commerce monolith. When a user adds an item to their cart, a single request hits your web server. This request triggers a cascade of operations within the monolith:

  1. Authentication Service: Verifies the user’s session.
  2. Product Catalog Service: Fetches product details (price, stock).
  3. Inventory Service: Decrements stock count.
  4. Cart Service: Adds the item to the user’s cart data.
  5. Recommendation Service: (Potentially) triggers a background job to update recommendations based on cart activity.

All of this happens within the same process, sharing memory and threads. It’s fast when it’s small and well-organized, but it’s also a single point of failure.

The core problem monoliths solve is simplicity of deployment and initial development. You build one thing, deploy one thing. Communication between components is direct function calls, not network requests. This bypasses latency and serialization overhead. However, as the codebase grows, the dependencies between these internal "services" become a labyrinth. The "Product Catalog Service" might directly call "Inventory Service," which might directly call "Pricing Service." A change in "Pricing Service" could inadvertently break "Inventory Service," which then breaks "Product Catalog Service," and suddenly adding to the cart fails for everyone.

The levers you control are primarily code organization and build/deployment pipelines. You need strict boundaries. Think of internal modules or packages as distinct "services."

  • Code Organization: Use clear module separation. A common pattern is to organize by feature (e.g., com.ecommerce.catalog, com.ecommerce.inventory, com.ecommerce.cart). Within these modules, enforce strict access control. The inventory module should only expose an API for inventory operations (e.g., decrementStock(productId, quantity)), and other modules should only use that public API, not reach into the inventory module’s internal data structures.
  • Build System: Configure your build system (Maven, Gradle, Bazel) to enforce these module boundaries. Tools like archunit can be integrated into your CI pipeline to fail builds if forbidden dependencies are detected (e.g., the cart module calling directly into the user_authentication module’s internal logic).
  • Deployment: While it’s a monolith, you can still deploy it in stages. Canary releases, blue-green deployments, and feature flags are critical. A bad deploy can take down the entire application, so minimizing the blast radius of a faulty deploy is paramount.
  • Observability: Because everything is in one process, tracing a request across different logical components requires robust logging and distributed tracing instrumentation within the monolith. Libraries like OpenTelemetry can help you tag logs and trace spans with moduleId or featureName to reconstruct the flow.

The one thing most people don’t realize is that even though it’s a single deployable artifact, you can (and must) treat internal modules as if they were separate services. This means defining clear, stable APIs for each module and rigorously testing those APIs in isolation. If your payment module needs to talk to your shipping module, it should go through a defined shippingService.createShipment(orderId) method, not by directly manipulating shipping data structures. This discipline prevents the kind of tight, implicit coupling that makes monoliths brittle.

The next hurdle you’ll face is managing configuration drift across multiple environments, even within a single artifact.

Want structured learning?

Take the full Monolith course →