# Monolithic Architecture

## The Project That Proved Monolith Is Not a Dirty Word

When I started building my multi-tenant POS system, I made the mistake of going straight to microservices. Six services, Docker Compose, service discovery, inter-service JWT propagation — all of it before I had written a single business rule.

Three weeks in, I was spending more time wiring infrastructure than building the product. I scrapped it and started over as a monolith. Within two days I had the core order flow working end to end.

The monolith shipped. The microservices rewrite came later — only after the domain was well understood and the team had grown.

**A monolith is not a failure. It is often the right tool.**

## Table of Contents

* [What Is a Monolith?](#what-is-a-monolith)
* [Anatomy of a Monolithic Application](#anatomy-of-a-monolithic-application)
* [Types of Monoliths](#types-of-monoliths)
* [When to Choose Monolithic Architecture](#when-to-choose-monolithic-architecture)
* [When to Avoid It](#when-to-avoid-it)
* [Practical Example](#practical-example)
* [Migrating Away From a Monolith](#migrating-away-from-a-monolith)
* [Lessons Learned](#lessons-learned)

***

## What Is a Monolith?

A monolithic application is deployed as a **single, self-contained process**. All functionality — authentication, order processing, inventory, reporting — runs in the same process, shares the same memory space, and is deployed as a single unit.

{% @mermaid/diagram content="graph TB
Client\[Client / Browser]

```
subgraph Monolith["Monolith Process (single deployable)"]
    AUTH[Auth Module]
    ORDERS[Orders Module]
    INVENTORY[Inventory Module]
    PAYMENTS[Payments Module]
    REPORTING[Reporting Module]
end

DB[(PostgreSQL)]

Client --> Monolith
Monolith --> DB

style Monolith fill:#e8f4f8" %}
```

All modules communicate through **in-process function calls** — no HTTP, no message queues, no serialisation overhead. This is one of its biggest strengths.

***

## Anatomy of a Monolithic Application

Here is the structure I used for the early version of my POS system before splitting it:

```
pos-system/
├── main.py                    # Application entry point
├── config.py                  # Shared configuration
├── database.py                # Single database connection
│
├── auth/
│   ├── models.py
│   ├── services.py
│   └── router.py
│
├── orders/
│   ├── models.py
│   ├── services.py
│   └── router.py
│
├── inventory/
│   ├── models.py
│   ├── services.py
│   └── router.py
│
├── payments/
│   ├── models.py
│   ├── services.py
│   └── router.py
│
└── shared/
    ├── exceptions.py
    └── utils.py
```

Each module is logically separated, but they are all part of the same codebase and process.

***

## Types of Monoliths

### 1. Single-Process Monolith

Everything runs as one OS process. This is the typical monolith most people think of.

### 2. Modular Monolith

Modules have explicit, enforced boundaries inside a single deployment. Dependencies between modules go through defined interfaces, not direct imports. This is the sweet spot: the simplicity of a monolith with enforced internal structure.

> I cover modular monolith in detail in [Article 02: Modular Monolith Architecture](https://blog.htunnthuthu.com/architecture-and-design/architecture-and-patterns/software-architecture-101/02-modular-monolith-architecture) — including the $50,000 lesson that convinced me it was the right starting point.

### 3. Distributed Monolith (Anti-Pattern)

Multiple services that are still tightly coupled — every deployment requires coordinating all of them, and they share a database. This is the worst of both worlds. I have built one accidentally and it is painful.

***

## When to Choose Monolithic Architecture

I reach for a monolith when:

* **The domain is not yet understood.** If I do not know the natural boundaries of the system, splitting it prematurely creates the wrong seams.
* **The team is small (1–3 people).** Microservices have a coordination overhead that kills small teams.
* **Speed of iteration matters.** In-process function calls, shared transactions, and a single debugger session make development faster.
* **The load is predictable and modest.** No need to scale individual components independently.
* **I am building a proof of concept.** Starting monolithic, then extracting services once the domain stabilises, is a proven strategy.

***

## When to Avoid It

A monolith starts to hurt when:

* **Different parts need to scale independently.** If the reporting module saturates CPU while orders need low latency, you need separation.
* **Multiple teams own different areas.** Shared codebase → merge conflicts → coordination overhead.
* **Deployment becomes a bottleneck.** A bug fix in the payments module requires redeploying the whole application.
* **Technology diversity is required.** You cannot run a Python module and a Go module in the same process.

***

## Practical Example

Here is a minimal FastAPI monolith — the kind I would build on day one of a new project:

```python
# main.py
from fastapi import FastAPI
from orders.router import router as orders_router
from inventory.router import router as inventory_router
from auth.router import router as auth_router
from database import engine, Base

Base.metadata.create_all(bind=engine)

app = FastAPI(title="POS System")

app.include_router(auth_router, prefix="/api/v1/auth", tags=["Auth"])
app.include_router(orders_router, prefix="/api/v1/orders", tags=["Orders"])
app.include_router(inventory_router, prefix="/api/v1/inventory", tags=["Inventory"])
```

```python
# orders/services.py — direct in-process call to inventory
from inventory.services import InventoryService
from payments.services import PaymentService

class OrderService:
    def __init__(self, db):
        self.db = db
        self.inventory = InventoryService(db)    # In-process — no HTTP call
        self.payments = PaymentService(db)        # In-process — no HTTP call

    def create_order(self, tenant_id: str, items: list[dict]) -> dict:
        # Check inventory — same transaction, no network hop
        for item in items:
            self.inventory.reserve(tenant_id, item["product_id"], item["qty"])

        total = sum(i["price"] * i["qty"] for i in items)
        payment = self.payments.charge(tenant_id, total)

        order = Order(tenant_id=tenant_id, total=total, payment_ref=payment.ref)
        self.db.add(order)
        self.db.commit()
        return order
```

The key insight: `OrderService` calls `InventoryService` and `PaymentService` via **direct Python calls**. No serialisation, no network latency, no partial failure handling needed. The entire operation lives inside a single database transaction.

***

## Migrating Away From a Monolith

When the monolith starts to show strain, I use the **Strangler Fig pattern**:

{% @mermaid/diagram content="graph LR
Client --> Router{API Gateway / Router}
Router -->|Legacy routes| Monolith
Router -->|Extracted routes| NewService\[New Microservice]
Monolith --> LegacyDB\[(Legacy DB)]
NewService --> NewDB\[(New DB)]" %}

1. Identify the module causing the most pain (usually the one with the most independent scaling needs or team ownership issues)
2. Extract it behind an interface first — do not change callers yet
3. Deploy the extracted service alongside the monolith
4. Route traffic to the new service gradually
5. Retire the old module from the monolith once proven stable

This is exactly how I split the chatbot service out of my POS monolith — payments stayed in the monolith for another year because there was no reason to extract it yet.

***

## Lessons Learned

* **Starting monolithic is not technical debt — it is pragmatism.** Technical debt is a bad monolith with no module boundaries.
* **Name your modules like services from day one.** Even in a monolith, `orders/`, `inventory/`, and `payments/` directories with explicit interfaces make extraction easier later.
* **A single database is a feature, not a problem.** ACID transactions across modules are one of the monolith's biggest advantages.
* **Do not let "monolith" become an excuse for a big ball of mud.** Enforce module boundaries even when the compiler does not force you to.
* **Extract when you feel the pain, not when you read that you should.**
