~/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.
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.

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.

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.
| Signal | Clean architecture earns its cost | It's overhead |
|---|---|---|
| Business rules | Real invariants, likely to outlive the current framework | Field validation only |
| Framework churn risk | Database or framework migration is plausible | Framework is settled, migration unlikely |
| Team size | Multiple teams need a stable inner-ring contract | One 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
· 19 min
What This Architecture Cost, Measured Rather Than ArguedNEW
What hexagonal architecture costs in Symfony 8: ports, layers and buses counted from the repository, and the thresholds below which it is not worth it. Part 6.
▸ DDD, CQRS and Hexagonal Architecture in Symfony 8 · 6#ddd#hexagonal-architecture#symfony#php#software-architecture#cqrs#refactoring19 min
· 18 min
The Application Layer, the Transaction, and a Working API
Application layer in Symfony 8: use cases on a Messenger command bus, the transaction boundary in config, optimistic locking and RFC 9457 errors. Part 2.
▸ DDD, CQRS and Hexagonal Architecture in Symfony 8 · 2#ddd#hexagonal-architecture#symfony#php#cqrs#messenger#software-architecture18 min
· 15 min
The Domain Model, and the Only Rule That Matters
DDD domain model in PHP 8.5 and Symfony 8: keep the container out of the domain, and choose Doctrine ORM or DBAL with the costs on the table. Series part 1.
▸ DDD, CQRS and Hexagonal Architecture in Symfony 8 · 1#ddd#hexagonal-architecture#symfony#php#doctrine#software-architecture15 min