Ideas Engineered for Tomorrow
We Engineer Services & Solutions for Your Business Needs
Home About
Products
Services
Hire
Industries
Consulting
Partners
Articles Careers Contact
Software Development

Clean Architecture: Building Maintainable Software

Your framework choice shouldn't dictate your business logic. Clean architecture puts the business rules at the center and makes everything else — databases, web frameworks, APIs — a pluggable detail.

📚 Architecture January 18, 2026 13 min read

In This Guide

Clean architecture, popularized by Robert C. Martin (Uncle Bob), organizes code so that business rules are independent of frameworks, databases, and UI. The core idea: your order processing logic shouldn't change because you switched from Express to Fastify, or from PostgreSQL to MongoDB. We've applied this to a client project where the original codebase had Sequelize ORM calls scattered across 40 Express route handlers. Changing the database would have meant rewriting the entire app. After restructuring to clean architecture, we swapped PostgreSQL for DynamoDB in 3 days — only the repository implementations changed. Zero business logic touched.

1. Why Clean Architecture Matters

Most codebases couple business logic to infrastructure from day one. Your "create order" logic lives inside an Express route handler, calls Sequelize directly, and throws HTTP status codes. This means:

Clean architecture inverts this. Business rules know nothing about Express or Sequelize. They're pure functions and classes that take inputs and produce outputs. Express is just one way to deliver those inputs — you could swap it for a CLI, a queue consumer, or a gRPC server.

Coupled Code Clean Architecture
Business logic in route handlersBusiness logic in use case classes
Direct ORM calls everywhereRepository interfaces, ORM in implementations
Can't test without databaseUnit test with in-memory fakes
Framework lock-inFramework is a detail — swappable
Change database = rewrite everythingChange database = new repository implementation

2. The Dependency Rule

The fundamental rule: dependencies point inward. Outer layers can depend on inner layers, never the reverse. Your entities don't know about use cases. Your use cases don't know about controllers. Your controllers don't know about the web framework's internals (ideally).

The Dependency Rule (arrows = "depends on"):

┌─────────────────────────────────────────────┐
│            Frameworks & Drivers            │
│  Express, PostgreSQL, Redis, AWS SDK       │
│  ┌─────────────────────────────────────┐  │
│  │        Interface Adapters          │  │
│  │  Controllers, Repositories, Presenters│  │
│  │  ┌─────────────────────────────┐  │  │
│  │  │      Application Layer      │  │  │
│  │  │  Use Cases / Interactors   │  │  │
│  │  │  ┌───────────────────┐   │  │  │
│  │  │  │    Entities       │   │  │  │
│  │  │  │ Business rules    │   │  │  │
│  │  │  │ Value objects     │   │  │  │
│  │  │  └───────────────────┘   │  │  │
│  │  └─────────────────────────────┘  │  │
│  └─────────────────────────────────────┘  │
└─────────────────────────────────────────────┘

Dependencies flow INWARD only. Inner layers never import from outer layers.

3. The Four Layers

Layer Contains Knows About Changes When
EntitiesBusiness rules, domain objects, value objectsNothing elseBusiness rules change
Use CasesApplication-specific business rules, orchestrationEntities + port interfacesApp workflow changes
Interface AdaptersControllers, presenters, repository implementationsUse cases + entitiesAPI format, DB schema change
FrameworksExpress, Sequelize, AWS SDK, ReactEverything aboveFramework upgrade/swap

4. Use Cases — Where Business Logic Lives

A use case (also called interactor) represents one thing the user can do: "Place Order," "Cancel Subscription," "Generate Invoice." It orchestrates entities and calls repository interfaces — no HTTP, no SQL, no framework code.

// Use Case: Place Order
// No Express, no Sequelize, no HTTP status codes

interface PlaceOrderInput {
  customerId: string;
  items: Array<{ productId: string; quantity: number }>;
  shippingAddress: Address;
}

interface PlaceOrderOutput {
  orderId: string;
  total: number;
  estimatedDelivery: Date;
}

class PlaceOrderUseCase {
  constructor(
    private orderRepo: OrderRepository,    // interface, not implementation
    private productRepo: ProductRepository,
    private inventoryService: InventoryService,
    private pricingService: PricingService
  ) {}

  async execute(input: PlaceOrderInput): Promise<PlaceOrderOutput> {
    // 1. Validate products exist and get prices
    const products = await Promise.all(
      input.items.map(i => this.productRepo.findById(i.productId))
    );
    if (products.some(p => p === null)) {
      throw new ProductNotFoundError();
    }

    // 2. Check inventory
    for (const item of input.items) {
      const available = await this.inventoryService.checkStock(item.productId);
      if (available < item.quantity) {
        throw new InsufficientStockError(item.productId, available);
      }
    }

    // 3. Calculate pricing (business rule in entity)
    const order = Order.create(input.customerId, input.shippingAddress);
    for (const item of input.items) {
      const product = products.find(p => p.id === item.productId)!;
      const price = this.pricingService.getPrice(product, item.quantity);
      order.addItem(product, item.quantity, price);
    }

    // 4. Persist
    await this.orderRepo.save(order);

    return {
      orderId: order.id,
      total: order.total().amount,
      estimatedDelivery: order.estimatedDelivery()
    };
  }
}

Notice: this use case has zero imports from Express, Sequelize, or any framework. You could call it from an HTTP controller, a CLI command, a queue consumer, or a test — it doesn't care.

5. Dependency Inversion in Practice

The use case defines what it needs through interfaces (ports). The infrastructure layer provides implementations (adapters). This is the Dependency Inversion Principle — high-level modules don't depend on low-level modules, both depend on abstractions.

// PORT: defined in the domain layer (what we need)
interface OrderRepository {
  save(order: Order): Promise<void>;
  findById(id: string): Promise<Order | null>;
  findByCustomer(customerId: string): Promise<Order[]>;
}

// ADAPTER 1: PostgreSQL (production)
class PostgresOrderRepository implements OrderRepository {
  constructor(private pool: Pool) {}

  async save(order: Order): Promise<void> {
    await this.pool.query(
      'INSERT INTO orders (id, customer_id, status, total) VALUES ($1,$2,$3,$4)
       ON CONFLICT (id) DO UPDATE SET status=$3, total=$4',
      [order.id, order.customerId, order.status, order.total().amount]
    );
  }
  // ... other methods
}

// ADAPTER 2: In-memory (testing)
class InMemoryOrderRepository implements OrderRepository {
  private orders: Map<string, Order> = new Map();

  async save(order: Order): Promise<void> {
    this.orders.set(order.id, order);
  }

  async findById(id: string): Promise<Order | null> {
    return this.orders.get(id) || null;
  }
  // ... other methods
}

// ADAPTER 3: DynamoDB (if you switch)
class DynamoOrderRepository implements OrderRepository {
  // Same interface, different persistence
}

6. Testing Without Frameworks

This is where clean architecture pays for itself. Business logic tests run in milliseconds — no database, no HTTP server, no Docker containers.

// Testing the PlaceOrder use case — pure unit test
describe('PlaceOrderUseCase', () => {
  let useCase: PlaceOrderUseCase;
  let orderRepo: InMemoryOrderRepository;
  let productRepo: InMemoryProductRepository;

  beforeEach(() => {
    orderRepo = new InMemoryOrderRepository();
    productRepo = new InMemoryProductRepository();
    productRepo.add(Product.create('p1', 'Widget', Money.of(29.99, 'USD')));

    useCase = new PlaceOrderUseCase(
      orderRepo,
      productRepo,
      new FakeInventoryService({ p1: 100 }),
      new SimplePricingService()
    );
  });

  it('creates an order with correct total', async () => {
    const result = await useCase.execute({
      customerId: 'cust_1',
      items: [{ productId: 'p1', quantity: 3 }],
      shippingAddress: Address.create('Mumbai', '400001', 'IN')
    });

    expect(result.total).toBe(89.97);
    expect(result.orderId).toBeDefined();

    const saved = await orderRepo.findById(result.orderId);
    expect(saved).not.toBeNull();
    expect(saved!.items).toHaveLength(1);
  });

  it('rejects order for out-of-stock items', async () => {
    await expect(useCase.execute({
      customerId: 'cust_1',
      items: [{ productId: 'p1', quantity: 999 }],
      shippingAddress: Address.create('Mumbai', '400001', 'IN')
    })).rejects.toThrow(InsufficientStockError);
  });
});

// These tests run in < 10ms. No database. No HTTP server. No Docker.
Test Type What It Tests Speed Dependencies
Entity unit testsBusiness rules (Order.addItem validation)< 1msNone
Use case testsApplication workflows< 10msIn-memory fakes
Repository integrationDatabase mapping correctness100-500msReal database (Testcontainers)
Controller/API testsHTTP routing, serialization50-200msHTTP framework + in-memory fakes

7. Project Structure That Works

src/
├── domain/                       ← Entities + Value Objects (no deps)
│  ├── order/
│  │  ├── Order.ts                ← Aggregate root
│  │  ├── OrderItem.ts
│  │  ├── OrderStatus.ts
│  │  └── OrderRepository.ts      ← Interface (port)
│  ├── shared/
│  │  ├── Money.ts
│  │  └── Address.ts
│  └── product/
│     ├── Product.ts
│     └── ProductRepository.ts

├── application/                  ← Use Cases (depends on domain only)
│  ├── PlaceOrder.ts
│  ├── CancelOrder.ts
│  ├── GetOrderDetails.ts
│  └── dto/                      ← Input/Output data structures
│     ├── PlaceOrderInput.ts
│     └── PlaceOrderOutput.ts

├── infrastructure/               ← Adapters (depends on everything)
│  ├── persistence/
│  │  ├── PostgresOrderRepo.ts   ← Implements OrderRepository
│  │  └── PostgresProductRepo.ts
│  ├── http/
│  │  ├── OrderController.ts     ← Express routes
│  │  └── middleware/
│  ├── messaging/
│  │  └── KafkaOrderConsumer.ts
│  └── external/
│     └── StripePaymentAdapter.ts

├── config/
│  └── container.ts              ← Dependency injection wiring
└── main.ts                      ← Composition root

The key rule: domain/ imports nothing from application/ or infrastructure/. application/ imports from domain/ only. infrastructure/ imports from both. The composition root (main.ts) wires everything together.

8. Pragmatic Clean Architecture

Purist clean architecture can create excessive boilerplate. Here's where we bend the rules in practice:

Purist Rule Pragmatic Shortcut When to Shortcut
Every interaction needs a use case classSimple CRUD reads can skip the use case layerGET endpoints with no business logic
Separate input/output DTOs for every use caseReuse entity types when the shape matchesSmall projects, internal APIs
Framework code must be in outermost layerUse ORM decorators on entities for simple appsTeam knows the ORM, unlikely to switch
Full dependency injection containerConstructor injection with manual wiringUnder 20 services to wire
Our rule: Apply clean architecture to the parts of your system that have complex business logic. Use simpler patterns (MVC, service layer) for CRUD-heavy areas. Not every endpoint needs four layers. The goal is maintainability, not architectural purity. If your team spends more time satisfying architecture rules than writing features, you've gone too far.

9. Frequently Asked Questions

How is clean architecture different from hexagonal architecture?

They're essentially the same idea with different terminology. Hexagonal architecture (ports and adapters) uses "ports" where clean architecture uses "interfaces" and "adapters" where clean architecture uses "implementations." The core principle is identical: business logic at the center, infrastructure at the edges, dependencies pointing inward. We use the terms interchangeably on our projects.

Is clean architecture too much boilerplate for small projects?

For a true MVP or prototype, yes — it's overhead. For anything that will be maintained for more than 6 months, the investment pays off. The key is applying it selectively: use clean architecture for your core business domain and simpler patterns for supporting features. A 3-person startup doesn't need four layers for a settings page, but they'll appreciate it for their payment processing logic.

Which DI framework should I use with clean architecture?

For TypeScript/Node.js: tsyringe or InversifyJS work well, but manual constructor injection is fine for small projects. For .NET: the built-in DI container is excellent. For Java: Spring's DI is the standard. Don't over-engineer DI — the goal is constructor injection so you can pass different implementations (production vs test), not to build an enterprise service locator. We use manual wiring for projects under 30 dependencies.

How does clean architecture work with serverless (Lambda)?

Perfectly. The Lambda handler is just another adapter — it parses the event, calls the use case, and formats the response. Your domain and application layers stay the same whether the trigger is API Gateway, SQS, or EventBridge. The cold start concern is valid: heavy DI containers add latency. We use lightweight composition (manual wiring) in serverless to keep cold starts under 200ms.

PI
Pillai Infotech Engineering Team

We've refactored messy codebases into clean architecture and built greenfield projects with it from day one. Our approach is pragmatic: use the layers where complexity demands it, simplify where it doesn't. The best architecture is the one your team can maintain.

Related Articles

Domain-Driven Design (DDD): A Practical Guide for Developers → Design Patterns for Modern Software Development → Microservices vs Monolith: When to Make the Switch →