~/blog/embracing-clean-architecture-the-key-to-scalable-and-maintainable-software

Clean Architecture: The Dependency Rule and Its Cost

Clean architecture as Robert C. Martin defined it: the dependency rule, the layers it produces, what adopting it costs, and the pitfalls on the way.

Krzysztof Słomka4 min readupdated

Most summaries of clean architecture lead with the four-ring diagram and the list of SOLID principles behind it. Both are accurate and neither is the part that decides whether adopting the pattern helps or hurts a given codebase. The part that decides is one rule, stated once, that every other detail in the pattern exists to enforce.

This covers the dependency rule itself, why it only has teeth at compile time, not in a diagram, the four layers and what each one is actually forbidden from knowing, the interface that makes an inner layer talk to an outer one without depending on it, and the two ways teams get this wrong in practice: over-applying it to code with nothing to protect, and the directory structure that looks compliant while the dependency rule is violated everywhere that matters.

Clean architecture concept illustration of layered, decoupled software design

The one rule everything else enforces#

Robert C. Martin's dependency rule: source code dependencies can only point inward. An inner layer must know nothing about an outer one, not its types, not its existence. Everything else in clean architecture, the four rings, the interface adapters, the "ports and adapters" vocabulary it shares with hexagonal architecture, is machinery built to make that one rule survivable in a real codebase without everything becoming an abstract interface.

Diagram of clean architecture's four concentric layers: entities, use cases, interface adapters, and frameworks

Entities hold the core business rules and know nothing about a database, a framework, or an HTTP request. Use cases orchestrate entities to perform a specific application action, and know nothing about how that action was triggered. Interface adapters translate between the use case layer and the outside world, a controller, a Doctrine repository implementation, a CLI command. Frameworks and drivers are the actual technology: Symfony, Doctrine, Redis, the HTTP transport. Each ring may depend on the ring inside it. None may depend on the ring outside it.

Why "inward" needs an interface to actually work#

The rule creates an obvious problem: a use case needs to save data, and saving data means talking to a database, which lives in the outermost ring. If the use case imported a DoctrineOrderRepository directly, the dependency would point outward, and the rule would already be broken on page one.

// BAD: the use case depends on a concrete outer-ring class. Changing the
// database, or testing the use case without one, now requires changing or
// faking Doctrine.
final class PlaceOrderUseCase
{
    public function __construct(private DoctrineOrderRepository $repository) {}
}
// GOOD: the interface is defined in the use case's own layer. The use case
// depends inward on an abstraction it owns. The Doctrine implementation,
// in the outer ring, depends inward on that same interface to satisfy it.
interface OrderRepository
{
    public function save(Order $order): void;
}

final class PlaceOrderUseCase
{
    public function __construct(private OrderRepository $repository) {}
}

// Lives in the outer ring. Depends inward on OrderRepository. The use case
// never imports this class or knows it exists.
final class DoctrineOrderRepository implements OrderRepository
{
    public function save(Order $order): void { /* ... */ }
}

The interface is owned by the inner layer, not the outer one. That ownership direction is the actual mechanism behind the dependency rule, and it's also the detail most summaries skip past on their way to the four-ring diagram. Get this backward, define the interface next to the Doctrine implementation and have the use case import it from there, and the rings look right on a diagram while the dependency graph violates the rule underneath.

Where teams apply this where it doesn't pay#

The clearest way to lose the benefit of clean architecture is applying its full four-layer ceremony to a CRUD endpoint whose entire logic is "validate, insert, return 201." A OrderEntity, CreateOrderUseCase, OrderController, and OrderRepository interface for a feature with zero business rules beyond field validation is four files and three layers protecting an invariant that doesn't exist. The dependency rule is worth its overhead when the inner rings hold logic actually worth insulating from the database and the framework. When they don't, the pattern adds indirection with nothing underneath it to justify the cost.

SignalClean architecture earns its costIt's overhead
Business rulesReal invariants, likely to outlive the current frameworkField validation only
Framework churn riskDatabase or framework migration is plausibleFramework is settled, migration unlikely
Team sizeMultiple teams need a stable inner-ring contractOne small team, low churn

A structure that looks compliant and isn't#

The second common failure: a directory layout named entities/, usecases/, adapters/, frameworks/ that matches the diagram exactly, while a use case class imports an Eloquent model directly for convenience "just this once." The dependency rule is a property of the import graph, not of folder names. A linter rule that fails the build on an inward-layer file importing anything from an outer-layer namespace catches this; code review relying on the directory structure looking right does not, because the violation is invisible until someone traces an import by hand.

Previous reading#

Code reviews are where the import-direction violations above actually get caught in practice, if anyone is looking for them.

$ related posts