Best Practices

AI Rules for Clean Architecture

AI tangles business logic with frameworks, databases, and HTTP handlers. Rules for dependency inversion, use case layers, interface adapters, framework independence, and testability through boundaries.

8 min read·February 23, 2025

Business logic in Express handlers — change frameworks, rewrite everything. Switch databases, rewrite everything.

Dependency inversion, use case interactors, interface adapters, framework independence, testable boundaries

AI Couples Everything to Everything

AI generates code where: business logic lives in Express route handlers (changing frameworks requires rewriting business rules), database queries are embedded in use cases (switching from Postgres to MongoDB requires rewriting business logic), domain objects import framework types (the Order class depends on Prisma types), HTTP request/response shapes leak into the domain (business logic parses req.body directly), and testing requires spinning up the entire application (cannot test a business rule without a database and HTTP server).

Clean architecture solves this with: concentric layers (entities at the center, frameworks at the edge), dependency inversion (inner layers define interfaces, outer layers implement them), use case isolation (business logic in framework-free functions), interface adapters (translate between external formats and domain formats), and testable boundaries (each layer testable in isolation with simple mocks). AI generates none of these — but not every project needs formal clean architecture.

These rules cover: the dependency rule, use case interactors, interface adapters, framework independence, testability through boundaries, and when clean architecture is worth the investment.

Rule 1: The Dependency Rule

The rule: 'Dependencies point inward only. Inner layers do not know about outer layers. The domain layer (entities, value objects) has zero external dependencies — no framework imports, no database imports, no HTTP imports. The use case layer depends only on the domain layer. The adapter layer depends on use cases and domain. The framework layer (Express, Next.js, Prisma) is the outermost ring — it depends on everything but nothing depends on it.'

For the inversion mechanism: 'The use case needs to save an order but must not depend on the database. Solution: define an interface in the use case layer: interface OrderRepository { save(order: Order): Promise<void>; findById(id: string): Promise<Order | null>; }. The database adapter implements this interface: class PrismaOrderRepository implements OrderRepository { ... }. The use case calls the interface; the framework wires the implementation. The use case is testable with a simple in-memory mock.'

AI generates: import { prisma } from '../lib/prisma'; in the use case — the business logic depends on Prisma. Switching to Drizzle requires rewriting every use case. With dependency inversion: the use case depends on OrderRepository (an interface). Switching databases: write a new DrizzleOrderRepository, wire it in the composition root. Zero changes to business logic.

  • Dependencies point inward: domain ← use cases ← adapters ← frameworks
  • Domain layer: zero external dependencies — pure TypeScript, no imports from frameworks
  • Interfaces in use case layer, implementations in adapter layer
  • Framework is outermost ring — replaceable without touching business logic
  • Composition root wires implementations to interfaces at application startup
💡 Switch Databases, Zero Rewrites

Use case depends on OrderRepository interface, not Prisma. Switching from Prisma to Drizzle: write a new DrizzleOrderRepository, wire it in the composition root. Zero changes to business logic. The interface is the boundary; implementations are interchangeable.

Rule 2: Use Case Interactors

The rule: 'Each use case is a single-purpose class or function: CreateOrder, ShipOrder, CancelOrder, ApplyDiscount. The use case: receives input (DTO, not HTTP request), calls domain logic (order.ship()), calls repository interfaces (orderRepo.save(order)), and returns output (DTO, not HTTP response). The use case knows nothing about HTTP, databases, or frameworks — it orchestrates domain objects and repository calls.'

For use case structure: 'class CreateOrderUseCase { constructor(private orderRepo: OrderRepository, private paymentGateway: PaymentGateway) {} async execute(input: CreateOrderInput): Promise<CreateOrderOutput> { const order = Order.create(input.items, input.shippingAddress); await this.paymentGateway.reserve(order.total); await this.orderRepo.save(order); return { orderId: order.id, total: order.total.toString() }; } }. Dependencies injected via constructor. Input and output are simple DTOs. The use case is a pure orchestration of domain logic.'

AI generates: app.post('/orders', async (req, res) => { const order = await prisma.order.create({ data: req.body }); await stripe.charges.create({ amount: order.total }); res.json(order); }) — HTTP handling, database, payment, and response formatting in one function. With a use case: the route handler calls createOrderUseCase.execute(input), the use case orchestrates the domain. The handler is 3 lines; the business logic is framework-free.

⚠️ Route Handler = 3 Lines

AI puts database queries, payment calls, and response formatting in one route handler. With a use case: the handler parses input, calls useCase.execute(input), returns the result. Three lines. Business logic is framework-free, testable without HTTP.

Rule 3: Interface Adapters

The rule: 'Adapters translate between external formats and domain formats. HTTP adapter: converts req.body to CreateOrderInput DTO, calls the use case, converts the output to HTTP response. Database adapter: implements OrderRepository interface, translates between domain Order entity and database row format. Payment adapter: implements PaymentGateway interface, translates between domain Money value object and Stripe charge parameters.'

For the adapter pattern: 'class ExpressOrderController { constructor(private createOrder: CreateOrderUseCase) {} async handle(req: Request, res: Response) { const input: CreateOrderInput = { items: req.body.items, shippingAddress: req.body.address }; const result = await this.createOrder.execute(input); res.status(201).json(result); } }. The controller is an adapter: it translates HTTP to use case input and use case output to HTTP. The use case never sees Request or Response.'

AI generates: use cases that parse req.body, use Prisma types as domain entities, and format Stripe responses. Three external systems coupled to business logic. With adapters: each external system has its own adapter. Change the payment provider from Stripe to Square: write a new SquarePaymentAdapter, wire it in the composition root. Zero changes to use cases or domain logic.

Rule 4: Testability Through Layer Boundaries

The rule: 'Each layer is testable in isolation. Domain entities: pure unit tests, no mocks needed (Order.create validates, order.ship enforces rules). Use cases: mock the repository and gateway interfaces (in-memory implementations). Adapters: integration tests against real external systems (database, APIs). The testing pyramid maps to architecture layers: many fast domain tests, fewer use case tests with simple mocks, few slow integration tests for adapters.'

For mock simplicity: 'Because use cases depend on interfaces (not implementations), mocks are trivial: const mockRepo: OrderRepository = { save: jest.fn(), findById: jest.fn().mockResolvedValue(testOrder) }. No need to mock Prisma internals, HTTP servers, or payment SDKs. The interface is the contract; the mock satisfies the contract. Test the business rule, not the database driver.'

AI generates: tests that require a running database, an HTTP server, and mock payment API servers — slow, flaky, and testing infrastructure instead of business logic. With clean architecture: domain tests run in milliseconds (pure functions), use case tests run with in-memory mocks (fast, deterministic), and only adapter tests need real infrastructure (run in CI, not on every save).

  • Domain: pure unit tests, no mocks — test business rules directly
  • Use cases: mock interfaces with simple objects — no framework mocking
  • Adapters: integration tests with real systems — database, APIs, file systems
  • Testing pyramid: many domain tests, fewer use case tests, few adapter tests
  • Mock simplicity: interface contract, not implementation internals
ℹ️ Milliseconds, Not Minutes

AI tests require a running database and HTTP server — slow, flaky. Clean architecture: domain tests are pure functions (milliseconds). Use case tests mock interfaces with simple objects (fast, deterministic). Only adapter tests need real infrastructure. Testing pyramid matches architecture layers.

Rule 5: When Clean Architecture Is Worth It

The rule: 'Use clean architecture when: (1) the application will live for years (the investment pays off over time), (2) the domain is complex (many business rules, not just CRUD), (3) multiple teams work on the codebase (boundaries prevent coupling between teams), (4) external systems may change (database migration, payment provider switch), (5) testability is a priority (fast, reliable test suite). Do not use for: prototypes, simple CRUD apps, short-lived projects, or solo developer projects where the overhead exceeds the benefit.'

For pragmatic adoption: 'You do not need all layers from day one. Start with: separate domain logic from framework code (do not put business rules in route handlers). Next: extract use cases (single-purpose functions that orchestrate domain logic). Then: add repository interfaces when you need testability or database flexibility. Full clean architecture emerges gradually — not as a big-bang refactor. Each step adds value independently.'

AI generates: either spaghetti code (everything in route handlers) or over-engineered architecture (AbstractFactoryBuilderProvider for a todo app). The pragmatic path: separate concerns gradually. A todo app needs: one file with route handlers. An enterprise order management system needs: full clean architecture with domain, use cases, adapters, and frameworks separated. Match the architecture investment to the problem complexity and expected lifespan.

Complete Clean Architecture Rules Template

Consolidated rules for clean architecture.

  • Dependency rule: inner layers never import from outer layers — dependencies point inward
  • Domain layer: zero external dependencies — pure TypeScript entities and value objects
  • Use case interactors: single-purpose, framework-free orchestration of domain logic
  • Interface adapters: translate between external formats and domain formats
  • Composition root: wire implementations to interfaces at startup — one place for all wiring
  • Testable boundaries: domain tests (pure), use case tests (mocked interfaces), adapter tests (integration)
  • Gradual adoption: separate domain from framework first, add layers as complexity grows
  • Not for prototypes or simple CRUD — match architecture investment to problem complexity
AI Rules for Clean Architecture — RuleSync Blog