# N-Tier Architecture

## How a Chaotic Codebase Taught Me the Value of Separation

Early in my career, I built a small internal web tool for a team I worked with — a simple inventory tracker with a PHP backend, MySQL database, and HTML forms. Everything lived in single files. Database queries mixed with HTML markup mixed with business validation.

Adding a new field meant touching the query, the HTML, the validation logic, and the export function — all in the same file. A change in one place routinely broke something else. The system worked, but maintenance was painful beyond belief.

When I refactored it using N-Tier architecture, I divided the application into distinct tiers with clear responsibilities. The improvement in maintainability was immediate. That experience shaped how I thought about structure for years afterward.

## Table of Contents

* [What Is N-Tier Architecture?](#what-is-n-tier-architecture)
* [The Classic Three-Tier Model](#the-classic-three-tier-model)
* [The Four-Tier Model](#the-four-tier-model)
* [N-Tier vs Layered Architecture](#n-tier-vs-layered-architecture)
* [Practical Example: Python Web Application](#practical-example-python-web-application)
* [Deployment Topology](#deployment-topology)
* [When to Use N-Tier](#when-to-use-n-tier)
* [Limitations](#limitations)
* [Lessons Learned](#lessons-learned)

***

## What Is N-Tier Architecture?

N-Tier (also called multi-tier) architecture divides a system into **physically or logically separated tiers**, each with a distinct responsibility. Tiers communicate only with adjacent tiers — Presentation talks to Business Logic, Business Logic talks to Data Access. Presentation never touches the database directly.

The "N" means the number of tiers is not fixed — common configurations are 2-tier, 3-tier, and 4-tier.

{% @mermaid/diagram content="graph TB
subgraph Tier1\["Tier 1: Presentation"]
UI\[Web / Mobile / API Client]
end

```
subgraph Tier2["Tier 2: Application / Business Logic"]
    BL[Services, Validators, Domain Rules]
end

subgraph Tier3["Tier 3: Data Access"]
    DAL[Repositories, ORM, Query Builders]
end

subgraph Tier4["Tier 4: Data Store"]
    DB[(PostgreSQL / Redis / File Store)]
end

Tier1 -->|Request / Response| Tier2
Tier2 -->|Query / Command| Tier3
Tier3 -->|SQL / Driver| Tier4

style Tier1 fill:#d4edda
style Tier2 fill:#cce5ff
style Tier3 fill:#fff3cd
style Tier4 fill:#f8d7da" %}
```

***

## The Classic Three-Tier Model

This is the most common configuration and where I started:

### Tier 1 — Presentation

Responsible for rendering output and accepting input. In a web application this is the HTTP layer — controllers, request parsing, response serialisation. It should contain **no business logic**.

### Tier 2 — Business Logic (Application)

Contains the rules of the domain: validation, calculations, orchestration of operations. This tier does not know how data is stored — it only knows what data it needs.

### Tier 3 — Data (Persistence)

Responsible for reading and writing data. Abstracts the underlying database behind repositories or data access objects. The business logic tier never writes raw SQL — it calls repositories.

***

## The Four-Tier Model

In larger applications I often add a fourth tier — or split one of the existing tiers:

| Tier           | Responsibility                                |
| -------------- | --------------------------------------------- |
| Presentation   | HTTP request/response, routing, serialisation |
| Application    | Use cases, orchestration, input validation    |
| Domain         | Business rules, domain models, invariants     |
| Infrastructure | Database, external APIs, message queues       |

This maps closely to the Onion and Clean Architecture styles. See [Onion Architecture](https://blog.htunnthuthu.com/architecture-and-design/architecture-and-patterns/software-architecture-101/application-patterns/02-onion-architecture) for the code-level perspective.

***

## N-Tier vs Layered Architecture

These are often used interchangeably but they refer to different things:

| Aspect   | N-Tier                                                   | Layered                                       |
| -------- | -------------------------------------------------------- | --------------------------------------------- |
| Concern  | **Deployment and process boundaries**                    | **Code organisation within a process**        |
| Scope    | System topology                                          | Codebase structure                            |
| "Tier" = | Separate process / server (can be on different machines) | Logical module within the same process        |
| Example  | Web server + App server + DB server                      | Controller → Service → Repository in same app |

In practice, an N-Tier system usually *also* has layered code organisation within each tier.

***

## Practical Example: Python Web Application

Here is how I structure a three-tier Python web application. This follows the same pattern I used before breaking my POS system into microservices.

```python
# --- Tier 3: Data Access ---
# inventory/repository.py

from sqlalchemy.orm import Session
from .models import Product

class ProductRepository:
    def __init__(self, db: Session):
        self._db = db

    def find_by_tenant(self, tenant_id: str) -> list[Product]:
        return (
            self._db.query(Product)
            .filter(Product.tenant_id == tenant_id, Product.active == True)
            .all()
        )

    def find_by_id(self, tenant_id: str, product_id: int) -> Product | None:
        return (
            self._db.query(Product)
            .filter(Product.tenant_id == tenant_id, Product.id == product_id)
            .first()
        )

    def save(self, product: Product) -> Product:
        self._db.add(product)
        self._db.commit()
        self._db.refresh(product)
        return product
```

```python
# --- Tier 2: Business Logic ---
# inventory/service.py

from .repository import ProductRepository
from .models import Product
from shared.exceptions import NotFoundError, InsufficientStockError

class InventoryService:
    def __init__(self, repo: ProductRepository):
        self._repo = repo  # Dependency injected — no direct DB access here

    def reserve_stock(self, tenant_id: str, product_id: int, quantity: int) -> bool:
        product = self._repo.find_by_id(tenant_id, product_id)
        if not product:
            raise NotFoundError(f"Product {product_id} not found")

        if product.stock_quantity < quantity:
            raise InsufficientStockError(
                f"Requested {quantity}, available {product.stock_quantity}"
            )

        product.stock_quantity -= quantity
        self._repo.save(product)
        return True

    def get_low_stock_items(self, tenant_id: str, threshold: int = 10) -> list[Product]:
        products = self._repo.find_by_tenant(tenant_id)
        return [p for p in products if p.stock_quantity <= threshold]
```

```python
# --- Tier 1: Presentation ---
# inventory/router.py

from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from database import get_db
from .service import InventoryService
from .repository import ProductRepository
from .schemas import ProductResponse, ReserveRequest
from shared.exceptions import NotFoundError, InsufficientStockError

router = APIRouter()

def get_inventory_service(db: Session = Depends(get_db)) -> InventoryService:
    return InventoryService(ProductRepository(db))

@router.get("/products", response_model=list[ProductResponse])
def list_products(
    tenant_id: str,
    service: InventoryService = Depends(get_inventory_service)
):
    return service.get_low_stock_items(tenant_id)  # Example: returns low-stock items

@router.post("/reserve")
def reserve_stock(
    body: ReserveRequest,
    service: InventoryService = Depends(get_inventory_service)
):
    try:
        service.reserve_stock(body.tenant_id, body.product_id, body.quantity)
        return {"status": "reserved"}
    except NotFoundError as e:
        raise HTTPException(status_code=404, detail=str(e))
    except InsufficientStockError as e:
        raise HTTPException(status_code=409, detail=str(e))
```

The presentation tier knows HTTP semantics. The business logic tier knows domain rules. The data tier knows the database. None of them bleed into each other.

***

## Deployment Topology

One of the real advantages of N-Tier is physical deployment separation. In production, my tiers have lived on different machines:

{% @mermaid/diagram content="graph LR
subgraph "Load Balancer"
LB\[Nginx]
end

```
subgraph "Web Tier (2x)"
    APP1[FastAPI Instance 1]
    APP2[FastAPI Instance 2]
end

subgraph "Cache Tier"
    REDIS[(Redis)]
end

subgraph "Database Tier"
    PG[(PostgreSQL Primary)]
    PGREPLICA[(PostgreSQL Replica)]
end

LB --> APP1
LB --> APP2
APP1 --> REDIS
APP2 --> REDIS
APP1 --> PG
APP2 --> PGREPLICA
PG --> PGREPLICA" %}
```

I can scale the application tier independently of the database tier. I can replace the database without touching the application servers. This is physical tier separation at work.

***

## When to Use N-Tier

* Web applications where presentation, logic, and data have naturally distinct concerns
* Teams where different people own different tiers (frontend, backend, DBA)
* Systems where tiers need to be independently deployed or hosted
* Regulated environments where data access must be auditable and controlled

***

## Limitations

* **Strict tiering can create unnecessary indirection.** Not every read operation needs four layers of abstraction.
* **Cross-cutting concerns are awkward.** Logging, caching, and error handling do not belong cleanly in one tier.
* **Changes often ripple through all tiers.** Adding a new field to a model frequently requires changes at every layer.
* **Not suitable for complex domain logic.** When domain rules are rich and interrelated, N-Tier becomes rigid. Onion or hexagonal architectures handle this better.

***

## Lessons Learned

* **Follow the rule strictly at first, then bend it deliberately.** Understanding why Presentation must not access data directly makes you better at choosing when to break the rule.
* **The data access tier is your friend.** Being forced to write a repository method rather than inlining SQL everywhere saved me from countless query bugs.
* **Thin controllers, fat services.** If my router functions are more than 10 lines, business logic has leaked into the presentation tier.
* **Naming matters.** Calling your layers `controllers/`, `services/`, and `repositories/` signals the intent to every developer on the team.
