Skip to content

Clean Architecture Principles in Real-World Applications

Apply clean architecture patterns that separate business logic from infrastructure, keeping applications testable, maintainable and open to change.

4 min read
Concentric circles diagram showing clean architecture layers from entities at the center to frameworks at the outer ring

Clean architecture isn't about following a specific folder structure. It's about one rule: dependencies point inward. Business logic doesn't know about databases, HTTP frameworks, or external APIs. This inversion of control makes the core of your application testable without infrastructure, swappable without rewrites, and understandable without reading every integration detail.

The Dependency Rule in Practice

The dependency rule states that inner layers cannot reference outer layers. Business entities don't import database clients. Use cases don't import Express or Next.js.

tstypescript
// ❌ Business logic coupled to infrastructure
import { prisma } from "../lib/prisma";
import { sendgrid } from "../lib/email";
 
async function createOrder(userId: string, items: CartItem[]): Promise<Order> {
  // Business logic directly depends on Prisma and SendGrid
  const user = await prisma.user.findUnique({ where: { id: userId } });
  if (!user) throw new Error("User not found");
 
  const total = items.reduce((sum, i) => sum + i.price * i.quantity, 0);
 
  const order = await prisma.order.create({
    data: { userId, total, items: { create: items } },
  });
 
  await sendgrid.send({
    to: user.email,
    subject: "Order Confirmation",
    text: `Your order #${order.id} total: $${total}`,
  });
 
  return order;
}
// Can't test without a database and email service running
// Can't switch from Prisma to another ORM without rewriting business logic
tstypescript
// ✅ Business logic depends only on interfaces (ports)
// domain/entities/order.ts
interface Order {
  id: string;
  userId: string;
  items: OrderItem[];
  total: number;
  status: OrderStatus;
  createdAt: Date;
}
 
type OrderStatus = "pending" | "confirmed" | "shipped" | "delivered";
 
interface OrderItem {
  productId: string;
  name: string;
  price: number;
  quantity: number;
}
 
// domain/ports/order-repository.ts
interface OrderRepository {
  create(order: Omit<Order, "id" | "createdAt">): Promise<Order>;
  findById(id: string): Promise<Order | null>;
  findByUserId(userId: string): Promise<Order[]>;
}
 
// domain/ports/user-repository.ts
interface UserRepository {
  findById(id: string): Promise<User | null>;
}
 
// domain/ports/notification-service.ts
interface NotificationService {
  sendOrderConfirmation(email: string, order: Order): Promise<void>;
}

Use Cases: The Application Layer

Use cases orchestrate business logic. They depend on domain interfaces (ports) and are completely decoupled from infrastructure details.

tstypescript
// application/use-cases/create-order.ts
class CreateOrderUseCase {
  constructor(
    private readonly orderRepo: OrderRepository,
    private readonly userRepo: UserRepository,
    private readonly notifier: NotificationService
  ) {}
 
  async execute(input: CreateOrderInput): Promise<Order> {
    // Validate user exists
    const user = await this.userRepo.findById(input.userId);
    if (!user) {
      throw new DomainError("USER_NOT_FOUND", "User does not exist");
    }
 
    // Apply business rules
    const total = this.calculateTotal(input.items);
 
    if (total <= 0) {
      throw new DomainError("INVALID_ORDER", "Order total must be positive");
    }
 
    if (input.items.length > 50) {
      throw new DomainError("ORDER_TOO_LARGE", "Maximum 50 items per order");
    }
 
    // Create order through repository interface
    const order = await this.orderRepo.create({
      userId: input.userId,
      items: input.items,
      total,
      status: "pending",
    });
 
    // Notify user through notification interface
    await this.notifier.sendOrderConfirmation(user.email, order);
 
    return order;
  }
 
  private calculateTotal(items: OrderItem[]): number {
    return items.reduce(
      (sum, item) => sum + item.price * item.quantity,
      0
    );
  }
}
 
interface CreateOrderInput {
  userId: string;
  items: OrderItem[];
}
 
class DomainError extends Error {
  constructor(
    public readonly code: string,
    message: string
  ) {
    super(message);
    this.name = "DomainError";
  }
}

Infrastructure Adapters

Adapters implement the interfaces defined by the domain. They contain all the infrastructure-specific code.

tstypescript
// infrastructure/repositories/prisma-order-repository.ts
class PrismaOrderRepository implements OrderRepository {
  constructor(private readonly prisma: PrismaClient) {}
 
  async create(
    data: Omit<Order, "id" | "createdAt">
  ): Promise<Order> {
    const record = await this.prisma.order.create({
      data: {
        userId: data.userId,
        total: data.total,
        status: data.status,
        items: {
          create: data.items.map(item => ({
            productId: item.productId,
            name: item.name,
            price: item.price,
            quantity: item.quantity,
          })),
        },
      },
      include: { items: true },
    });
 
    return this.toDomain(record);
  }
 
  async findById(id: string): Promise<Order | null> {
    const record = await this.prisma.order.findUnique({
      where: { id },
      include: { items: true },
    });
    return record ? this.toDomain(record) : null;
  }
 
  async findByUserId(userId: string): Promise<Order[]> {
    const records = await this.prisma.order.findMany({
      where: { userId },
      include: { items: true },
      orderBy: { createdAt: "desc" },
    });
    return records.map(r => this.toDomain(r));
  }
 
  private toDomain(record: PrismaOrder & { items: PrismaOrderItem[] }): Order {
    return {
      id: record.id,
      userId: record.userId,
      total: record.total.toNumber(),
      status: record.status as OrderStatus,
      createdAt: record.createdAt,
      items: record.items.map(i => ({
        productId: i.productId,
        name: i.name,
        price: i.price.toNumber(),
        quantity: i.quantity,
      })),
    };
  }
}
tstypescript
// infrastructure/notifications/email-notification-service.ts
class EmailNotificationService implements NotificationService {
  constructor(
    private readonly emailClient: EmailClient,
    private readonly templateEngine: TemplateEngine
  ) {}
 
  async sendOrderConfirmation(
    email: string,
    order: Order
  ): Promise<void> {
    const html = this.templateEngine.render("order-confirmation", {
      orderId: order.id,
      total: order.total.toFixed(2),
      items: order.items,
    });
 
    await this.emailClient.send({
      to: email,
      subject: `Order #${order.id} Confirmed`,
      html,
    });
  }
}

Testing Without Infrastructure

The payoff of clean architecture is testing. Business logic tests run in milliseconds without databases, external services, or network calls.

tstypescript
describe("CreateOrderUseCase", () => {
  const mockOrderRepo: OrderRepository = {
    create: async (data) => ({
      id: "order-1",
      createdAt: new Date(),
      ...data,
    }),
    findById: async () => null,
    findByUserId: async () => [],
  };
 
  const mockUserRepo: UserRepository = {
    findById: async (id) =>
      id === "user-1"
        ? { id: "user-1", email: "test@example.com", name: "Test" }
        : null,
  };
 
  const mockNotifier: NotificationService = {
    sendOrderConfirmation: async () => {},
  };
 
  const useCase = new CreateOrderUseCase(
    mockOrderRepo,
    mockUserRepo,
    mockNotifier
  );
 
  test("creates order with correct total", async () => {
    const order = await useCase.execute({
      userId: "user-1",
      items: [
        { productId: "p1", name: "Widget", price: 10, quantity: 3 },
        { productId: "p2", name: "Gadget", price: 25, quantity: 1 },
      ],
    });
 
    expect(order.total).toBe(55);
    expect(order.status).toBe("pending");
  });
 
  test("rejects order for non-existent user", async () => {
    await expect(
      useCase.execute({
        userId: "non-existent",
        items: [{ productId: "p1", name: "X", price: 10, quantity: 1 }],
      })
    ).rejects.toThrow("User does not exist");
  });
 
  test("rejects order with too many items", async () => {
    const items = Array.from({ length: 51 }, (_, i) => ({
      productId: `p${i}`,
      name: `Item ${i}`,
      price: 1,
      quantity: 1,
    }));
 
    await expect(
      useCase.execute({ userId: "user-1", items })
    ).rejects.toThrow("Maximum 50 items per order");
  });
});

Composition Root: Wiring It Together

The composition root is where you create concrete implementations and inject them into use cases. This is the only place that knows about all the infrastructure details.

tstypescript
// infrastructure/composition-root.ts
function createOrderModule(config: AppConfig) {
  const prisma = new PrismaClient();
  const emailClient = new SendGridClient(config.sendgridApiKey);
  const templateEngine = new HandlebarsTemplateEngine();
 
  const orderRepo = new PrismaOrderRepository(prisma);
  const userRepo = new PrismaUserRepository(prisma);
  const notifier = new EmailNotificationService(emailClient, templateEngine);
 
  const createOrder = new CreateOrderUseCase(orderRepo, userRepo, notifier);
 
  return { createOrder };
}
 
// api/routes/orders.ts — thin adapter layer
function orderRoutes(app: Express, modules: ReturnType<typeof createOrderModule>) {
  app.post("/orders", async (req, res) => {
    try {
      const order = await modules.createOrder.execute({
        userId: req.user.id,
        items: req.body.items,
      });
      res.status(201).json(order);
    } catch (error) {
      if (error instanceof DomainError) {
        res.status(400).json({ code: error.code, message: error.message });
      } else {
        res.status(500).json({ message: "Internal server error" });
      }
    }
  });
}

Key Takeaways

Clean architecture is about one rule applied consistently: inner layers never depend on outer layers. Define your business entities and use cases in the domain layer, express external dependencies as interfaces (ports), and implement those interfaces in infrastructure adapters. The composition root wires everything together at application startup. The immediate payoff is testability—use cases can be tested with simple mock objects in milliseconds. The long-term payoff is adaptability—swapping a database, email provider, or web framework means writing a new adapter, not rewriting business logic. Keep the architecture pragmatic: not every application needs four layers. The minimum viable clean architecture is domain logic that depends on interfaces, not implementations.

Wilfredo Rujel

Wilfredo Rujel

Full Stack Software Engineer

Share this postX