backend / architecture design / 06_layered_architecture.md

Layered Architecture

7 interview angles 6 min read source

Layered Architecture

The most common application architecture. Splits the system into horizontal layers — presentation, application, domain, infrastructure — each depending only on the ones below. Old, simple, well-understood. Every “fancy” architecture (Clean, Hexagonal, Onion) is a variation on layered.

For Clean Architecture see 09_clean_architecture.md. For Hexagonal see 11_hexagonal_ports_adapters.md.

The classic four layers

┌──────────────────────────────────┐
│ Presentation                      │  HTTP handlers, views, CLI parsers
├──────────────────────────────────┤
│ Application (use cases)           │  orchestrates domain operations
├──────────────────────────────────┤
│ Domain (business logic)           │  entities, value objects, domain services
├──────────────────────────────────┤
│ Infrastructure                    │  DB, external APIs, file system, cache
└──────────────────────────────────┘

Rules:

  1. Each layer depends only on layers below it.
  2. Higher layers know nothing of presentation (the domain doesn’t import HTTP types).
  3. Cross-cutting concerns (logging, auth, transactions) live in dedicated infrastructure or wrap multiple layers.

A concrete example

# presentation/views.py
@app.post("/orders")
def create_order(req: Request, order_dto: OrderCreate, session=Depends(get_session)):
    use_case = CreateOrderUseCase(session)
    order = use_case.execute(order_dto.customer_id, order_dto.items)
    return OrderResponse.from_domain(order)

# application/use_cases.py
class CreateOrderUseCase:
    def __init__(self, session):
        self.order_repo = OrderRepository(session)
        self.inventory = InventoryService(session)

    def execute(self, customer_id: int, items: list[ItemInput]) -> Order:
        for item in items:
            self.inventory.reserve(item.product_id, item.quantity)
        order = Order.create(customer_id, items)
        self.order_repo.save(order)
        return order

# domain/order.py
@dataclass
class Order:
    id: int | None
    customer_id: int
    items: list[OrderItem]
    status: OrderStatus

    @classmethod
    def create(cls, customer_id, items):
        # domain rules (e.g., min order value, max items per order)
        if not items:
            raise InvalidOrderError("Order must have items")
        return cls(None, customer_id, [OrderItem(**i) for i in items], OrderStatus.PENDING)

    def total(self) -> Decimal:
        return sum(item.subtotal() for item in self.items)

# infrastructure/repositories.py
class OrderRepository:
    def __init__(self, session):
        self.session = session

    def save(self, order: Order):
        self.session.add(OrderORM.from_domain(order))
        self.session.commit()

Notice:

  • The view imports the use case, not the domain directly.
  • The use case orchestrates; domain rules live in Order.create().
  • The repository handles persistence; the domain entity has no DB code.

Strict vs relaxed layering

Strict: each layer can ONLY call the layer immediately below. Skipping a layer is forbidden.

Relaxed: a layer can call any layer below it (e.g., presentation can call domain directly for read-only queries).

Most teams use relaxed. Strict is academically pure but adds boilerplate for trivial cases (e.g., reading data for a UI display doesn’t need a use case wrapper).

The naming variations

Different sources use different layer names. They mean roughly the same thing:

Source Top → Bottom
Classic 3-tier Presentation → Business Logic → Data
DDD UI / Application → Domain → Infrastructure
Clean Architecture Frameworks → Interface Adapters → Application Business Rules → Enterprise Business Rules
Hexagonal Adapters (in) → Application → Domain ← Adapters (out)
Onion UI → Application Services → Domain Services → Domain Model

The shape is the same: outer layers depend inward; the domain is the center.

Dependency direction — the key rule

Presentation depends on Application depends on Domain.
Domain depends on NOTHING (or only on abstractions).
Infrastructure depends on Domain (implements its interfaces).

The classical layered architecture has Domain depending on Infrastructure (need DB to load entities). Modern variants (Clean, Hexagonal) invert this: Domain defines an interface (OrderRepository); Infrastructure implements it.

# domain/order_repository.py — interface defined in domain
class OrderRepository(Protocol):
    def find(self, id: int) -> Order | None: ...
    def save(self, order: Order) -> None: ...

# infrastructure/sqlalchemy_order_repository.py — implementation in infra
class SqlAlchemyOrderRepository:
    def __init__(self, session):
        self.session = session

    def find(self, id):
        row = self.session.query(OrderORM).get(id)
        return row.to_domain() if row else None

    def save(self, order):
        ...

The domain doesn’t know about SQLAlchemy. The dependency arrow points inward (infra → domain), even though data flow goes outward.

This is dependency inversion in action. See 04_dependency_inversion.md.

When strict layering helps

  • Multiple presentation entry points (web + CLI + GraphQL) sharing one domain.
  • Testing: each layer is testable in isolation. Mock the layer below.
  • Replacing implementations: swap DB, swap framework — only infrastructure changes.
  • Onboarding: clear “where does X live?” answers reduce ramp-up time.

When strict layering hurts

  • CRUD apps: forcing every read through a use case adds boilerplate.
  • Simple scripts / one-off projects: overhead exceeds the benefit.
  • Tight performance constraints: layer transitions add CPU; ORM-bypass queries may need to skip the domain.
  • Anemic domain problem: if your domain entities are just data bags with no behavior, “domain layer” is just a struct — layers are useless without real logic.

For an admin CRUD app: maybe just Django/Flask views talking to ORM models. For a complex domain (banking, logistics, healthcare): the layers pay for themselves.

The “Anemic Domain Model” warning

# Anemic — entities with no behavior, all logic in services
@dataclass
class Order:
    id: int
    items: list
    status: str

class OrderService:
    def add_item(self, order, item):
        order.items.append(item)
    def total(self, order):
        return sum(i.price for i in order.items)
    def can_ship(self, order):
        return order.status == "paid" and ...

The entity is a data bag; the service has all the logic. Symptom: the entity feels like a database row, not a business object. Fix: move behavior onto the entity.

# Rich domain — entity owns its behavior
@dataclass
class Order:
    id: int
    items: list[OrderItem]
    status: OrderStatus

    def add_item(self, product, quantity):
        if self.status != OrderStatus.PENDING:
            raise OrderLockedError
        self.items.append(OrderItem(product, quantity))

    def total(self) -> Decimal:
        return sum(item.subtotal() for item in self.items)

    def can_ship(self) -> bool:
        return self.status == OrderStatus.PAID and all(i.in_stock() for i in self.items)

Domain logic on the entity. Services orchestrate cross-entity operations or external coordination. See 12_domain_driven_design.md.

Cross-cutting concerns

Things that touch multiple layers: logging, authentication, transactions, observability, caching.

Approaches:

  • Decorators / middleware: wrap presentation handlers.
  • Aspect-oriented (rare in Python): weave behavior into many call sites.
  • Decorator pattern at the domain boundary: wrap repositories with caching, wrap use cases with transactions.
# Wrap a use case with a transaction
class TransactionalUseCase:
    def __init__(self, inner, session):
        self.inner = inner
        self.session = session

    def execute(self, *args, **kwargs):
        with self.session.begin():
            return self.inner.execute(*args, **kwargs)

Composition wins. Decorate the use case; the use case stays clean.

Common pitfalls

  • Skipping layers for “performance”: OK in moderation; if you do it everywhere, you don’t have layers anymore.
  • Leaking framework types into the domain: Order imports flask.Request or Pydantic BaseModel — domain coupling.
  • Anemic domain: data classes with all logic in services — defeats the point.
  • Layers without isolation: presentation imports DB models directly — domain layer is bypassed.
  • “Use case” classes that are 5 methods on the same data: at some point that’s just a service. Avoid one-class-per-action dogma.

Common interview confusions

  • “Layered architecture is the same as MVC.” — MVC is a presentation pattern (Model-View-Controller); layered is the whole application structure. MVC fits inside the presentation layer.
  • “Strict layering is always better.” — it pays off for complex domains; it’s overhead for CRUD.
  • “Domain layer means ORM models.” — anti-pattern. Domain entities should be free of ORM concerns. If they’re SQLAlchemy models, you have an anemic active-record approach, not a clean domain layer.

Interview angle

  • “What is layered architecture?” — splits an app into horizontal layers (typically presentation, application, domain, infrastructure). Each layer depends only on layers below. The domain (business logic) is the center; outer layers handle inputs/outputs.
  • “How do layers depend on each other?” — top-to-bottom: presentation → application → domain. Infrastructure implements interfaces defined in the domain (dependency inversion). Strict layered = adjacent layers only; relaxed = any layer below.
  • “What’s the anemic domain model?” — entities with no behavior (just data); all logic in services. Symptom of “I have layers but no domain.” Fix: move methods onto entities; services orchestrate, don’t do.
  • “Layered vs Clean Architecture?” — Clean is a specific style of layered: dependency rule (always inward), interface adapters layer for translating between framework and domain, distinct “entities” (enterprise rules) and “use cases” (application rules) layers.
  • “When would you NOT use layered?” — simple CRUD apps where the overhead exceeds the benefit; one-off scripts; very small services where one file is fine.
  • “How do you handle cross-cutting concerns like logging or transactions?” — middleware/decorators at boundaries (e.g., transactional decorator wrapping use cases; logging middleware wrapping HTTP handlers). Composition over modifying every layer.
  • “How is domain-layer testing easier than testing the whole app?” — domain entities have no I/O dependencies; pure functions / methods are fast and deterministic to test. Use cases use mocked repositories. No DB, no HTTP — milliseconds per test.