Clean and Onion Architecture: Keeping Policy at the Center

Compare clean and onion architecture, learn how concentric boundaries keep policy independent, and apply dependency rules with practical code and trade-offs.

published: reading time: 10 min read author: GeekWorkBench
Quick Summary

Clean and Onion Architecture organize software so application policy owns the contracts that databases, frameworks, and delivery adapters implement. The guide compares their rings and terminology, then uses TypeScript examples to show direct database coupling and an inward-owned persistence contract. Its trade-offs and checklists help readers decide whether explicit use-case boundaries will reduce coupling or add ceremony without enough benefit.

Clean and Onion Architecture: Keeping Policy at the Center

Introduction

A use case that imports PrismaClient is tied to one database library, even if its business rule is simple:

async function placeOrder(db: PrismaClient, input: OrderInput): Promise<void> {
  await db.order.create({ data: input });
}

The application can own the contract and depend on that instead:

interface Orders {
  save(order: OrderInput): Promise<void>;
}

async function placeOrder(orders: Orders, input: OrderInput): Promise<void> {
  await orders.save(input);
}

An outer adapter imports Orders and implements it with Prisma or another store. The use case no longer imports the database library. This guide compares Clean and Onion vocabulary and boundaries, then shows how to keep policy independent without adding rings that only forward data.

When to Use / When Not to Use

These architectures help when domain rules are costly to change, multiple interfaces invoke the same use cases, or the framework model is likely to outlive its usefulness. They can also make policy tests fast because those tests need not boot a web server or database.

They are a poor fit when an application is mostly simple data entry and the proposed entities, interactors, gateways, and presenters merely pass values along. The rings add mapping and wiring. Start with a smaller structure if the domain has little behavior, then introduce boundaries where changes or tests show a real seam.

Core concepts

In a typical Onion arrangement, domain entities and value objects occupy the center. Application services or use cases sit around them. Interfaces for persistence and other capabilities are declared inward; infrastructure implements those contracts on the outside. Clean Architecture describes a similar dependency rule and commonly names entities, use cases, interface adapters, and frameworks/drivers as rings.

The diagrams are related but their vocabulary differs. Do not get stuck deciding whether a particular DTO belongs in “interface adapters” or an “outer onion.” Decide instead which policy owns it, whether dependencies point inward, and where translation belongs. Runtime control can cross the boundary outward: a use case calls an interface, and the injected database adapter runs. The compile-time dependency still points toward the use case’s contract.

What Changes Between Clean and Onion?

Onion Architecture puts the domain model at the center and describes application services and infrastructure as surrounding layers. Its presentation makes the domain’s independence from databases and user interfaces easy to see. Clean Architecture gives the application actions a more explicit place: use cases sit between entities and interface adapters, with frameworks and drivers farther out. A controller or presenter translates between an external format and a use case; it should not own the business rule.

That distinction affects naming and where teams tend to draw boundaries, not the direction of dependency. For example, a team using Onion terms might call an inward-owned persistence interface a repository in the application or domain layer. A Clean Architecture team may call the same boundary an output port used by an interactor. Either can work if the contract expresses what the policy needs and the database implementation stays outside. Avoid importing a label without deciding who owns the contract and which types may cross it.

Mermaid diagram

flowchart TD
  FW[Frameworks and drivers] --> AD[Interface adapters]
  AD --> UC[Use cases]
  UC --> DOMAIN[Entities and domain policy]
  DB[Database adapter] -. implements .-> PORT[Persistence contract]
  PORT --> UC

The concentric idea is shown by nesting policy inward; the dotted implementation edge clarifies that an outer adapter satisfies an inward contract. A visual ring alone does not prove the code follows the rule. Imports and build dependencies do.

Implementation / code example

Suppose the use case needs to load and save an account while a domain object enforces a withdrawal rule:

// domain/account.ts
export class Account {
  constructor(
    readonly id: string,
    private cents: number,
  ) {}
  withdraw(amount: number): void {
    if (amount <= 0 || amount > this.cents)
      throw new Error("Withdrawal rejected");
    this.cents -= amount;
  }
  balance(): number {
    return this.cents;
  }
}

// application/withdraw.ts
export interface Accounts {
  byId(id: string): Promise<Account>;
  save(account: Account): Promise<void>;
}

export async function withdraw(
  id: string,
  amount: number,
  accounts: Accounts,
): Promise<number> {
  const account = await accounts.byId(id);
  account.withdraw(amount);
  await accounts.save(account);
  return account.balance();
}

An outer adapter implements Accounts using the chosen persistence library. A controller converts HTTP input into the use-case call and maps known errors to responses. In production, withdrawal also needs concurrency control: two simultaneous reads must not both spend the same balance. A transaction, lock, or conditional update belongs in the persistence design and should be tested under contention.

Trade-off table

Choice Benefit Cost
Domain at the center Business rules avoid framework coupling Domain concepts require deliberate modeling
Use-case ring Application actions are explicit and testable Many tiny interactors can become ceremony
Inward contracts Infrastructure can adapt behind stable policy Data translation and dependency wiring increase
Frameworks at the edge Framework upgrades have a smaller blast radius Teams must resist convenient framework imports inward
Onion or Clean vocabulary Onion foregrounds the domain model; Clean names use cases and interface adapters explicitly Mixing terms without shared ownership rules makes boundaries hard to review

Production failure scenarios + mitigations

An ORM entity is treated as the domain object. Schema annotations and lazy-loading behavior leak into policy. Map at the adapter boundary or consciously accept the coupling; do not assume the diagram has isolated it.

A use case reads, checks, then writes with no concurrency guarantee. The core rule is correct for one request but fails under concurrent requests. Put atomicity in the adapter contract and implement it with a transaction or conditional write; add a contention test.

The composition root wires the wrong implementation. A test adapter or permissive implementation may be enabled in production. Make environment-specific wiring explicit, fail startup on missing production configuration, and expose safe adapter identity in diagnostics.

A module barrel quietly reverses the dependency direction. The domain imports a shared index.ts that re-exports ORM types alongside domain types, so an apparently harmless import pulls persistence into the core package. Enforce allowed package dependencies in the build, keep inward-facing modules free of infrastructure re-exports, and inspect the dependency graph in CI.

Two delivery adapters enforce different authorization rules. A new batch job calls a use case directly and skips a permission check that existed only in the HTTP controller. Put authorization decisions on a path shared by the entry points, and test the use case through each supported adapter. Keep transport-specific identity parsing at the edge, then pass an explicit actor or capability inward.

Observability checklist

  • Trace each use-case invocation from inbound adapter through persistence or provider adapters.
  • Capture outcome, duration, and error category at the use-case boundary.
  • Monitor database contention, transaction retries, and failed commits.
  • Attach correlation IDs to audit events without placing secrets or full sensitive objects in logs.

Security/compliance notes

Keep authorization policy in a path that all delivery adapters invoke; controllers should not be the only gate. Validate transport shape outside, then validate domain invariants centrally. Keep PII minimization and retention explicit at the use-case and adapter boundaries. Database adapters should parameterize queries, and composition configuration should source secrets from a protected runtime mechanism rather than source files.

Common pitfalls / anti-patterns

  • Treating “clean” as a demand for a separate project for every ring.
  • Creating use cases that only forward arguments and add no policy or orchestration.
  • Letting an inner package import a framework because “only tests use it.”
  • Confusing runtime call direction with compile-time dependency direction.
  • Hiding transaction semantics behind a generic save method when correctness needs an atomic operation.

Quick Recap Checklist

  • Are domain rules independent of transport, database, and framework types?
  • Do source imports point toward policy?
  • Do contracts express use-case needs rather than vendor APIs?
  • Are transaction and authorization guarantees explicit?
  • Does each ring earn its maintenance cost?

Interview Questions

1. What is the dependency rule in clean architecture?

Source code dependencies point toward higher-level policy. Outer adapters can implement contracts owned inward, so the business rules do not depend on a particular database or delivery framework.

2. How are clean and onion architecture different?

They use different names and emphasize different presentations of concentric boundaries. Both aim to keep policy central and technical details outside. Teams should agree on concrete dependency rules instead of treating the labels as exact interchangeable blueprints.

3. Can the use case call a database?

It can request persistence through an inward-owned contract. At runtime an adapter performs database work, while compile-time dependencies remain directed toward the use case.

4. Where should a repository interface live?

It belongs on the policy side that needs the capability. The domain may own it when persistence is part of a domain concept; otherwise, an application use case can own a narrower contract. The database adapter implements that contract outside.

5. What is the difference between runtime control flow and source dependency direction?

A use case can call an interface and cause an outer adapter to run at runtime. The source dependency still points inward because the use case declares the interface and the adapter depends on it to implement the contract.

6. Should every use case have its own class?

No. A separate function or class is useful when it gives an application action a clear boundary, policy, or test seam. If it only forwards arguments, keep the code simpler until the action needs its own behavior.

7. Does putting an ORM behind an adapter automatically make the architecture clean?

No. Check whether domain and use-case packages still import ORM types, whether adapter mapping is explicit, and whether the core can be built and tested without the ORM dependency.

8. Where should transaction boundaries be defined?

Define the required atomic operation at the application boundary, then implement it with the database's transaction or conditional-write mechanism in an outer adapter. A generic save operation is not enough if correctness depends on multiple writes succeeding together.

9. How can a team enforce the dependency rule?

Use package boundaries or build-time dependency rules to reject imports from inner modules to infrastructure. A dependency graph check in CI catches violations that code review may miss, including imports hidden behind barrel files.

10. When is a simpler layered design a better choice?

Choose fewer layers when the application has little domain behavior and boundaries would only pass data through. Add explicit use-case and adapter boundaries when changing policy, adding another delivery mechanism, or testing without infrastructure becomes valuable.

Further Reading

Conclusion

Clean and onion architecture give teams a way to keep policy at the center while delivery and infrastructure change around it. Their strongest test is simple: can the important rule be understood and tested without importing the framework that happens to deliver it? If yes, the boundaries are doing useful work. If every operation is a forwarding class, simplify.

Category

Related Posts

Hexagonal Architecture: Ports, Adapters, and Testable Cores

Learn hexagonal architecture through ports, adapters, and inward dependencies, with practical code, trade-offs, failure modes, and security guidance.

#software architecture #hexagonal architecture #ports and adapters

Layered Architecture: A Guide to Responsibility Tiers

Learn layered architecture's responsibility tiers, dependency rules, code examples, and trade-offs for separating presentation, domain, and infrastructure.

#software architecture #layered architecture #maintainability

Modular Monoliths: Strong Boundaries in One Deployment

Design a modular monolith with module ownership, public contracts, and enforced dependency rules while keeping one shared deployment and runtime.

#software architecture #modular monolith #modules