Software Architecture: Monolith, Microservices and the Distributed Monolith Trap

Software Architecture: Monolith, Microservices and the Distributed Monolith Trap

Table of Contents

Many young Backend programmers tend to view software architecture models as a religion or a measure of skill. Microservices are often revered as the pinnacle of technology, Monolith is labeled as outdated, while Distributed Monolith - the worst state - is often mistaken for real microservices.

System design is not about showcasing complexity. It’s a series of trade-off decisions. Each network boundary set up, each database separated comes with a hefty bill in terms of infrastructure costs, operational complexity, and feature velocity.

This article dissect the technical nature of the three architecture models from the practical lens of a Backend Engineer, while analyzing classic misconceptions and computer science laws that directly affect their success or failure.

1. Monolithic Architecture (Single-Block Architecture)

Monolith is not “spaghetti code”. It is a software distribution model in which the entire application is packaged and runs as a single process on the same memory address space.

Technical Nature

graph TB
  subgraph "Physical Server (Single Process Boundary)"
    subgraph "Monolithic Application"
      Order[Order Module]
      Payment[Payment Module]
      Inventory[Inventory Module]
    end
    DB[("`Single Database
- Shared Schema
- ACID Transaction`")]
  end

  User([User]) -->|HTTP/HTTPS| Order
  Order -->|"`In-Memory Call
- 0ms Latency
- Local Interface`"| Payment
  Order -->|"In-Memory Call"| Inventory
  Order -.->|"ACID Transaction"| DB
  Payment -.->|"ACID Transaction"| DB
  Inventory -.->|"ACID Transaction"| DB

  style DB fill:#1e1e2e,stroke:#a6e3a1,stroke-width:2px
  • In-memory function calls: Business modules communicate directly through function calls. This communication occurs within an extremely short CPU cycle, with zero latency and eliminates the risk of network disconnections.
  • ACID Transactions at the database: Since a single database is used, ensuring data consistency is extremely simple. Complex data writing tasks on multiple tables (e.g., creating orders and deducting wallet balances) are protected by the local Transaction mechanism of the relational database management system (RDBMS). If one step fails, the entire task is automatically rolled back.

Practical Advantages

  • Feature Velocity (Development Speed): When changing data structures or modifying function signatures that affect multiple modules, IDEs can perform safe refactoring on the entire codebase in seconds.
  • Simplified Operations (Low Ops): Only one executable file (e.g., Go/Rust binary) or a source code directory (Python/Node.js) is needed. The deployment process is simplified to uploading a file to a VPS or running a single Docker container. No API Gateway, Service Mesh, or complex configuration management systems are required.
  • Hardware Optimization: Maximizes the use of CPU cache and shared address space, avoiding RAM waste due to duplicate virtualization.

Technical Disadvantages

  • Concurrent Development (Human Bottleneck): When the team exceeds 30-40 developers working on the same codebase, source code conflicts and overlaps in the CI/CD pipeline will appear continuously. This is when Conway’s Law starts to take effect.
  • Uneven Scaling: If only the image processing module requires a lot of memory (RAM), while the payment module only needs CPU, you still need to scale the entire Monolith process to larger servers, resulting in resource waste.
  • Single Point of Failure (Cascading Failure): A memory leak or unhandled panic at a minor feature can also bring down the entire application process, causing the entire system to stop working.

2. Microservices Architecture

Microservices are a distributed architecture that divides a system into small services that run independently on separate processes and communicate with each other through networks (network protocols) such as gRPC, HTTP REST, or Message Queue (Kafka, RabbitMQ).

Technical Nature

graph TB
  User([User]) -->|HTTP| GW[API Gateway]

  subgraph "Network Boundary (gRPC / REST / Event-Driven)"
    subgraph "Order Service (Team A)"
      Order[Order Service]
      OrderDB[("`Order DB
Isolated Schema`")]
    end

    subgraph "Payment Service (Team B)"
      Payment[Payment Service]
      PaymentDB[("`Payment DB
Isolated Schema`")]
    end

    subgraph "Inventory Service (Team C)"
      Inventory[Inventory Service]
      InventoryDB[("`Inventory DB
Isolated Schema`")]
    end

    MessageBus[["`Message Broker
- Kafka / RabbitMQ
- Eventual Consistency`"]]
  end

  GW -->|Route| Order
  GW -->|Route| Payment
  
  Order -->|"`gRPC Call
- Latency > 5ms
- Timeout/Retry`"| Payment
  Order -->|"Publish Event"| MessageBus
  MessageBus -->|"Subscribe"| Inventory

  Order -.-> OrderDB
  Payment -.-> PaymentDB
  Inventory -.-> InventoryDB

  style GW fill:#1e1e2e,stroke:#cba6f7,stroke-width:2px
  style MessageBus fill:#1e1e2e,stroke:#f9e2af,stroke-width:2px
  • Database per Service: This is a prerequisite for achieving independence. Each service owns its database completely. No service is allowed to read/write directly to another service’s database. All data interactions must go through the API layer provided by that service.
  • Network Boundary & Fallacies of Distributed Computing: Communication between modules is no longer a function call in memory, but rather network hops over the network. You must face the 8 fallacies of distributed computing, especially: the network is always reliable, latency is zero, and bandwidth is infinite.

Practical Advantages

  • Suitable for Conway’s Law: Conway’s Law states that the system structure mimics the organization’s communication structure. Microservices allow the organization to be divided into small teams (Two-Pizza Teams). Team A can develop and deploy the Order Service without needing permission or affecting Team B’s work on the Payment Service.
  • Fault Isolation: A panic error that crashes the Order Service will not directly crash the Payment Service. The system can still serve some features if a fallback mechanism is designed.
  • Technological Diversity: Allows choosing the optimal language and framework for each specific problem (e.g., using Python for AI/Data Science, using Go for high-performance API Gateway).

Technical Disadvantages (The Price to Pay)

  • Distributed Transactions: It’s very difficult to maintain ACID Transactions. When a business behavior needs to write data to multiple services (e.g., deducting wallet money -> reducing inventory -> creating an invoice), you must design a data compensation mechanism like the Saga Pattern (Choreography or Orchestration) combined with the Outbox Pattern to ensure eventual consistency.
  • Network Overhead: Communicating over the network requires continuous serialization (converting struct to JSON/Protobuf) and deserialization, plus physical network latency. A long chain of service calls will significantly increase the overall response time.
  • Operational Burden: Requires complex container management infrastructure (Kubernetes), centralized log collection systems (Loki/Elasticsearch), distributed tracing mechanisms (OpenTelemetry, Jaeger), and network error recovery mechanisms (Circuit Breaker, Rate Limiting).
  • Cognitive Load: Programmers no longer just focus on writing business logic. They must constantly handle network error scenarios: “If the Payment API call times out, what should the Order Service do? Retry how many times? How to avoid duplicate calls (Idempotency)?”

3. Distributed Monolith (Đơn khối phân tán) - Tấn bi kịch tồi tệ nhất

Distributed Monolith là một phản mẫu kiến trúc (architectural anti-pattern) cực kỳ phổ biến. Đây là trạng thái khi một hệ thống được chia tách thành nhiều dịch vụ vật lý độc lập chạy trên mạng, nhưng logic nghiệp vụ và dữ liệu của chúng lại liên kết quá chặt chẽ (tight coupling).

Bản chất kỹ thuật

graph TB
  User([User]) -->|HTTP| GW[API Gateway]

  subgraph "Tightly Coupled Services"
    Order[Order Service]
    Payment[Payment Service]
    Inventory[Inventory Service]
  end

  DB[("`Shared Database
- Single Point of Failure
- Coupled Schema
- Lock Contention`")]

  GW --> Order
  GW --> Payment

  Order -->|HTTP Call| Payment
  Payment -->|HTTP Call| Inventory

  Order -.->|"Direct Query / JOIN"| DB
  Payment -.->|"Direct Query / JOIN"| DB
  Inventory -.->|"Direct Query / JOIN"| DB

  style DB fill:#f38ba8,stroke:#f38ba8,stroke-width:2px

Một hệ thống rơi vào bẫy Distributed Monolith khi có các dấu hiệu sau:

  1. Dùng chung một Database vật lý (Shared Database): Các dịch vụ khác nhau truy cập và chỉnh sửa trực tiếp vào cùng một schema cơ sở dữ liệu.
  2. Khóa chặt quy trình deploy (Coordinated Deployments): Không thể deploy Order Service nếu không deploy cùng lúc Payment Service vì logic của hai bên phụ thuộc lẫn nhau.
  3. Tách service nhưng vẫn dùng chung thư viện chứa business logic: Mọi thay đổi logic ở thư viện dùng chung đều buộc toàn bộ các dịch vụ phải cập nhật và deploy lại.

Tại sao đây là một thảm họa?

Distributed Monolith gom góp tất cả các nhược điểm của Microservices lẫn Monolith nhưng loại bỏ hoàn toàn ưu điểm của cả hai:

  • Mất quyền sở hữu Schema: Khi Team A thay đổi cấu trúc bảng orders, họ có thể vô tình làm sập Payment Service của Team B do Team B cũng viết câu lệnh SELECT trực tiếp vào bảng đó. Kết quả là không ai dám tối ưu hóa hay thay đổi cấu trúc database vì sợ ảnh hưởng đến các dịch vụ khác.
  • Overhead mạng vô nghĩa: Các dịch vụ giao tiếp với nhau qua HTTP/REST, chịu độ trễ mạng vật lý, nhưng cuối cùng lại cùng ghi dữ liệu vào một ổ đĩa cứng của một database duy nhất. Điều này tạo ra nút thắt cổ chai (bottleneck) tại cơ sở dữ liệu trong khi tăng thêm điểm lỗi tiềm ẩn (point of failure) trên đường truyền mạng.
  • Deploy cồng kềnh: Mất hoàn toàn khả năng deploy độc lập. Mỗi lần cập nhật tính năng, đội ngũ phát triển phải lập kế hoạch bảo trì phức tạp để deploy đồng bộ một nhóm dịch vụ cùng lúc.

4. Comparative Analysis from an Academic Perspective

To make an accurate choice of architecture, we need to evaluate based on the core theories of computer science:

CriteriaMonolithicMicroservicesDistributed Monolith
Address SpaceShared (In-memory)Separated (Over the network)Separated (Over the network)
Data OwnershipShared DatabaseDatabase per Service (Encapsulated)Shared Database (Coupled)
Data ConsistencyACID TransactionEventual Consistency (Saga / Outbox)ACID (but affected by lock and network)
Communication ReliabilityAbsolute (Direct function call)Network risk (Fallacies of Distributed Computing)Network risk + Database bottleneck
Transaction LatencyExtremely low (Microseconds)High (Milliseconds, due to serialization + network)High (Milliseconds)
Independent Deployment CapabilityNo (Deploy entire block)Yes (Absolutely independent)No (Coordinated Deployments)
Conformity to Conway’s LawSuitable for small teams (<15 devs)Suitable for large organizations (>30 devs)Reflects organizational communication breakdown

CAP Theorem and Data Trade-off

According to the CAP theorem, in a distributed system, when a network partition (Network Partition - P) occurs, you can only choose one of the two: Instant data consistency (Consistency - C) or System availability (Availability - A).

  • In Monolithic, since all processes occur locally on a server and database communication is done through direct connection, the risk of network partition between modules is 0. The system can easily achieve both C and A.
  • In Microservices, the risk of network partition (P) is mandatory. If the Order Service cannot connect to the Payment Service due to a network issue, you must choose:
    • Reject transaction (Prioritize Consistency - C): Return an error to the customer, the system’s availability is reduced.
    • Accept transaction and process later (Prioritize Availability - A): The system records the order in a pending state, then uses the Saga Pattern to compensate for data when the network recovers.

Many teams switch to Microservices but still expect to have instant consistency (Strong Consistency) across the entire system. This is a direct contradiction to the CAP theorem, leading to patchy designs and ultimately falling into the Distributed Monolith trap.

5. Practical Advice: Modular Monolith as a Stepping Stone

A common mistake made by startups is trying to build a Microservices system from day one when domain boundaries are not yet clear. When the product continuously changes its business model (pivot), misdefining the service structure will lead to continuous restructuring of the distributed database - a very costly task.

The wisest choice for most projects is to start with a Modular Monolith.

graph TB
  subgraph "Modular Monolith (Logical Separation)"
    subgraph "Order Module"
      OrderLogic[Order Logic]
      OrderTables[(Order Tables)]
    end

    subgraph "Payment Module"
      PaymentLogic[Payment Logic]
      PaymentTables[(Payment Tables)]
    end
  end

  OrderLogic -->|"`In-Memory Call
- Interface Only`"| PaymentLogic
  OrderLogic -.->|"No Direct Query/JOIN"| PaymentTables
  PaymentLogic -.->|"No Direct Query/JOIN"| OrderTables

  style OrderTables fill:#1e1e2e,stroke:#a6e3a1,stroke-width:1px
  style PaymentTables fill:#1e1e2e,stroke:#a6e3a1,stroke-width:1px

Modular Monolith Design Principles

  1. Clear Logical Boundary Separation: Divide the codebase structure according to separate business domains. In Go, organize the directory structure clearly: /internal/order, /internal/payment, /internal/inventory. These modules communicate with each other only through clearly defined Interfaces, absolutely prohibiting direct calls to internal structs of other modules.
  2. Logical Schema Isolation: Even when using the same physical database, separate data tables by prefix (prefix) or separate schema (e.g., order_order_items, payment_transactions). Absolutely prohibit JOIN commands across tables between Order and Payment domains. If the Order Module needs payment information, it must call the Payment Module’s Interface to retrieve the data.
  3. Using Local Events: Instead of directly calling other modules, use in-memory event buses like Go’s channels to emit business events (e.g., OrderCreatedEvent). Other modules register to listen to this event to handle corresponding logic. This helps minimize tight coupling between modules.

When to Actually Transition to Microservices?

You should only bear the cost of Microservices when and only when you encounter real physical barriers:

  1. Human Process Bottlenecks: When the number of programmers increases, the time spent queuing for deployment and resolving source code conflicts begins to take longer than writing feature code.
  2. Different Hardware Resource Requirements: Image processing services need to use GPUs or a lot of RAM, while log recording services require high disk throughput.
  3. Severe Fault Isolation Requirements: Payment processing services need to achieve 99.99% stability and cannot be affected by memory leaks from product suggestion features.

Software architecture is born to solve technical problems that come with the business goals of the company. Don’t turn your system into a tool to polish your CV or experiment with unrealistic technology trends. Start simple by designing good logical boundaries, and the system will automatically show you the way when it really needs to be physically separated.

Share :

Related Posts

When to use cache and when not to

When to use cache and when not to

There is a production bug that I still remember vividly: user A cancels an order, but the app still displays “delivering” for the next 10 minutes. Support receives 30 tickets in one morning. The reason: cache TTL is 10 minutes, but no one invalidates it when the order status changes.

Read More
Agent = Model + Harness: Do not put an F1 engine into a brakeless bus

Agent = Model + Harness: Do not put an F1 engine into a brakeless bus

At the end of last year, I created a bot that ran in the background to automatically read menus from partner restaurants sent in PDF/Excel format, then parsed them into a Go struct to load into the Menu Service database.

Read More