You've spent the morning chasing a single feature flag. The HTTP handler imports a service. The service imports a repository. The repository imports an ORM model. The ORM model imports a config helper. The config helper imports the framework's auth singleton. By lunchtime, you've read sixteen files and you still aren't sure which one is allowed to know about the user's session.

That's the moment people start googling phrases like "clean architecture", "hexagonal architecture", "ports and adapters". The names differ. The pitch is the same: there's a way to lay out an application so that, three years from now, you can still tell which code is the business and which code is the plumbing.

I like calling this lucid architecture, not because it's a new pattern, but because the goal isn't really purity, it's clarity. You want a stranger to walk into your codebase and figure out, in one read, which file is about what your business does and which file is about how it talks to Postgres. You want to swap an ORM without rewriting your domain. You want tests that don't need a running database. You want a use case to be a thing you can point at, not a verb scattered across four files.

Let's break down what those layers actually look like, what each one is allowed to depend on, and, just as importantly, when this whole approach is overkill.

Where The Tangle Comes From

Most codebases don't start tangled. They get tangled because the framework's "happy path" puts everything within easy reach of everything else.

You generate a controller. The controller imports the ORM model directly because that's what the docs do. The ORM model has a save() method that writes to the database, so business logic ends up living on it because that's where the data is. Validation ends up on the model too. Authorization ends up partly in middleware, partly in the controller, partly inside the model's lifecycle hooks. Email sending ends up wherever the user-creation code happens to live, because there's no obvious other place. Three months later, "what does it mean to register a user" is answered by reading a controller, two model methods, a middleware, two observers, a queue job, and a boot() method buried in a service provider.

None of that is wrong, exactly. It's all idiomatic in whichever framework you're in. The trouble is that the framework's vocabulary has quietly replaced your business's vocabulary. There's no file in the repo that says this is what registering a user means in our domain. There's just a controller method called store.

Lucid architecture is the move where you say: my business has things (a User, an Order, a Subscription), my business has actions (Register A User, Renew A Subscription, Cancel An Order), and the framework is one of many ways those actions can be triggered. The framework should be at the edge of the system, not at the center.

That's the whole idea. The rest is just naming the layers and agreeing on which way the arrows point.

Entities: The Things Your Business Cares About

An entity is a thing your business has rules about. A User. An Order. An Invoice. A BookingRequest. The test for whether something is an entity is roughly: if your company switched from Postgres to DynamoDB tomorrow, would this concept still exist? If yes, it's an entity. If no, it's infrastructure.

Entities own the rules that are true regardless of how the data is stored or how a user triggers a change. An Invoice knows that you can't apply a discount greater than its subtotal. A Subscription knows that it can be cancelled, and that cancelling it twice should be a no-op rather than an error. Those rules belong inside the entity itself, not on the outside.

Concretely, this means your entity files look almost embarrassingly plain. No ORM base class. No framework imports. Just a type with a few methods.

::: tabs

@tab TypeScript

TypeScript src/domain/subscription.ts
export type SubscriptionStatus = "active" | "cancelled" | "expired";

export class Subscription {
  constructor(
    public readonly id: string,
    public readonly userId: string,
    public status: SubscriptionStatus,
    public renewsAt: Date,
  ) {}

  cancel(now: Date): void {
    if (this.status !== "active") return;
    if (this.renewsAt < now) {
      this.status = "expired";
      return;
    }
    this.status = "cancelled";
  }

  isActiveOn(date: Date): boolean {
    return this.status === "active" && this.renewsAt >= date;
  }
}

@tab Python

Python domain/subscription.py
from dataclasses import dataclass
from datetime import datetime
from typing import Literal

Status = Literal["active", "cancelled", "expired"]

@dataclass
class Subscription:
    id: str
    user_id: str
    status: Status
    renews_at: datetime

    def cancel(self, now: datetime) -> None:
        if self.status != "active":
            return
        if self.renews_at < now:
            self.status = "expired"
            return
        self.status = "cancelled"

    def is_active_on(self, date: datetime) -> bool:
        return self.status == "active" and self.renews_at >= date

@tab Go

Go internal/domain/subscription.go
package domain

import "time"

type Status string

const (
    Active    Status = "active"
    Cancelled Status = "cancelled"
    Expired   Status = "expired"
)

type Subscription struct {
    ID       string
    UserID   string
    Status   Status
    RenewsAt time.Time
}

func (s *Subscription) Cancel(now time.Time) {
    if s.Status != Active {
        return
    }
    if s.RenewsAt.Before(now) {
        s.Status = Expired
        return
    }
    s.Status = Cancelled
}

func (s *Subscription) IsActiveOn(date time.Time) bool {
    return s.Status == Active && !s.RenewsAt.Before(date)
}

:::

Notice what's missing: no database. No HTTP. No queue. No framework. You can run this code in a unit test with three lines of setup, and the test will pass or fail in milliseconds. That's the first thing you buy with this layout: a domain you can actually test.

The second thing you buy is a place to put rules. Six months from now, when somebody asks "can you cancel a subscription that's already expired?", the answer lives in Subscription.cancel(), not scattered across two controllers, an ORM observer, and a feature flag.

Use Cases: The Verbs Of Your Application

If entities are the nouns, use cases are the verbs. Register a user. Cancel a subscription. Apply a coupon to an order. Deliver an invoice. Each one describes something your application does as a single business-meaningful operation.

A use case orchestrates entities and the outside world. It loads the entity it needs, asks the entity to perform its operation, and writes the result back. It's allowed to talk to repositories (which it knows about as interfaces), it's allowed to publish events, it's allowed to send emails, all through abstractions it defines. What it's not allowed to do is import the framework, the ORM, or anything HTTP-shaped.

Here's the same cancel flow as a use case in a couple of languages.

::: tabs

@tab TypeScript

TypeScript src/usecases/cancel-subscription.ts
import { Subscription } from "../domain/subscription";

export interface SubscriptionRepository {
  findById(id: string): Promise<Subscription | null>;
  save(sub: Subscription): Promise<void>;
}

export interface Clock {
  now(): Date;
}

export interface NotificationGateway {
  notifyCancelled(userId: string): Promise<void>;
}

export class CancelSubscription {
  constructor(
    private readonly subs: SubscriptionRepository,
    private readonly clock: Clock,
    private readonly notify: NotificationGateway,
  ) {}

  async execute(subscriptionId: string): Promise<void> {
    const sub = await this.subs.findById(subscriptionId);
    if (!sub) throw new Error("subscription not found");

    const before = sub.status;
    sub.cancel(this.clock.now());
    if (sub.status === before) return;

    await this.subs.save(sub);
    await this.notify.notifyCancelled(sub.userId);
  }
}

@tab Python

Python usecases/cancel_subscription.py
from typing import Protocol
from datetime import datetime
from domain.subscription import Subscription

class SubscriptionRepository(Protocol):
    def find_by_id(self, sub_id: str) -> Subscription | None: ...
    def save(self, sub: Subscription) -> None: ...

class Clock(Protocol):
    def now(self) -> datetime: ...

class NotificationGateway(Protocol):
    def notify_cancelled(self, user_id: str) -> None: ...

class CancelSubscription:
    def __init__(self, subs: SubscriptionRepository, clock: Clock, notify: NotificationGateway):
        self.subs = subs
        self.clock = clock
        self.notify = notify

    def execute(self, subscription_id: str) -> None:
        sub = self.subs.find_by_id(subscription_id)
        if sub is None:
            raise LookupError("subscription not found")

        before = sub.status
        sub.cancel(self.clock.now())
        if sub.status == before:
            return

        self.subs.save(sub)
        self.notify.notify_cancelled(sub.user_id)

@tab Go

Go internal/usecases/cancel_subscription.go
package usecases

import (
    "context"
    "errors"
    "time"

    "yourapp/internal/domain"
)

type SubscriptionRepository interface {
    FindByID(ctx context.Context, id string) (*domain.Subscription, error)
    Save(ctx context.Context, s *domain.Subscription) error
}

type Clock interface {
    Now() time.Time
}

type NotificationGateway interface {
    NotifyCancelled(ctx context.Context, userID string) error
}

type CancelSubscription struct {
    Subs   SubscriptionRepository
    Clock  Clock
    Notify NotificationGateway
}

func (uc *CancelSubscription) Execute(ctx context.Context, id string) error {
    sub, err := uc.Subs.FindByID(ctx, id)
    if err != nil {
        return err
    }
    if sub == nil {
        return errors.New("subscription not found")
    }

    before := sub.Status
    sub.Cancel(uc.Clock.Now())
    if sub.Status == before {
        return nil
    }

    if err := uc.Subs.Save(ctx, sub); err != nil {
        return err
    }
    return uc.Notify.NotifyCancelled(ctx, sub.UserID)
}

:::

A few things worth pointing out.

The use case defines the interfaces it needs (SubscriptionRepository, Clock, NotificationGateway) and accepts them as constructor arguments. It doesn't go look them up. It doesn't new them. It doesn't reach into a service container. That's the whole "depend on abstractions, not concretions" idea, made concrete: the use case says I need something I can call findById on, and somebody else's job is to plug in the real implementation.

The use case is also a single named thing. CancelSubscription is a class with one main method, not a verb hidden in a controller called update. When a product manager asks "what happens when a subscription is cancelled?", you can show them this file and answer the question in a minute. That's the lucidity part.

And use cases are easy to test, because their dependencies are interfaces. A unit test wires up an in-memory repository, a fake clock, a stub notifier, and runs the whole flow without booting the framework. The test reads almost like prose: given a subscription that's active and renews next week, when I cancel it, then its status is "cancelled" and the user is notified.

Four concentric rings showing the lucid architecture layers: Entities at center, Use Cases, Adapters, and Frameworks &amp; Drivers on the outside, with a curved arrow indicating dependencies point inward.

Services And Adapters: The Edges

Around the use cases, you have a layer that knows about the outside world. This is where the HTTP controller lives. This is where the Postgres repository lives. This is where the Stripe client lives. Some people call this "infrastructure", some call it "adapters", some call it "interface adapters", some call it "services". The name matters less than the role: this layer translates between the world's vocabulary and the application's vocabulary.

An HTTP controller's job is small. It receives a request, pulls the bits it needs out of the request body, calls the appropriate use case, and turns the result (or the error) into a response. It does not contain business logic. If a controller is more than ~20 lines, something has leaked into it.

TypeScript src/adapters/http/subscription-controller.ts
import type { Request, Response } from "express";
import { CancelSubscription } from "../../usecases/cancel-subscription";

export class SubscriptionController {
  constructor(private readonly cancel: CancelSubscription) {}

  async cancelHandler(req: Request, res: Response): Promise<void> {
    try {
      await this.cancel.execute(req.params.id);
      res.status(204).end();
    } catch (err) {
      if ((err as Error).message === "subscription not found") {
        res.status(404).json({ error: "not found" });
        return;
      }
      throw err;
    }
  }
}

Notice the controller doesn't know what an active subscription is. It doesn't know whether cancellation triggers an email. It doesn't know what a Subscription even looks like. It just routes the request. Tomorrow, if somebody adds a CLI command that also needs to cancel subscriptions, that command imports the same CancelSubscription use case directly, no controller in sight, no business logic duplicated.

The Postgres repository lives on the same edge:

TypeScript src/adapters/db/subscription-repo-postgres.ts
import type { Pool } from "pg";
import { Subscription } from "../../domain/subscription";
import type { SubscriptionRepository } from "../../usecases/cancel-subscription";

export class PostgresSubscriptionRepository implements SubscriptionRepository {
  constructor(private readonly pool: Pool) {}

  async findById(id: string): Promise<Subscription | null> {
    const { rows } = await this.pool.query(
      "select id, user_id, status, renews_at from subscriptions where id = $1",
      [id],
    );
    if (rows.length === 0) return null;
    const r = rows[0];
    return new Subscription(r.id, r.user_id, r.status, new Date(r.renews_at));
  }

  async save(sub: Subscription): Promise<void> {
    await this.pool.query(
      `update subscriptions
         set status = $2, renews_at = $3
       where id = $1`,
      [sub.id, sub.status, sub.renews_at],
    );
  }
}

The repository implements the interface that the use case defined, not the other way around. The arrow points inward: the adapter depends on the use case's contract, the use case never depends on the adapter. If you decide to switch from Postgres to MongoDB, you write a MongoSubscriptionRepository that implements the same interface, swap it in your composition root, and the use case never notices.

The same shape applies to outbound integrations. A StripeGateway that implements your PaymentProcessor interface. A SendgridSender that implements your EmailGateway interface. A RedisRateLimiter that implements your RateLimiter interface. The pattern is monotonous on purpose: once you've seen one adapter, you've seen them all.

Framework Independence Is About Direction, Not Religion

This is the part of clean architecture that gets the most pushback, and most of the pushback comes from misreading the goal. "You're telling me to build a Laravel app without using Eloquent? Or a Spring app without Spring Boot? That's lunacy."

Right. Don't do that.

Framework independence isn't about not using your framework. It's about which way the dependencies point. Use Laravel, use Spring, use Django, use Express. Use them in the adapter layer where they belong. Let your HTTP layer be deeply Laravel-y. Let your queue jobs use the framework's worker. Let your routing be whatever your framework wants it to be. That's all fine.

The only rule is that the inner layers don't import the framework. Your Subscription entity doesn't extend Illuminate\Database\Eloquent\Model. Your CancelSubscription use case doesn't ask for Laravel's Container or Spring's ApplicationContext. Your domain doesn't know what an HTTP request looks like.

What this buys you in practice:

When the framework changes its mind (and they all do), your blast radius is the adapter layer, not the whole app. Laravel 12 deprecates a query-builder method? You change the repository. Your domain doesn't care. Spring decides annotations should look different? You change the controllers. Your use cases don't care. Express gets replaced by Hono in your shop? Your controllers get rewritten. Your business logic doesn't move.

The other thing it buys you is being able to read the inner layers as a description of the business. When you sit a junior engineer down and say "this is what our application does", you point at usecases/. They read ten files and they understand the company. They don't have to understand the framework first.

That's why the framework lives at the edge. Not because frameworks are bad; they're great, that's why we use them. But because they're a delivery mechanism, and your business shouldn't be entangled with one delivery mechanism.

The Dependency Rule, And The One Thing You Have To Get Right

If you remember nothing else from this article, remember this: dependencies only point inward.

  • Frameworks and drivers (Express, Django, Postgres, RabbitMQ, Stripe SDK) can depend on adapters.
  • Adapters (controllers, repositories, gateways) can depend on use cases.
  • Use cases can depend on entities.
  • Entities depend on nothing in your codebase. Only the standard library and pure types.

That's it. That's the whole rule. Every other "clean architecture" guideline is a consequence of this one.

You'll know you've broken the rule when you look at an import line and feel a small lurch in your stomach. "Why does my domain entity import the database connection?" Because somebody, in a hurry, added a getById method to Subscription itself instead of putting it in a repository. "Why does my use case import Express?" Because somebody, in a hurry, made the use case take an Request object directly instead of plain parameters. The fix is always the same: peel the dependency back out one ring, redefine the interface in the inner ring, and let the outer ring implement it.

This is also the reason most "clean architecture" tutorials show the dependency rule before they show any code. The structure makes no sense without the arrow direction. Once you internalize the arrow, you stop needing to memorize folder layouts. Any layout that respects the arrow is fine, and any layout that violates it is a problem regardless of how many domain/, application/, infrastructure/ folders it has.

The Cost: When This Is Overkill

I'd be lying if I said this layout is free. It isn't.

The first cost is code volume. The same feature that takes one Rails controller method takes a use case, a repository interface, a repository implementation, and a controller. That's four files instead of one. For a CRUD endpoint that wraps a single SQL query, the extra files don't earn their keep. You've added ceremony with no payoff.

The second cost is navigation. When everything is hooked together through interfaces, "go to definition" sometimes lands you on the interface, not the implementation. Modern IDEs handle it, but you do spend a few extra seconds following the wire. Multiply that by every developer on the team, every day, and it's not zero.

The third cost is the pattern's seductiveness. Once you know how to draw clean rings, every problem starts looking like it wants rings drawn around it. A two-page admin script does not need an entity, a use case, a repository interface, and an adapter. A throwaway prototype does not need a domain layer. A small CLI utility that runs once a month does not need framework independence. It is its own framework.

So when does it earn its keep?

The pattern starts paying off as soon as the application has more than one way to be triggered: an HTTP API and a queue worker, or a web app and a CLI, or a public API and an admin panel. Once two delivery mechanisms need to perform the same business operation, you either duplicate the operation or you extract it. The use-case shape is exactly that extraction.

It also pays off when your business rules are non-trivial enough that you want to test them without a database. Subscription pricing logic, order validation, scheduling rules, billing edge cases: anything where you can imagine writing fifty test cases against pure functions. Putting that logic in entities and use cases lets you do exactly that.

And it pays off in long-lived codebases: anything you expect to live more than two or three years. The cost is paid up front; the savings come later, every time you swap an ORM, change frameworks, restructure the team, or onboard a new engineer.

For a six-week prototype that you might throw away? Skip the rings. Use whatever your framework gives you. Move fast. You can always extract a domain later if the prototype survives. Many won't.

Lucidity Over Purity

The version of clean architecture you'll see in some books treats the rings as a religion: every entity must be a plain object, every use case must accept an input DTO and return an output DTO, every adapter must hide behind an interface even if there's only ever going to be one. That's the version that makes people roll their eyes. Reasonably so.

The version worth keeping is the lucid one. The goal is that any reader of your codebase can answer three questions in a couple of minutes: what does this application do? (open usecases/), what does it do those things to? (open domain/), how does the outside world reach it? (open adapters/). When the answers to those three questions are clear, the architecture is doing its job, and most of the smaller decisions (folder names, DTO shapes, exactly how many interfaces) sort themselves out.

You don't need every project to be lucid architecture. You need the long-lived ones to be. And once you've worked in a codebase that respects the dependency rule, the next time you open one that doesn't, you'll feel exactly why the rule exists. About sixteen files in.