Clean Architecture: Dependency Rule and Layers

Robert Martin's Clean Architecture emerged from decades of architectural patterns—Hexagonal Architecture, Onion Architecture, and others—all sharing a common goal: separation of concerns through...

Key Insights

  • The Dependency Rule is the single most important concept in Clean Architecture: all source code dependencies must point inward, toward higher-level policies and away from implementation details.
  • Clean Architecture isn’t about the number of layers or folder structures—it’s about protecting your business logic from external concerns like frameworks, databases, and UI changes.
  • Apply Clean Architecture selectively: it shines in complex domains with long lifespans but adds unnecessary ceremony to simple CRUD applications or short-lived prototypes.

Introduction to Clean Architecture

Robert Martin’s Clean Architecture emerged from decades of architectural patterns—Hexagonal Architecture, Onion Architecture, and others—all sharing a common goal: separation of concerns through dependency management. The architecture prioritizes four outcomes: independence from frameworks, testability without external dependencies, UI agnosticism, and database agnosticism.

Why does this matter? Because the most expensive part of software isn’t writing it—it’s maintaining it. When your business logic is entangled with your web framework or ORM, changing either becomes a surgical operation. Clean Architecture inverts this relationship. Your business rules become the stable core, and everything else becomes a replaceable plugin.

This isn’t academic theory. Teams that adopt these principles report faster feature development after the initial investment, easier testing, and smoother technology migrations. The cost is upfront complexity and more code. Whether that trade-off makes sense depends on your context.

The Dependency Rule Explained

The Dependency Rule is simple to state and difficult to enforce: source code dependencies must point inward only. Inner layers cannot know anything about outer layers—no imports, no type references, no knowledge of their existence.

This creates a stable core. When you change your database from PostgreSQL to MongoDB, your use cases don’t change. When you swap React for Vue, your business rules remain untouched. The inner circles contain policies; the outer circles contain mechanisms.

Here’s a use case that follows the rule:

# domain/repositories.py (inner layer - defines the contract)
from abc import ABC, abstractmethod
from domain.entities import Order

class OrderRepository(ABC):
    @abstractmethod
    def find_by_id(self, order_id: str) -> Order | None:
        pass
    
    @abstractmethod
    def save(self, order: Order) -> None:
        pass

# application/use_cases/cancel_order.py (inner layer - uses the contract)
from domain.repositories import OrderRepository
from domain.entities import Order
from domain.exceptions import OrderNotFoundError, OrderAlreadyShippedError

class CancelOrderUseCase:
    def __init__(self, order_repository: OrderRepository):
        self._order_repository = order_repository
    
    def execute(self, order_id: str) -> None:
        order = self._order_repository.find_by_id(order_id)
        
        if order is None:
            raise OrderNotFoundError(order_id)
        
        if order.status == "shipped":
            raise OrderAlreadyShippedError(order_id)
        
        order.cancel()
        self._order_repository.save(order)

Notice what’s missing: no SQLAlchemy imports, no HTTP concepts, no framework dependencies. The use case depends on an abstract OrderRepository, not a concrete implementation. This is the Dependency Rule in action.

The Four Layers

Clean Architecture defines four concentric layers, each with distinct responsibilities:

Entities sit at the center. These are your enterprise business rules—concepts that would exist even if you had no software system. They’re the most stable code you’ll write.

Use Cases surround entities. They contain application-specific business rules: the workflows that orchestrate entities to accomplish user goals. A use case knows what the application does, not how it’s done technically.

Interface Adapters convert data between the format use cases need and the format external systems provide. Controllers, presenters, and gateway implementations live here.

Frameworks & Drivers form the outermost layer. This is where your web framework, database driver, and external service clients reside. It’s the most volatile code—and intentionally so.

Here’s how this translates to a project structure:

src/
├── domain/                     # Entities layer
│   ├── entities/
│   │   ├── order.py
│   │   ├── customer.py
│   │   └── product.py
│   ├── value_objects/
│   │   ├── money.py
│   │   └── address.py
│   ├── repositories.py         # Abstract repository interfaces
│   └── exceptions.py
├── application/                # Use Cases layer
│   ├── use_cases/
│   │   ├── cancel_order.py
│   │   ├── place_order.py
│   │   └── get_order_status.py
│   ├── dto/
│   │   ├── order_request.py
│   │   └── order_response.py
│   └── services.py             # Application services if needed
├── adapters/                   # Interface Adapters layer
│   ├── controllers/
│   │   └── order_controller.py
│   ├── presenters/
│   │   └── order_presenter.py
│   └── gateways/
│       └── payment_gateway.py
└── infrastructure/             # Frameworks & Drivers layer
    ├── persistence/
    │   ├── sqlalchemy_order_repository.py
    │   └── models.py
    ├── web/
    │   ├── flask_app.py
    │   └── routes.py
    └── external/
        └── stripe_payment_client.py

The directory structure enforces the architecture. Imports should only flow downward in this tree—infrastructure imports from adapters, which imports from application, which imports from domain. Never the reverse.

Crossing Boundaries with Dependency Inversion

The Dependency Rule creates a problem: how does an inner layer use functionality from an outer layer? The answer is Dependency Inversion. Inner layers define interfaces; outer layers implement them.

# domain/repositories.py (defined in inner layer)
from abc import ABC, abstractmethod
from domain.entities import Order

class OrderRepository(ABC):
    @abstractmethod
    def find_by_id(self, order_id: str) -> Order | None:
        pass
    
    @abstractmethod
    def save(self, order: Order) -> None:
        pass

# infrastructure/persistence/sqlalchemy_order_repository.py (implemented in outer layer)
from sqlalchemy.orm import Session
from domain.repositories import OrderRepository
from domain.entities import Order
from infrastructure.persistence.models import OrderModel

class SqlAlchemyOrderRepository(OrderRepository):
    def __init__(self, session: Session):
        self._session = session
    
    def find_by_id(self, order_id: str) -> Order | None:
        model = self._session.query(OrderModel).filter_by(id=order_id).first()
        return self._to_entity(model) if model else None
    
    def save(self, order: Order) -> None:
        model = self._to_model(order)
        self._session.merge(model)
        self._session.commit()
    
    def _to_entity(self, model: OrderModel) -> Order:
        return Order(
            id=model.id,
            customer_id=model.customer_id,
            status=model.status,
            items=model.items,
            total=model.total
        )
    
    def _to_model(self, entity: Order) -> OrderModel:
        return OrderModel(
            id=entity.id,
            customer_id=entity.customer_id,
            status=entity.status,
            items=entity.items,
            total=entity.total
        )

Dependency injection wires these together at application startup:

# infrastructure/container.py
from dependency_injector import containers, providers
from infrastructure.persistence.sqlalchemy_order_repository import SqlAlchemyOrderRepository
from application.use_cases.cancel_order import CancelOrderUseCase

class Container(containers.DeclarativeContainer):
    db_session = providers.Singleton(create_session)
    
    order_repository = providers.Factory(
        SqlAlchemyOrderRepository,
        session=db_session
    )
    
    cancel_order_use_case = providers.Factory(
        CancelOrderUseCase,
        order_repository=order_repository
    )

The use case never knows it’s talking to SQLAlchemy. You could swap in a MongoDB implementation, an in-memory fake for testing, or a remote service—the use case code wouldn’t change.

Data Flow Across Layers

Requests flow inward; responses flow outward. At each boundary, data transforms to match the layer’s expectations.

# adapters/controllers/order_controller.py
from flask import request, jsonify
from application.use_cases.place_order import PlaceOrderUseCase
from application.dto.order_request import PlaceOrderRequest
from adapters.presenters.order_presenter import OrderPresenter

class OrderController:
    def __init__(self, place_order: PlaceOrderUseCase, presenter: OrderPresenter):
        self._place_order = place_order
        self._presenter = presenter
    
    def create_order(self):
        # Transform HTTP request → Application DTO
        dto = PlaceOrderRequest(
            customer_id=request.json["customer_id"],
            items=[
                {"product_id": item["product_id"], "quantity": item["quantity"]}
                for item in request.json["items"]
            ]
        )
        
        # Execute use case (returns domain entity or response DTO)
        result = self._place_order.execute(dto)
        
        # Transform result → HTTP response
        return jsonify(self._presenter.present(result)), 201

# application/use_cases/place_order.py
class PlaceOrderUseCase:
    def execute(self, request: PlaceOrderRequest) -> PlaceOrderResponse:
        # Transform DTO → Domain entities
        customer = self._customer_repository.find_by_id(request.customer_id)
        items = [
            OrderItem(product_id=item["product_id"], quantity=item["quantity"])
            for item in request.items
        ]
        
        # Domain logic
        order = Order.create(customer=customer, items=items)
        order.validate()
        
        # Persist
        self._order_repository.save(order)
        
        # Transform Entity → Response DTO
        return PlaceOrderResponse(
            order_id=order.id,
            status=order.status,
            total=str(order.total)
        )

Each layer speaks its own language. The controller speaks HTTP. The use case speaks application concepts. The domain speaks business concepts. Transformation happens at boundaries.

Common Pitfalls and Violations

Leaking framework dependencies inward is the most common violation:

# BAD: Domain entity depends on SQLAlchemy
from sqlalchemy import Column, String
from sqlalchemy.ext.declarative import declarative_base

Base = declarative_base()

class Order(Base):  # Domain entity inherits from ORM base
    __tablename__ = "orders"
    id = Column(String, primary_key=True)
    status = Column(String)
# GOOD: Domain entity is pure Python
from dataclasses import dataclass

@dataclass
class Order:
    id: str
    status: str
    
    def cancel(self) -> None:
        if self.status == "shipped":
            raise OrderAlreadyShippedError(self.id)
        self.status = "cancelled"

Anemic use cases delegate everything to entities or services, adding no value. If your use case is just repository.save(entity), you’re probably over-engineering.

Skipping layers seems efficient but creates coupling. Controllers calling repositories directly means your business rules live in controllers—good luck testing that.

Over-engineering simple applications wastes time. A CRUD API with no business logic doesn’t need four layers.

When to Apply Clean Architecture

Clean Architecture shines when:

  • Your domain has complex business rules that change independently of infrastructure
  • You expect the application to live for years
  • You need to support multiple interfaces (web, CLI, API)
  • You want comprehensive unit testing without infrastructure dependencies
  • Your team is large enough that clear boundaries prevent stepping on each other

It’s overkill when:

  • You’re building a prototype to validate an idea
  • The application is pure CRUD with no business logic
  • You’re working solo on a short-lived project
  • Time-to-market matters more than long-term maintainability

Start simple. Extract layers as complexity grows. You can always add architecture; removing premature abstraction is harder. The goal isn’t architectural purity—it’s sustainable software development.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.