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:
- Each layer depends only on layers below it.
- Higher layers know nothing of presentation (the domain doesn’t import HTTP types).
- 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:
Orderimportsflask.RequestorPydantic 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.