Hasina Razafintsalama

Hasina RAZAFINTSALAMA

← Back to Blog
Architecture

Clean Architecture: Building Software That Outlives Its Framework

Clean Architecture is not about folders or diagrams, it is about one rule: dependencies point inward. Here is what that actually buys you in a real codebase.

2026-07-10·12 min

Robert C. Martin's Clean Architecture popularized a diagram of concentric circles, but the diagram is not the point. The point is one rule that, applied consistently, keeps your business logic usable long after the framework you built it in has been replaced. It belongs to the same family as hexagonal architecture (ports and adapters) and onion architecture: same core idea, different vocabulary.

The dependency rule

Source code dependencies can only point inward. The outer layers, the web framework, the database, the UI, depend on the inner layers: use cases and entities. The reverse is never allowed. A use case must not import an Eloquent model, a Symfony controller or an HTTP client directly. When an inner layer genuinely needs something from the outside, for example saving an order, it declares an interface that it owns, and the outer layer implements it. That is the Dependency Inversion Principle, and it is what makes the rule practical rather than aspirational.

The layers

LayerResponsibilityExamplesKnows about
EntitiesEnterprise-wide business rulesOrder, Money, InvoiceNothing outside plain language
Use casesApplication-specific workflowsPlaceOrder, CancelSubscriptionEntities and interfaces it defines
Interface adaptersTranslate between use cases and the outsideControllers, presenters, repository implementationsUse cases and entities
Frameworks and driversTechnical detail, the volatile layerLaravel, PostgreSQL, HTTP, the browserEverything inward

The names vary. Hexagonal architecture calls the boundaries ports and adapters, onion architecture draws rings instead of a hexagon, but the constraint is identical: dependencies flow toward the domain, never away from it.

A text diagram

text
+---------------------------------------------------+
|  Frameworks & Drivers  (Laravel, Postgres, HTTP)  |
|  +---------------------------------------------+  |
|  |  Interface Adapters                         |  |
|  |  (controllers, presenters, repo impls)      |  |
|  |  +-------------------------------------+     |  |
|  |  |  Use Cases                          |     |  |
|  |  |  (PlaceOrder, CancelSubscription)   |     |  |
|  |  |  +---------------------------+      |     |  |
|  |  |  |  Entities                 |      |     |  |
|  |  |  |  (Order, Money, Invoice)  |      |     |  |
|  |  |  +---------------------------+      |     |  |
|  |  +-------------------------------------+     |  |
|  +---------------------------------------------+  |
+---------------------------------------------------+

Dependencies point inward, and only inward.

Read it from the inside out. Entities at the center hold rules that would be true in any application of this business. Use cases wrap them with the workflows this application performs. Interface adapters translate those workflows to and from the outside world. Frameworks and drivers sit on the edge, where change is constant and cost of replacement should be low. An arrow may cross a boundary inward but never outward: an outer class can call an inner one, an inner class can only call an interface it defined itself.

A concrete example: decoupling a use case from Laravel

A use case should depend on an interface, not on Eloquent. The concrete implementation lives in the outer layer and is bound at runtime, in a Laravel service provider or the Symfony container.

php
// Use case: pure PHP, no Eloquent, no framework imports
final class PlaceOrder
{
    public function __construct(
        private OrderRepository $orders,   // interface, not Eloquent
        private PaymentGateway $payments,  // interface
    ) {}

    public function execute(PlaceOrderRequest $request): Order
    {
        $order = Order::create($request->items, $request->customerId);
        $this->payments->charge($order->total(), $request->paymentToken);
        $this->orders->save($order);

        return $order;
    }
}
php
// Domain layer: the contract, owned by the inside
interface OrderRepository
{
    public function save(Order $order): void;
    public function find(string $id): ?Order;
}
php
// Infrastructure layer: implements the contract with Eloquent
final class EloquentOrderRepository implements OrderRepository
{
    public function save(Order $order): void
    {
        OrderModel::updateOrCreate(['id' => $order->id()], $order->toArray());
    }

    public function find(string $id): ?Order
    {
        $model = OrderModel::find($id);
        return $model ? Order::fromModel($model) : null;
    }
}

// Bound once in a service provider, never referenced by the use case
$this->app->bind(OrderRepository::class, EloquentOrderRepository::class);

The use case never names Eloquent. It works against OrderRepository, and the container decides which implementation to inject. Swapping Postgres for another store, or Eloquent for Doctrine, is a change in one class in the outer layer.

Testing without a database

Because the use case depends on an interface, a test can pass it a fake that keeps orders in an array. No migrations, no HTTP server, no test database to reset between runs.

php
final class InMemoryOrderRepository implements OrderRepository
{
    private array $orders = [];

    public function save(Order $order): void
    {
        $this->orders[$order->id()] = $order;
    }

    public function find(string $id): ?Order
    {
        return $this->orders[$id] ?? null;
    }
}

// The test: no framework, milliseconds to run
$useCase = new PlaceOrder(new InMemoryOrderRepository(), new FakePaymentGateway());
$order = $useCase->execute($request);

assert($order->total()->equals(Money::eur(4200)));

What you gain

  • Testability: business rules are tested in memory, in milliseconds, with no infrastructure.
  • Swappable infrastructure: change the ORM, the database or the delivery mechanism without touching a business rule.
  • Localized framework upgrades: a major framework version bump hits the outer layer, not the domain.
  • Readable business logic: the rules live in one place, in language a domain expert would recognize.
  • Parallel work: one developer on the domain, another on the infrastructure, against a shared interface.

What it costs

  • More files and more indirection: an interface and an implementation where a direct call would have done.
  • Mapping work: domain objects and ORM models are separate, so you translate between them.
  • Onboarding cost: a developer new to the codebase has more concepts to learn before being productive.
  • Over-engineering risk: on a simple CRUD screen, the layers cost more than they return.

Clean Architecture vs a classic layered architecture

A classic layered app goes controller to service to repository to database, with dependencies pointing down. It looks similar, but the database still shapes the code above it: the entities are usually ORM models, so a schema change ripples upward. Clean Architecture inverts the database dependency. The domain defines the repository interface, and the database implementation depends on the domain, not the other way around. The difference is the direction of the arrows, not the number of layers.

In practice most teams land somewhere in between. A pragmatic version keeps entities and use cases as plain classes, puts repository and gateway interfaces in the domain, and lets controllers stay thin Laravel or Symfony classes that call a use case and format the result. You get the testability and the isolation without a rigid four-directory layout or a mapper for every model.

When to use it

It pays off when business logic is complex, expected to live for years, or needs to be tested and evolved independently of how it is delivered. It is overkill for a CRUD admin panel, a prototype, or a project with a short shelf life. Partial adoption is legitimate: you can keep the single rule without the full folder structure. The decision is rarely all or nothing, and it can change as a module grows in importance.

If you take one thing from Clean Architecture, take this: do not let framework classes leak into your business logic. That one discipline prevents most of the pain the full approach is designed to solve, at almost no cost.

FAQ

What is Clean Architecture in simple terms?

It is a way of organizing code so that the business rules do not depend on the framework, the database or the UI. Those are treated as details, plugged in from the outside. One rule keeps it honest: source code dependencies only point inward, toward the domain.

Is Clean Architecture the same as hexagonal architecture?

They are closely related. Hexagonal architecture (ports and adapters), onion architecture and Clean Architecture all share dependency inversion toward the domain. Clean Architecture adds a specific layer vocabulary, entities, use cases, interface adapters, frameworks, and the concentric diagram.

Does Clean Architecture work with Laravel or Symfony?

Yes. The framework becomes an outer layer. In Laravel you bind interfaces to implementations in a service provider; in Symfony you wire them in the service container. Eloquent or Doctrine models become an infrastructure detail hidden behind a repository interface.

Is Clean Architecture overkill for a small project?

Often, yes. For a CRUD app or a short-lived project, the extra files and indirection cost more than they return. Keep the single rule, no framework classes in the business logic, and skip the rest.

Even without adopting the full structure, the discipline is worth it. Keep your business rules in plain PHP, put an interface between them and anything technical, and your code will survive the next framework migration with far less rework.

Need help with this topic? Full Stack Development

Discover this service