If you’ve ever inherited a codebase where business rules live inside route handlers, validation is copy-pasted across three controllers, and switching databases would mean a six-month rewrite — this article is for you.
Clean Architecture, Domain-Driven Design (DDD), and the Dependency Inversion Principle (DIP) are usually taught as three separate topics. In practice, they’re one idea seen from three angles:
Your business logic should not know or care about your framework, your database, or your delivery mechanism.
In this post we’ll connect all three using real code from a working e-commerce backend built with NestJS, TypeScript, and Drizzle ORM — the same codebase we build from scratch in our new course.
The Problem: Business Logic That Leaks Everywhere
Most backend codebases start the same way: a controller receives a request, talks to an ORM model, and returns JSON. It works — until the rules get interesting.
- A SKU must be 3–50 alphanumeric characters.
- An order can only be modified while it’s pending.
- A payment’s status is derived from its refunds — never set directly.
When those rules live in controllers and services that import the ORM directly, every rule ends up duplicated, every test needs a database, and every infrastructure change ripples through your business logic. The dependency arrows all point the wrong way: your most important code (the rules) depends on your most volatile code (the tech stack).
All three ideas in this article exist to flip those arrows.
Clean Architecture: Dependencies Point Inward
Clean Architecture (Robert C. Martin’s formulation of hexagonal/onion architecture) organizes code into concentric layers. The only rule that matters:
Source code dependencies point inward, toward the business rules — never outward.
In our e-commerce project, every bounded context follows the same four-layer structure.
src/product/
domain/ ← entities, value objects, domain events (pure TypeScript, zero framework imports)
application/ ← use cases, ports (interfaces), sagas
infrastructure/ ← adapters: Drizzle repositories, Stripe client, Twilio client
presentation/ ← controllers, GraphQL resolvers, DTOs
- Domain knows about nothing but itself.
- Application knows about the domain.
- Infrastructure and Presentation know about the application and domain — and depend on interfaces defined there, not the other way around.
You can delete the infrastructure/ folder and the domain still compiles.
That’s the test.
DDD: What Actually Lives in the Center
Clean Architecture tells you dependencies point inward, but it doesn’t tell you what the inner circle should look like.
That’s what Domain-Driven Design is for.
DDD gives you the building blocks that make the domain layer worth protecting.
Value Objects: Make Invalid State Unrepresentable
Why have a Sku class instead of a string?
Because a string can be anything, and a Sku can only be a valid SKU.
export class Sku {
private static readonly SKU_PATTERN = /^[A-Za-z0-9-]+$/;
private static readonly MIN_LENGTH = 3;
private static readonly MAX_LENGTH = 50;
private constructor(private readonly value: string) {}
static create(value: string): Sku {
const trimmed = value.trim();
if (trimmed.length < Sku.MIN_LENGTH || trimmed.length > Sku.MAX_LENGTH) {
throw new Error(`SKU must be between ${Sku.MIN_LENGTH} and ${Sku.MAX_LENGTH} characters`);
}
if (!Sku.SKU_PATTERN.test(trimmed)) {
throw new Error("SKU must contain only alphanumeric characters and dashes");
}
return new Sku(trimmed.toUpperCase());
}
}
The constructor is private. The only way to get a Sku is through create(), which validates.
Everywhere else in the codebase, if you’re holding a Sku, it’s valid.
No defensive re-checking.
Ever.
The same pattern powers Money (no more floating-point currency bugs) and ProductName.
Aggregates: One Object Guards the Rules
An aggregate root is an entity that owns a cluster of related objects and enforces every rule about them.
Here’s the Product aggregate:
export class Product extends AggregateRoot {
private constructor(props: ProductProps) {}
static create(
name: string,
description: string,
sku: string,
price: Money,
stock: number
): Product {
Product.validateName(name);
Product.validateStock(stock);
const product = new Product({
id: new ProductId(),
sku: Sku.create(sku),
price,
isActive: true,
variants: [],
});
product.apply(new ProductCreatedEvent());
return product;
}
static reconstitute(props: ProductProps): Product {
return new Product(props);
}
}
Notice what’s not here:
- No decorators
- No ORM annotations
- No
@Injectable()
This class doesn’t know NestJS or Drizzle exist.
All state changes go through behavior methods like:
activate()adjustVariantStock()addVariant()
That means invariants such as “no duplicate variant SKUs” are enforced in exactly one place.
Dependency Inversion: The Mechanism That Makes It Work
Here’s the catch.
Your application layer needs to save products.
Saving requires a database.
Doesn’t that force the inner layers to depend on infrastructure?
No.
That’s exactly what the Dependency Inversion Principle solves.
High-level modules should not depend on low-level modules. Both should depend on abstractions.
The application layer defines a port describing what it needs.
export const PRODUCT_REPOSITORY = Symbol("PRODUCT_REPOSITORY");
export interface ProductRepository {
save(product: Product): Promise<void>;
findById(id: ProductId): Promise<Product | null>;
findBySku(sku: Sku): Promise<Product | null>;
findAll(filters?: ProductFilters): Promise<Product[]>;
delete(id: ProductId): Promise<void>;
}
The infrastructure layer provides adapters that implement it.
src/product/infrastructure/adapters/
drizzle-product.repository.ts
mongo-product.repository.ts
Swapping databases becomes a one-line change.
{
provide: PRODUCT_REPOSITORY,
useClass: DrizzleProductRepository, // or MongoProductRepository
}
At runtime, control flows:
Application → Database
But at compile time, the dependency goes:
Database Adapter → Application Interface
The arrow flips.
The database becomes a plugin.
This also makes testing dramatically easier.
Your use cases depend on ProductRepository, so unit tests can use an in-memory implementation instead of spinning up a database.
How the Three Relate
| Concept | What it answers |
|---|---|
| Clean Architecture | Where does code live? Layers with dependencies pointing inward. |
| Domain-Driven Design | What belongs in the center? Value objects, entities, aggregates, domain events. |
| Dependency Inversion | How do outer layers plug in? Inner layers define interfaces; outer layers implement them. |
Dependency Inversion is the load-bearing principle.
Without it, Clean Architecture is just folders.
With it, the whole structure holds.
Where People Go Wrong
1. Anemic Domain Models
Entities that are just getters and setters, with all logic living in service classes.
If your aggregate has no behavior, you have DDD-shaped folders—not DDD.
2. Ports Defined in Infrastructure
If the interface lives next to the implementation, nothing was inverted.
Ports belong in the application layer.
3. Letting ORM Entities Double as Domain Entities
The moment @Column() appears on your aggregate, your domain depends on your database.
Map explicitly between persistence and domain models.
4. Skipping Value Objects
Every primitive with rules—email, money, SKU, status—deserves a value object.
They’re one of the cheapest ways to prevent production bugs.
Learn to Build This From Scratch
Reading about architecture is one thing.
Building it is another.
In our new course, we build this exact e-commerce backend from an empty folder to a production-ready application with four bounded contexts:
- Product
- Customer
- Order
- Payment
You’ll learn:
- Value objects, entities, and aggregate roots
- CQRS with commands, handlers, and domain events
- Ports and adapters with swappable PostgreSQL and MongoDB repositories
- Aggregate invariants and state machines
- Cross-context orchestration with sagas
Every pattern in this article is implemented from scratch and explained step by step.
FAQ
Is Clean Architecture overkill for small projects?
For a weekend prototype, yes.
For software with real business rules that will live for years, the extra interfaces and mapping functions are a small investment for long-term flexibility and testability.
Do I need DDD to use Clean Architecture?
No.
But they complement each other perfectly.
Clean Architecture protects the center.
DDD makes the center worth protecting.
Is the Dependency Inversion Principle the same as dependency injection?
No.
Dependency Inversion is a design principle.
Dependency injection is one technique for implementing it.
Frameworks like NestJS make wiring implementations to interfaces easy.
Does this work outside NestJS?
Absolutely.
The domain and application layers are just plain TypeScript.
NestJS only appears in the outer layers—which is exactly the point.