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.

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.
// ❌ 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// ✅ 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.
// 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.
// 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,
})),
};
}
}// 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.
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.
// 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.


