Blog/Deep Dives/Clean Architecture in TypeScript Backend Applications
POST
November 28, 2025
LAST UPDATEDNovember 28, 2025

Clean Architecture in TypeScript Backend Applications

Learn how to implement Clean Architecture in TypeScript backends with NestJS and Express, covering layers, dependency inversion, and testing strategies.

Tags

Clean ArchitectureTypeScriptBackendDesign Patterns
Clean Architecture in TypeScript Backend Applications
7 min read

Clean Architecture in TypeScript Backend Applications

Clean Architecture is a design philosophy that structures your TypeScript backend into concentric layers where every source code dependency points inward, toward the core business logic. Proposed by Robert C. Martin, the approach ensures that your domain entities and use cases remain completely independent of frameworks, databases, and delivery mechanisms. In a TypeScript backend, this means your business rules live in plain TypeScript classes with zero imports from Express, NestJS, Prisma, or any external library. The result is a codebase where you can swap your web framework, change your database, or replace your messaging queue without touching a single line of business logic.

TL;DR

Clean Architecture enforces a dependency rule: inner layers never know about outer layers. Your TypeScript entities and use cases contain pure business logic with no framework imports. Adapters translate between the outside world and your domain. This structure pays off in complex domains with rich business rules but adds overhead to simple CRUD apps.

Why This Matters

Most backend applications start simple and grow into tangled messes. Controllers call repositories directly, business logic leaks into route handlers, and database models double as domain entities. When the time comes to change your ORM, add a new API transport, or write meaningful tests, you discover that everything is coupled to everything.

Clean Architecture solves this by establishing clear boundaries. When your domain logic is isolated, you can test it with simple unit tests that run in milliseconds—no database containers, no HTTP mocking, no test environment configuration. When a new requirement arrives, you modify the use case layer without worrying about breaking API contracts or database migrations. When your team grows, developers can work on different layers simultaneously without merge conflicts.

The cost is indirection. More files, more interfaces, more mapping between layers. This is a deliberate trade-off, and understanding when it pays off is as important as understanding how to implement it.

How It Works

The Dependency Rule

The fundamental principle of Clean Architecture is the dependency rule: source code dependencies must always point inward. An inner layer never imports, references, or has any knowledge of an outer layer.

The layers, from innermost to outermost, are:

  1. Entities — Core business objects and enterprise-wide rules
  2. Use Cases — Application-specific business rules
  3. Interface Adapters — Controllers, presenters, gateways, and repositories
  4. Frameworks and Drivers — Express, NestJS, PostgreSQL drivers, external APIs

An entity never imports a use case. A use case never imports a controller. A controller never imports the Express Request type directly into a use case.

Entities: The Core

Entities encapsulate enterprise-wide business rules. In a TypeScript backend, these are plain classes or types that represent your domain concepts.

typescript
// domain/entities/Order.ts
export class Order {
  private readonly items: OrderItem[];
  private status: OrderStatus;
 
  constructor(
    public readonly id: string,
    public readonly customerId: string,
    items: OrderItem[],
    status: OrderStatus = OrderStatus.PENDING
  ) {
    if (items.length === 0) {
      throw new DomainError("Order must contain at least one item");
    }
    this.items = [...items];
    this.status = status;
  }
 
  get total(): number {
    return this.items.reduce(
      (sum, item) => sum + item.price * item.quantity,
      0
    );
  }
 
  confirm(): void {
    if (this.status !== OrderStatus.PENDING) {
      throw new DomainError("Only pending orders can be confirmed");
    }
    this.status = OrderStatus.CONFIRMED;
  }
 
  cancel(): void {
    if (this.status === OrderStatus.SHIPPED) {
      throw new DomainError("Shipped orders cannot be cancelled");
    }
    this.status = OrderStatus.CANCELLED;
  }
}

Notice there are no decorators, no ORM annotations, no framework imports. This is pure TypeScript expressing pure business rules. The Order class knows how to calculate totals, validate its own state transitions, and enforce invariants. It knows nothing about HTTP, SQL, or JSON serialization.

Use Cases: Application Logic

Use cases orchestrate the flow of data to and from entities. Each use case represents a single application operation.

typescript
// application/use-cases/CreateOrder.ts
import { Order } from "../../domain/entities/Order";
import { OrderRepository } from "../ports/OrderRepository";
import { PaymentGateway } from "../ports/PaymentGateway";
import { EventPublisher } from "../ports/EventPublisher";
 
export interface CreateOrderInput {
  customerId: string;
  items: { productId: string; quantity: number; price: number }[];
}
 
export class CreateOrderUseCase {
  constructor(
    private readonly orderRepository: OrderRepository,
    private readonly paymentGateway: PaymentGateway,
    private readonly eventPublisher: EventPublisher
  ) {}
 
  async execute(input: CreateOrderInput): Promise<Order> {
    const order = new Order(
      crypto.randomUUID(),
      input.customerId,
      input.items.map((i) => ({
        productId: i.productId,
        quantity: i.quantity,
        price: i.price,
      }))
    );
 
    await this.paymentGateway.authorize(order.customerId, order.total);
    await this.orderRepository.save(order);
    await this.eventPublisher.publish("order.created", {
      orderId: order.id,
    });
 
    return order;
  }
}

The use case imports interfaces (ports), not concrete implementations. It has no idea whether OrderRepository writes to PostgreSQL, MongoDB, or an in-memory array. This is dependency inversion in action.

Ports and Adapters

Ports are interfaces defined in the application layer that describe what the application needs from the outside world. Adapters are concrete implementations in the outer layer that satisfy those interfaces.

typescript
// application/ports/OrderRepository.ts
import { Order } from "../../domain/entities/Order";
 
export interface OrderRepository {
  save(order: Order): Promise<void>;
  findById(id: string): Promise<Order | null>;
  findByCustomerId(customerId: string): Promise<Order[]>;
}
typescript
// infrastructure/repositories/PrismaOrderRepository.ts
import { PrismaClient } from "@prisma/client";
import { OrderRepository } from "../../application/ports/OrderRepository";
import { Order } from "../../domain/entities/Order";
import { OrderMapper } from "../mappers/OrderMapper";
 
export class PrismaOrderRepository implements OrderRepository {
  constructor(private readonly prisma: PrismaClient) {}
 
  async save(order: Order): Promise<void> {
    const data = OrderMapper.toPersistence(order);
    await this.prisma.order.upsert({
      where: { id: data.id },
      create: data,
      update: data,
    });
  }
 
  async findById(id: string): Promise<Order | null> {
    const record = await this.prisma.order.findUnique({
      where: { id },
      include: { items: true },
    });
    return record ? OrderMapper.toDomain(record) : null;
  }
 
  async findByCustomerId(customerId: string): Promise<Order[]> {
    const records = await this.prisma.order.findMany({
      where: { customerId },
      include: { items: true },
    });
    return records.map(OrderMapper.toDomain);
  }
}

The mapper layer is critical. Domain entities are not the same as persistence models. Your database might store data in a normalized relational schema, but your domain entity might represent a rich aggregate with computed properties and behavior. Mappers translate between these two representations.

Project Structure

Here is a practical folder structure for a NestJS project using Clean Architecture:

src/
├── domain/
│   ├── entities/
│   │   ├── Order.ts
│   │   ├── Customer.ts
│   │   └── Product.ts
│   ├── value-objects/
│   │   ├── Money.ts
│   │   └── Email.ts
│   └── errors/
│       └── DomainError.ts
├── application/
│   ├── use-cases/
│   │   ├── CreateOrder.ts
│   │   ├── CancelOrder.ts
│   │   └── GetOrdersByCustomer.ts
│   ├── ports/
│   │   ├── OrderRepository.ts
│   │   ├── PaymentGateway.ts
│   │   └── EventPublisher.ts
│   └── dtos/
│       ├── CreateOrderInput.ts
│       └── OrderOutput.ts
├── infrastructure/
│   ├── repositories/
│   │   └── PrismaOrderRepository.ts
│   ├── gateways/
│   │   └── StripePaymentGateway.ts
│   ├── messaging/
│   │   └── RabbitMQEventPublisher.ts
│   ├── mappers/
│   │   └── OrderMapper.ts
│   └── config/
│       └── database.ts
└── presentation/
    ├── http/
    │   ├── controllers/
    │   │   └── OrderController.ts
    │   └── middleware/
    │       └── AuthMiddleware.ts
    └── modules/
        └── OrderModule.ts

The domain/ and application/ directories have zero external dependencies. You can verify this by checking their import statements—they should only reference each other and standard TypeScript/JavaScript APIs.

Practical Implementation

Wiring It Up with NestJS

NestJS's dependency injection container makes it straightforward to connect the layers:

typescript
// presentation/modules/OrderModule.ts
import { Module } from "@nestjs/common";
import { OrderController } from "../http/controllers/OrderController";
import { CreateOrderUseCase } from "../../application/use-cases/CreateOrder";
import { PrismaOrderRepository } from "../../infrastructure/repositories/PrismaOrderRepository";
import { StripePaymentGateway } from "../../infrastructure/gateways/StripePaymentGateway";
import { RabbitMQEventPublisher } from "../../infrastructure/messaging/RabbitMQEventPublisher";
 
@Module({
  controllers: [OrderController],
  providers: [
    CreateOrderUseCase,
    {
      provide: "OrderRepository",
      useClass: PrismaOrderRepository,
    },
    {
      provide: "PaymentGateway",
      useClass: StripePaymentGateway,
    },
    {
      provide: "EventPublisher",
      useClass: RabbitMQEventPublisher,
    },
  ],
})
export class OrderModule {}

Testing Benefits

The most immediate payoff of Clean Architecture is testability. Use cases can be tested with simple mocks:

typescript
// tests/application/CreateOrder.test.ts
import { CreateOrderUseCase } from "../../src/application/use-cases/CreateOrder";
 
describe("CreateOrderUseCase", () => {
  it("should create an order and publish event", async () => {
    const mockRepo = { save: jest.fn(), findById: jest.fn(), findByCustomerId: jest.fn() };
    const mockPayment = { authorize: jest.fn() };
    const mockEvents = { publish: jest.fn() };
 
    const useCase = new CreateOrderUseCase(mockRepo, mockPayment, mockEvents);
 
    const order = await useCase.execute({
      customerId: "customer-1",
      items: [{ productId: "prod-1", quantity: 2, price: 29.99 }],
    });
 
    expect(order.id).toBeDefined();
    expect(mockRepo.save).toHaveBeenCalledTimes(1);
    expect(mockPayment.authorize).toHaveBeenCalledWith("customer-1", 59.98);
    expect(mockEvents.publish).toHaveBeenCalledWith("order.created", {
      orderId: order.id,
    });
  });
});

These tests run in milliseconds because they involve no I/O. No database setup, no network calls, no environment variables. The business rules are tested in complete isolation.

Express Implementation

Clean Architecture is not framework-specific. Here is the same approach with Express:

typescript
// presentation/http/routes/orderRoutes.ts
import { Router } from "express";
import { CreateOrderUseCase } from "../../../application/use-cases/CreateOrder";
import { PrismaOrderRepository } from "../../../infrastructure/repositories/PrismaOrderRepository";
import { StripePaymentGateway } from "../../../infrastructure/gateways/StripePaymentGateway";
import { InMemoryEventPublisher } from "../../../infrastructure/messaging/InMemoryEventPublisher";
import { prisma } from "../../../infrastructure/config/database";
 
const router = Router();
 
const createOrderUseCase = new CreateOrderUseCase(
  new PrismaOrderRepository(prisma),
  new StripePaymentGateway(),
  new InMemoryEventPublisher()
);
 
router.post("/orders", async (req, res) => {
  try {
    const order = await createOrderUseCase.execute(req.body);
    res.status(201).json(order);
  } catch (error) {
    if (error instanceof DomainError) {
      res.status(400).json({ message: error.message });
    } else {
      res.status(500).json({ message: "Internal server error" });
    }
  }
});
 
export { router as orderRoutes };

Without a DI container, you wire dependencies manually. For small projects this is acceptable. For larger projects, consider using a lightweight DI library like tsyringe or inversify.

Common Pitfalls

Over-abstracting early. Do not create interfaces for everything on day one. Start with concrete implementations and extract interfaces when you actually need a second implementation or when testing demands it.

Anemic domain models. If your entities are just data containers with getters and setters, you have lost the value of the domain layer. Entities should contain behavior—validation, state transitions, computed properties, and invariant enforcement.

Leaking infrastructure concerns. If your entity has a @Column() decorator or your use case catches a PrismaClientKnownRequestError, you have violated the dependency rule. Domain and application layers must remain infrastructure-agnostic.

Mapper explosion. With multiple layers, you need mappers between persistence models, domain entities, and API response DTOs. This is real overhead. Keep mappers simple and consider generating them with tools or using factory functions.

Ignoring the dependency rule for convenience. The moment you import a framework type into your domain layer "just this once," you have undermined the entire architecture. Discipline in maintaining the dependency rule is what makes Clean Architecture work.

When to Use (and When Not To)

Use Clean Architecture when:

  • Your domain has complex business rules beyond simple CRUD
  • Multiple delivery mechanisms exist (REST API, GraphQL, CLI, message consumers)
  • Long-lived projects where infrastructure will change over time
  • Teams are large enough that clear boundaries prevent stepping on each other's code
  • Testability of business logic is a high priority

Skip Clean Architecture when:

  • Building a prototype or proof of concept
  • The application is primarily CRUD with minimal business logic
  • The team is small and the project has a short lifespan
  • You are building a BFF (Backend for Frontend) that mostly proxies other services
  • Time-to-market matters more than long-term maintainability

The key insight is that Clean Architecture is an investment. It costs more upfront in terms of boilerplate and structure, but it pays dividends in maintainability, testability, and flexibility over the lifetime of a complex project.

FAQ

What is Clean Architecture in TypeScript? Clean Architecture is a software design approach that organizes TypeScript backend code into concentric layers—entities, use cases, interface adapters, and frameworks—where source code dependencies always point inward toward the business logic. This isolation means your core business rules have no knowledge of which web framework, database, or messaging system you use.

How does Clean Architecture differ from MVC? While MVC organizes code by technical role (model, view, controller), Clean Architecture organizes by business responsibility with strict dependency rules. In MVC, your model often contains both business logic and database access. In Clean Architecture, the domain layer has zero knowledge of the web framework, database, or any external system, and the dependency rule prevents outer concerns from leaking inward.

When is Clean Architecture overkill? Clean Architecture adds unnecessary complexity to simple CRUD applications, prototypes, short-lived scripts, or projects with fewer than a handful of business rules. If your application is primarily shuttling data between a frontend and a database with minimal transformation or validation, the overhead of multiple layers, interfaces, and mappers is not justified by the benefits.

Can I use Clean Architecture with NestJS? Yes, NestJS is an excellent fit for Clean Architecture because its module system and built-in dependency injection naturally support the separation of concerns and dependency inversion that Clean Architecture requires. NestJS providers can bind interfaces to concrete implementations, making it straightforward to swap infrastructure components without touching business logic.

Collaboration

Need help with a project?

Let's Build It

I help startups and established companies design, build, and scale world-class digital products. From deep technical architecture to pixel-perfect UI — let's bring your vision to life.

SH

Article Author

Sadam Hussain

Senior Full Stack Developer

Senior Full Stack Developer with over 7 years of experience building React, Next.js, Node.js, TypeScript, and AI-powered web platforms.

Related Articles

How to Design API Contracts Between Micro-Frontends and BFFs
Mar 21, 20266 min read
Micro-Frontends
BFF
API Design

How to Design API Contracts Between Micro-Frontends and BFFs

Learn how to design stable API contracts between Micro-Frontends and Backend-for-Frontend layers with versioning, ownership boundaries, error handling, and schema governance.

Next.js BFF Architecture
Mar 21, 20261 min read
Next.js
BFF
Architecture

Next.js BFF Architecture

An architectural deep dive into using Next.js as a Backend-for-Frontend, including route handlers, server components, auth boundaries, caching, and service orchestration.

Next.js Cache Components and PPR in Real Apps
Mar 21, 20266 min read
Next.js
Performance
Caching

Next.js Cache Components and PPR in Real Apps

A practical guide to using Next.js Cache Components and Partial Prerendering in real applications, with tradeoffs, cache strategy, and freshness considerations.