Software Design Principles in Practice - SOLID, DDD, Clean Architecture

First Published:
Last Updated:

Most engineers reach a point where they have read the canonical books — Robert C. Martin on SOLID, Eric Evans on Domain-Driven Design, Vaughn Vernon on aggregates, Uncle Bob's Clean Architecture diagram pinned over their desk — and yet, when they sit down in front of a real codebase, the principles refuse to translate. The class diagrams in the book have neat little arrows. The pull request in front of you has none.

This article tries to close that gap. It walks through every principle in SOLID, the core building blocks of DDD, and the dependency rule of Clean Architecture, as concrete code you can paste into a project today. For each principle, we look at a bad implementation, the same code rewritten to honor the principle, and — equally important — the situations where the principle starts to do more harm than good. Because the second half of becoming a good designer is learning when not to apply a principle.

All examples are written in TypeScript. TypeScript is a small enough language to keep the code unobtrusive, but it has both interface and class, structural typing, and discriminated unions, which means we can express abstraction and polymorphism without leaning on a specific framework.

Beyond Self-Disruption: The Paradigm Shift Software Engineers Need in the AI Era argued that the durable skills of an engineer in an AI-augmented era are defining problems and judging output. Design principles are one of the sharpest tools we still have for both. AI can produce a working class UserService { ... } in seconds. Whether that class is one you can still change six months from now is a judgment only a human, armed with these principles, can make.

1. Why Principles Before Patterns (And When Patterns Mislead)

Before we open the SOLID box, it is worth asking what these principles are for. Robert C. Martin opens his original 2000 paper "Design Principles and Design Patterns" with a description of "software rot": code that starts off elegant and degrades over time, every change introducing a little more friction, until the cost of any modification dominates the project. The principles, he argued, are countermeasures against rot.

1.1 The Real Goal: Reducing the Cost of Change

The principles are not aesthetic preferences. They are a bet that change is the dominant cost in software, not initial implementation. A function written in five minutes will be read for years. A class shipped on Monday will be touched by people who never met its author. If you optimize for the first hour of the file's life, you make the next thousand hours worse.

Every SOLID rule, every DDD pattern, every Clean Architecture circle exists to make one specific kind of change cheaper: changing one thing without breaking three other things. When a principle stops doing that — when applying it makes the next change harder, not easier — the principle is wrong for that situation. We will return to this idea repeatedly.

1.2 Why "Principle Reading" Doesn't Translate to Code

Books are forced to teach principles in isolation. SRP gets a chapter, OCP gets a chapter, Aggregate gets a chapter. In a real codebase the principles overlap, contradict, and trade off against each other. A change that improves SRP often hurts ISP. A textbook-perfect aggregate is sometimes the wrong choice for a CRUD endpoint.

The skill the books cannot teach is which principle to reach for in this situation, and how far to push it. That skill is built by seeing many before/after pairs, and that is what the rest of this article gives you.

1.3 What This Article Covers (and Doesn't)

This article covers SOLID (five principles), DDD tactical patterns (Entity, Value Object, Aggregate), and the Clean Architecture dependency rule. It does not cover language-specific syntax debates (interface vs abstract class, decorators, mixins), framework directory layouts (Spring, Django, NestJS), or strategic DDD (Bounded Context, Context Map). Those are valuable topics; they are not the bottleneck for engineers stuck on the implementation gap.

For each principle, the structure is the same:
  1. The common misreading
  2. A bad implementation
  3. The same logic refactored to honor the principle
  4. The situations where the principle goes too far
The before/after pairs are deliberately small. Real production code will be larger and messier, but the shape of the change is what transfers. If you can recognize the shape in this article, you will recognize it in your codebase.

2. SRP — Single Responsibility with Concrete Examples

The Single Responsibility Principle is the most-quoted and most-misunderstood of the five SOLID principles. It is usually paraphrased as "a class should do one thing." That is not what Martin wrote, and it is not a useful rule, because almost any class can be argued to be doing one thing if you describe it abstractly enough.

2.1 The Common Misreading ("One Function, One Thing")

Martin's actual definition, restated in his later writing, is that a class should have one reason to change, where "reason" means "an actor who can request a change." The principle is not about counting methods or counting verbs. It is about who, in the organization or product, is allowed to ask for this code to behave differently.

Most failed SRP refactors split classes by technical layer (validation, transformation, persistence) instead of by change axis (sales asks for new fields, finance asks for new tax rules, operations asks for new export formats). Splitting by technical layer creates ceremony without reducing the cost of change. Splitting by change axis genuinely makes future changes cheaper.

2.2 Bad: A Class That Mixes Persistence, Format, and Notification

Here is a class that violates SRP in a way that is easy to overlook because it "feels reasonable":
class InvoiceService {
  async createInvoice(order: Order): Promise<void> {
    const invoice = {
      id: crypto.randomUUID(),
      orderId: order.id,
      amount: order.items.reduce((s, i) => s + i.price * i.quantity, 0),
      tax: 0,
      issuedAt: new Date(),
    };
    invoice.tax = invoice.amount * 0.10;

    await db.invoices.insert(invoice);

    const pdf = `Invoice ${invoice.id}\nAmount: ${invoice.amount}\nTax: ${invoice.tax}`;
    await s3.putObject({ Bucket: "invoices", Key: `${invoice.id}.pdf`, Body: pdf });

    await mailer.send({
      to: order.customerEmail,
      subject: `Your invoice ${invoice.id}`,
      body: `Your invoice for order ${order.id} is attached.`,
    });
  }
}
This class has at least four reasons to change: tax law (tax calculation), accounting policy (database schema), brand guidelines (PDF format), and customer communication policy (email content). Each of those changes touches the same method. Each change risks breaking unrelated behavior. Tests for this class either become integration tests or mock everything.

2.3 Good: Separating Reasons to Change

We split by change axis, not by technical noun. Each piece can be changed without recompiling, retesting, or rereading the others:
class TaxCalculator {
  calculate(amount: number): number {
    return amount * 0.10;
  }
}

class InvoiceRepository {
  async save(invoice: Invoice): Promise<void> {
    await db.invoices.insert(invoice);
  }
}

class InvoicePdfRenderer {
  render(invoice: Invoice): string {
    return `Invoice ${invoice.id}\nAmount: ${invoice.amount}\nTax: ${invoice.tax}`;
  }
}

class InvoiceNotifier {
  constructor(private mailer: Mailer) {}
  async notify(invoice: Invoice, customerEmail: string): Promise<void> {
    await this.mailer.send({
      to: customerEmail,
      subject: `Your invoice ${invoice.id}`,
      body: `Your invoice for order ${invoice.orderId} is attached.`,
    });
  }
}

class CreateInvoiceUseCase {
  constructor(
    private tax: TaxCalculator,
    private repo: InvoiceRepository,
    private renderer: InvoicePdfRenderer,
    private storage: ObjectStorage,
    private notifier: InvoiceNotifier,
  ) {}

  async execute(order: Order): Promise<Invoice> {
    const amount = order.items.reduce((s, i) => s + i.price * i.quantity, 0);
    const invoice: Invoice = {
      id: crypto.randomUUID(),
      orderId: order.id,
      amount,
      tax: this.tax.calculate(amount),
      issuedAt: new Date(),
    };
    await this.repo.save(invoice);
    await this.storage.put(`${invoice.id}.pdf`, this.renderer.render(invoice));
    await this.notifier.notify(invoice, order.customerEmail);
    return invoice;
  }
}
CreateInvoiceUseCase orchestrates, but no longer knows how tax is computed, how the invoice is stored, what the PDF looks like, or how the customer is told. Each collaborator can be changed in isolation, with isolated tests.

2.4 The "Axis of Change" Heuristic

When you suspect SRP is being violated, the cheapest test is this: list the last five non-trivial changes to the class. If they came from different stakeholders for different reasons, the class has too many responsibilities. If they all came from the same stakeholder for the same reason, the class is fine even if it is long.

This heuristic gives you a defensible answer in code review, where "should we split this?" otherwise dissolves into taste.

2.5 When SRP Goes Too Far

SRP, taken to extremes, produces a fog of two-line classes. Each one is "SRP-correct" in isolation, but tracing a request through the system requires opening a dozen files. The cost of reading has gone up; the cost of changing has gone up because changes now span many files.

The reflex of "every method on its own class, injected via the constructor" is the SRP equivalent of cargo cult. When in doubt, prefer one class with cohesive methods over five classes that always change together. We revisit this in Section 9.

3. OCP — Open/Closed in Modern Languages

Bertrand Meyer's original Open/Closed Principle (1988) was about inheritance: you should be able to extend a module's behavior by subclassing, without modifying the existing class. Martin reformulated it in 1996 around abstractions: a module should be open for extension and closed for modification, achieved through polymorphism rather than inheritance specifically.

3.1 What "Open for Extension, Closed for Modification" Actually Means

The practical meaning is: when a new variant of behavior arrives, you should be able to add it by writing new code, not by modifying existing code that already works. The reason is regression: every time you reopen a working file, you risk breaking something. If new behavior is added by adding a new file, the blast radius is bounded by that file.

OCP is most clearly violated by long switch or if-else chains that branch on a type tag and need a new case for every new variant.

3.2 Bad: switch / if-else Branching on Type

type Shape =
  | { kind: "circle"; radius: number }
  | { kind: "rectangle"; width: number; height: number };

function area(shape: Shape): number {
  if (shape.kind === "circle") {
    return Math.PI * shape.radius ** 2;
  } else if (shape.kind === "rectangle") {
    return shape.width * shape.height;
  }
  throw new Error("unknown shape");
}

function perimeter(shape: Shape): number {
  if (shape.kind === "circle") {
    return 2 * Math.PI * shape.radius;
  } else if (shape.kind === "rectangle") {
    return 2 * (shape.width + shape.height);
  }
  throw new Error("unknown shape");
}
Adding a Triangle requires editing both functions, plus the type alias, plus every other place a switch on kind exists. The change is mechanical but spread across the codebase.

3.3 Good: Polymorphism via Strategy or Discriminated Union

The polymorphic version pushes per-variant code into per-variant files:
interface Shape {
  area(): number;
  perimeter(): number;
}

class Circle implements Shape {
  constructor(private radius: number) {}
  area(): number { return Math.PI * this.radius ** 2; }
  perimeter(): number { return 2 * Math.PI * this.radius; }
}

class Rectangle implements Shape {
  constructor(private width: number, private height: number) {}
  area(): number { return this.width * this.height; }
  perimeter(): number { return 2 * (this.width + this.height); }
}

// Adding Triangle requires no edits to Circle, Rectangle, or any caller:
class Triangle implements Shape {
  constructor(private a: number, private b: number, private c: number) {}
  area(): number {
    const s = (this.a + this.b + this.c) / 2;
    return Math.sqrt(s * (s - this.a) * (s - this.b) * (s - this.c));
  }
  perimeter(): number { return this.a + this.b + this.c; }
}

3.4 OCP in TypeScript without Inheritance Hierarchies

Inheritance is not the only way to satisfy OCP. A registry of strategies — a map of variant key to handler function — gives the same property in a more functional style:
type AreaCalculator = (shape: any) => number;

const areaCalculators: Record<string, AreaCalculator> = {
  circle: (s) => Math.PI * s.radius ** 2,
  rectangle: (s) => s.width * s.height,
};

function area(shape: { kind: string }): number {
  const calc = areaCalculators[shape.kind];
  if (!calc) throw new Error(`unknown shape: ${shape.kind}`);
  return calc(shape);
}

// Adding a triangle: no edits to existing functions
areaCalculators.triangle = (s) => {
  const semi = (s.a + s.b + s.c) / 2;
  return Math.sqrt(semi * (semi - s.a) * (semi - s.b) * (semi - s.c));
};
This style is common in plugin architectures and in code that is configured at runtime. It satisfies OCP — adding a variant is additive — without committing to a class hierarchy.

The strategy parameter is typed as any on purpose, and the trade-off is worth naming. Tightening it — for instance, with a generic constrained to a discriminated union of shapes — would force every new variant to extend that union and modify the file the registry was meant to keep closed. The runtime extensibility of a registry and the compile-time exhaustiveness of a union pull against each other: pick the registry when variants are loaded at runtime (plugins, configuration), and pick the union when the variants are known at build time (where the compiler's exhaustiveness check is the more valuable property).

3.5 When OCP Adds Friction Without Payoff

Premature OCP is a trap. If you have two variants and no concrete plan for a third, building the polymorphic structure now is speculative — and the speculation is often wrong. The third variant, when it arrives, may not fit the abstraction you guessed at, and you will rewrite the abstraction anyway.

A pragmatic rule: write the switch the first two times. The third time, refactor to polymorphism with the third variant in hand, using all three to triangulate the right abstraction. This is the Rule of Three we revisit in Section 10.

4. LSP — Liskov in Practice

The Liskov Substitution Principle, due to Barbara Liskov (1987), says that objects of a subtype should be substitutable for objects of their supertype without altering the correctness of the program. In practice the principle is violated whenever a subclass throws on a method the parent supports, weakens a postcondition, or strengthens a precondition.

4.1 The Square-Rectangle Trap and Why It's Real

The textbook example — class Square extends Rectangle — sounds contrived until you encounter a real one in production. The pattern is always the same: a subclass that seems like a specialization at the math level breaks the contract at the behavior level. Square cannot have its width and height set independently, but Rectangle.setWidth(w) promises that getWidth() === w regardless of getHeight(). The promise breaks the moment a Rectangle reference points to a Square.

4.2 Bad: Subclass That Strengthens Preconditions

class Rectangle {
  constructor(protected width: number, protected height: number) {}
  setWidth(w: number): void { this.width = w; }
  setHeight(h: number): void { this.height = h; }
  area(): number { return this.width * this.height; }
}

class Square extends Rectangle {
  constructor(side: number) { super(side, side); }
  setWidth(w: number): void { this.width = w; this.height = w; }
  setHeight(h: number): void { this.height = h; this.width = h; }
}

function resizeAndCheck(r: Rectangle): void {
  r.setWidth(5);
  r.setHeight(4);
  // Caller expects 20. With a Square at runtime, gets 16.
  console.log(r.area());
}
The caller's assumption — that setWidth does not affect setHeight — is part of Rectangle's implicit contract. Square strengthens the precondition (width must equal height), violating LSP.

4.3 Good: Modeling the Real Invariant

If Square cannot be substituted for Rectangle, the type hierarchy is wrong. Model both as separate types, both implementing a smaller, honest interface:
interface Polygon {
  area(): number;
}

class Rectangle implements Polygon {
  constructor(private width: number, private height: number) {}
  withWidth(w: number): Rectangle { return new Rectangle(w, this.height); }
  withHeight(h: number): Rectangle { return new Rectangle(this.width, h); }
  area(): number { return this.width * this.height; }
}

class Square implements Polygon {
  constructor(private side: number) {}
  withSide(s: number): Square { return new Square(s); }
  area(): number { return this.side * this.side; }
}
Two improvements stack here: each type now exposes only the operations its invariants allow, and we have switched from mutating setters to value-returning with* methods, which sidesteps the subclassing trap entirely.

4.4 Composition Over Inheritance as the LSP-Safe Default

Most LSP violations in modern codebases come from inheritance reaching for code reuse rather than for substitutability. If you find yourself overriding a method to throw UnsupportedOperationException, or to no-op, the inheritance is the wrong tool. Composition — "Square has a side length, Rectangle has a width and height" — avoids the substitutability question entirely because there is no parent type to substitute for.

A useful rule: inherit only when the subclass is genuinely a behavioral specialization of the parent, where every parent method makes sense unchanged on the child. Otherwise, compose.

4.5 LSP for Stateful Services (Beyond Classes)

LSP applies beyond OOP class hierarchies. Anywhere a contract is implicit — an HTTP API, a message handler, a function passed as a callback — substituting a "compatible" implementation that strengthens preconditions or weakens postconditions is an LSP violation. A common production case: a payment provider implementation that silently retries idempotently, replaced by one that doesn't. Callers that relied on the original behavior break.

The defense is to write the contract down — typed interfaces, postconditions in tests, idempotency guarantees as part of the spec — so that "compatible" has a definition you can check.

5. ISP — Interface Segregation

The Interface Segregation Principle says that clients should not be forced to depend on methods they do not use. In Martin's original framing the concern was multiple-inheritance languages, where a fat interface forced unwilling implementations. In modern languages with structural typing or single-inheritance plus interfaces, the consequence is different but the principle still bites.

5.1 Why Fat Interfaces Hurt Even Without Multiple Inheritance

When an interface has fifteen methods and a caller uses two, every change to one of the unused thirteen ripples into the caller's compile graph, mocking burden, and conceptual surface area. Test doubles bloat. Type-narrowing becomes harder. The interface starts to behave like a dumping ground rather than a contract.

The deeper cost: the interface no longer tells you what the caller needs. An interface should describe the role a collaborator plays for a specific caller. A fifteen-method interface describes nothing in particular.

5.2 Bad: A Repository Interface with 12 Methods

interface UserRepository {
  findById(id: string): Promise<User | null>;
  findByEmail(email: string): Promise<User | null>;
  findAll(): Promise<User[]>;
  findByRole(role: string): Promise<User[]>;
  count(): Promise<number>;
  insert(user: User): Promise<void>;
  update(user: User): Promise<void>;
  delete(id: string): Promise<void>;
  softDelete(id: string): Promise<void>;
  restore(id: string): Promise<void>;
  bulkInsert(users: User[]): Promise<void>;
  exportToCsv(): Promise<string>;
}

class LoginUseCase {
  constructor(private users: UserRepository) {}
  async execute(email: string, password: string): Promise<Session> {
    const user = await this.users.findByEmail(email);
    if (!user || !verify(password, user.passwordHash)) {
      throw new InvalidCredentials();
    }
    return createSession(user.id);
  }
}
LoginUseCase uses one method but depends on the entire surface of UserRepository. Test doubles must stub twelve methods. A breaking change to exportToCsv forces a recompile of the login flow. Worst of all, the type does not communicate that login only reads by email — that intent is buried.

5.3 Good: Role Interfaces Sized to the Caller

The fix is "role interfaces": small interfaces named after the role the collaborator plays for this specific caller. The repository implementation can satisfy many of them at once via TypeScript's structural typing.
interface UserFinderByEmail {
  findByEmail(email: string): Promise<User | null>;
}

interface UserMutator {
  insert(user: User): Promise<void>;
  update(user: User): Promise<void>;
}

interface UserExporter {
  exportToCsv(): Promise<string>;
}

class LoginUseCase {
  constructor(private users: UserFinderByEmail) {}
  async execute(email: string, password: string): Promise<Session> {
    const user = await this.users.findByEmail(email);
    if (!user || !verify(password, user.passwordHash)) {
      throw new InvalidCredentials();
    }
    return createSession(user.id);
  }
}

// One concrete class can satisfy all of them at once:
class PostgresUserRepository
  implements UserFinderByEmail, UserMutator, UserExporter {
  async findByEmail(email: string): Promise<User | null> { /* ... */ return null; }
  async insert(user: User): Promise<void> { /* ... */ }
  async update(user: User): Promise<void> { /* ... */ }
  async exportToCsv(): Promise<string> { /* ... */ return ""; }
}
Now each caller's signature documents exactly what it needs. Tests stub a single method. A change to CSV export does not touch the login path.

5.4 The "Caller Determines the Interface" Rule

Whose job is it to define the interface — the implementer or the caller? ISP, taken seriously, says the caller. The interface lives next to the code that needs it, named after that need. The implementer comes along and says "yes, I can satisfy that role." This inverts the usual direction (where a service exposes its API and callers wedge themselves into it), and it is the same inversion that DIP (Section 6) generalizes.

5.5 When Splitting Interfaces Becomes Theatre

ISP also has an over-application failure mode. Defining UserFinderById, UserFinderByEmail, UserFinderByRole, UserCounter, UserExistenceChecker, all separately, when in practice they always come together, produces ceremony without value. The right grain is "the smallest set of operations that callers actually take as a unit." If two methods are always used together by every caller, the interface that contains both is fine.

The ISP question to ask in code review is not "does this interface have only one method?" but "does any real caller use only a subset of these methods?" If yes, segregate. If every caller uses all of them, leave them alone.

6. DIP — Dependency Inversion

The Dependency Inversion Principle says that high-level modules should not depend on low-level modules; both should depend on abstractions. And: abstractions should not depend on details; details should depend on abstractions. The principle is named for the direction of the dependency arrow at runtime versus compile time, and it is the principle most often half-applied.

6.1 Inversion of Direction, Not Just Container Magic

Many engineers learn DIP through a DI framework — Spring, NestJS, Guice — and conclude that DIP is "letting the container wire constructors." That is the mechanism; the principle is older and language-agnostic. DIP is about which way the import statement points.

Without DIP, the application core (use cases, domain logic) imports the database driver, the HTTP client, the email service. Every change to those tools is felt by the core. With DIP, the core defines the interface it needs (UserRepository, EmailSender), and the infrastructure code imports the core to satisfy that interface. The compile-time import points the opposite way of the runtime call.

6.2 Bad: High-Level Module Imports a Concrete DB Driver

// src/usecases/RegisterUser.ts
import { Pool } from "pg"; // <-- high-level imports low-level

const pool = new Pool({ connectionString: process.env.DATABASE_URL });

export class RegisterUserUseCase {
  async execute(email: string, password: string): Promise<string> {
    const id = crypto.randomUUID();
    const hash = await bcrypt.hash(password, 10);
    await pool.query(
      "INSERT INTO users (id, email, password_hash) VALUES ($1, $2, $3)",
      [id, email, hash],
    );
    return id;
  }
}
The use case knows about Postgres, knows the schema, knows the SQL. Switching to DynamoDB, or testing without a real database, requires editing this file. The file mixes domain rules ("a user has an email and a hashed password") with infrastructure concerns ("which INSERT statement to run").

6.3 Good: High-Level Module Owns the Abstraction

The use case defines what it needs. Infrastructure implements it. The arrow at compile time points from infrastructure → core, not core → infrastructure.
// src/usecases/RegisterUser.ts (high-level — depends on no infrastructure)
export interface UserRepository {
  insert(user: { id: string; email: string; passwordHash: string }): Promise<void>;
  existsByEmail(email: string): Promise<boolean>;
}

export interface PasswordHasher {
  hash(plain: string): Promise<string>;
}

export class RegisterUserUseCase {
  constructor(
    private users: UserRepository,
    private hasher: PasswordHasher,
  ) {}

  async execute(email: string, password: string): Promise<string> {
    if (await this.users.existsByEmail(email)) {
      throw new EmailAlreadyRegistered();
    }
    const id = crypto.randomUUID();
    const passwordHash = await this.hasher.hash(password);
    await this.users.insert({ id, email, passwordHash });
    return id;
  }
}
// src/infrastructure/PostgresUserRepository.ts (low-level — depends on core)
import { Pool } from "pg";
import type { UserRepository } from "../usecases/RegisterUser";

export class PostgresUserRepository implements UserRepository {
  constructor(private pool: Pool) {}
  async insert(user: { id: string; email: string; passwordHash: string }) {
    await this.pool.query(
      "INSERT INTO users (id, email, password_hash) VALUES ($1, $2, $3)",
      [user.id, user.email, user.passwordHash],
    );
  }
  async existsByEmail(email: string): Promise<boolean> {
    const r = await this.pool.query("SELECT 1 FROM users WHERE email = $1", [email]);
    return (r.rowCount ?? 0) > 0;
  }
}
Two effects: the use case is testable without any database, and Postgres can be replaced with DynamoDB by writing one new file (a DynamoUserRepository) with no edits to the core. This is the dependency rule at the file-import level.

Bad and good dependency graphs side by side: high-level module depending on a concrete database driver versus the same module depending on an abstraction it owns
Bad and good dependency graphs side by side: high-level module depending on a concrete database driver versus the same module depending on an abstraction it owns

6.4 DIP Without DI Frameworks

You can apply DIP without Spring, without NestJS, without any container. A composition root — typically main.ts or an explicit wiring.ts — instantiates the concrete classes and hands them to the use cases. No reflection, no decorators, no XML.
// src/main.ts
import { Pool } from "pg";
import { PostgresUserRepository } from "./infrastructure/PostgresUserRepository";
import { BcryptPasswordHasher } from "./infrastructure/BcryptPasswordHasher";
import { RegisterUserUseCase } from "./usecases/RegisterUser";

const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const userRepo = new PostgresUserRepository(pool);
const hasher = new BcryptPasswordHasher();
const registerUser = new RegisterUserUseCase(userRepo, hasher);

// `registerUser` is now ready to be wired into an HTTP route, a CLI, or a test.
This is enough DIP for most applications. The container only earns its weight in projects with hundreds of services and complex lifecycles.

6.5 The Limits: When Direct Dependency Is Honest

DIP has a cost. Every abstraction is a layer of indirection, an extra file, an extra mock in tests. For a small script that talks to one database forever, inverting that dependency adds rope without adding flexibility. For a function that wraps console.log, hiding console behind an interface is theatre.

The rule of thumb: invert the dependency when the low-level module is a plausible substitute target — different database, different cloud, different environment in tests. If the answer to "what would the alternative implementation be?" is "there isn't one," you do not need the interface.

7. DDD Building Blocks (Entity, Value Object, Aggregate)

Eric Evans's Domain-Driven Design (2003) introduced the tactical patterns most engineers recognize from the book's classification: Entity, Value Object, Aggregate. The patterns answer different questions, and many production codebases blur them, which is why understanding them in their purest form is the precondition for blurring them well.

7.1 Entity: Identity Beats Attributes

An Entity is an object whose identity persists across changes to its attributes. Two entities with identical attributes are still different entities if their identifiers differ; an entity whose attributes change over time is still the same entity. The canonical example is a User: rename the user, change their email, change their role, and they are still the same user, identified by an immutable userId.
class User {
  constructor(
    public readonly id: UserId,
    public name: string,
    public email: Email,
  ) {}

  equals(other: User): boolean {
    return this.id.equals(other.id);
  }
}
Equality is by identity, not by attribute. This is the Evans test for whether something is an Entity: if you would care that the same one persists across a name change, it is an Entity.

7.2 Value Object: Immutable, Equality by Value

A Value Object has no independent identity; it is fully described by its attributes, and two value objects with identical attributes are interchangeable. Money, Email, DateRange, GeographicCoordinate — these are values, not entities. The test is the inverse: if you would not care which one was used as long as the attributes match, it is a Value Object.

Value Objects are immutable. Mutating them is a category error.
class Money {
  private constructor(
    public readonly amount: number,
    public readonly currency: string,
  ) {}

  static of(amount: number, currency: string): Money {
    if (!Number.isFinite(amount)) throw new Error("amount must be finite");
    if (!/^[A-Z]{3}$/.test(currency)) throw new Error("currency must be ISO 4217");
    return new Money(amount, currency);
  }

  add(other: Money): Money {
    if (this.currency !== other.currency) {
      throw new Error("cannot add different currencies");
    }
    return Money.of(this.amount + other.amount, this.currency);
  }

  multiply(n: number): Money {
    if (!Number.isFinite(n)) throw new Error("multiplier must be finite");
    return Money.of(this.amount * n, this.currency);
  }

  equals(other: Money): boolean {
    return this.amount === other.amount && this.currency === other.currency;
  }
}
Three properties to notice. First, the constructor is private; you must go through Money.of, which validates. Second, add returns a new Money; nothing mutates. Third, equality is by value, not by reference. These three together make Value Objects compose safely — they cannot be in an invalid state, and they cannot be silently changed by a distant caller.

Whenever you find a primitive string or number that travels through your codebase carrying meaning ("this is the user's email", "this is the price in JPY"), you are looking at a missed Value Object. The principle is sometimes called "Primitive Obsession" when it is absent.

7.3 Aggregate: The Consistency Boundary

An Aggregate is a cluster of Entities and Value Objects that are treated as a single unit for the purpose of data changes. One Entity in the cluster is the Aggregate Root. External code may hold references to the root only; references to internals are passed out for use within a single operation only.

The reason aggregates exist is invariants. A business rule like "the total of an order's line items must equal its declared total" cannot be enforced if any caller can modify any line item directly. Wrap the rule's data in an aggregate, route all modifications through the root, and the root enforces the invariant.

Vaughn Vernon's Effective Aggregate Design (2011) distilled four rules of thumb that the DDD community has converged on:
  1. Model true invariants in consistency boundaries. If an invariant must hold within a single transaction, the data it spans is one aggregate.
  2. Design small aggregates. Large aggregates create lock contention and load problems. Smaller is almost always better.
  3. Reference other aggregates by identity. Within an aggregate, hold references to entities and value objects directly. Across aggregate boundaries, hold only the other aggregate's ID.
  4. Use eventual consistency outside the boundary. If two aggregates need to stay in sync, do it through asynchronous events — do not extend one transaction across both.

7.4 Bad: Anemic Domain Model with Setters Everywhere

A common antipattern is the "anemic domain model": entities reduced to data bags with public setters, and all behavior moved to a "service" class. The data and the rules that govern it have been separated, and the rules are easy to bypass.
class Order {
  id!: string;
  status!: "draft" | "submitted" | "paid" | "cancelled";
  lines!: { productId: string; quantity: number; unitPrice: number }[];
  total!: number;
}

class OrderService {
  submit(order: Order) {
    order.status = "submitted";
  }

  addLine(order: Order, productId: string, quantity: number, unitPrice: number) {
    order.lines.push({ productId, quantity, unitPrice });
    order.total += quantity * unitPrice;
  }
}

// Anywhere in the codebase, anyone can write:
order.status = "paid";          // bypasses any state-transition rules
order.total = 0;                // breaks the total/lines invariant
order.lines.push({ /* ... */ }); // recomputes total nowhere
The invariants — total equals sum of lines, status follows a valid transition — exist only as comments in someone's head. The Order class does not enforce them. Any developer, any new feature, any AI-generated patch, can violate them silently.

7.5 Good: Behavior Lives with the Data It Protects

Move the rules into the aggregate root. Make the fields private. Expose intentional operations.
type OrderStatus = "draft" | "submitted" | "paid" | "cancelled";

class OrderLine {
  constructor(
    public readonly productId: string,
    public readonly quantity: number,
    public readonly unitPrice: Money,
  ) {
    if (quantity <= 0) throw new Error("quantity must be positive");
  }
  subtotal(): Money {
    return this.unitPrice.multiply(this.quantity);
  }
}

class Order {
  private constructor(
    public readonly id: OrderId,
    private status: OrderStatus,
    private lines: OrderLine[],
  ) {}

  static draft(id: OrderId): Order {
    return new Order(id, "draft", []);
  }

  addLine(line: OrderLine): void {
    if (this.status !== "draft") {
      throw new Error("cannot modify a submitted order");
    }
    this.lines.push(line);
  }

  submit(): void {
    if (this.status !== "draft") {
      throw new Error(`cannot submit an order in status ${this.status}`);
    }
    if (this.lines.length === 0) {
      throw new Error("cannot submit an order without lines");
    }
    this.status = "submitted";
  }

  total(): Money {
    return this.lines.reduce(
      (acc, l) => acc.add(l.subtotal()),
      Money.of(0, "JPY"),
    );
  }

  // No public setter for status. No public mutation of lines.
  // The aggregate root protects every invariant the business cares about.
}
Now Order cannot be in an invalid state without going through code that would have to be edited intentionally to break a rule. Reviewers and AI tools have a much easier time spotting suspicious changes.

7.6 The "Aggregate Per Transaction" Rule and Its Trade-offs

Vernon's rule that a single transaction should modify at most one aggregate is uncomfortable until you understand why. Two aggregates in one transaction create distributed-locking concerns even on a single database, and they entangle two consistency boundaries that you spent effort separating. Eventual consistency between aggregates — orchestrated by domain events — is the correct default.

The rule is occasionally bent. Vernon himself notes that user-aggregate affinity — when only one user touches a set of aggregates at once — sometimes makes a multi-aggregate transaction safe in practice. As with every principle in this article, the discipline is not "never bend" but "know what you are bending and why."

8. Clean Architecture Layering

In 2012, Robert C. Martin published The Clean Architecture, a synthesis of Hexagonal (Cockburn), Onion (Palermo), and DCI architectures into a single concentric-circle diagram with one explicit rule: source code dependencies point only inward. The diagram has been redrawn in countless blog posts, but the rule is simpler than the layers.

8.1 The Concentric Circles Reread

Clean Architecture concentric circles with the Dependency Rule arrow pointing inward from Frameworks and Drivers toward Entities
Clean Architecture concentric circles with the Dependency Rule arrow pointing inward from Frameworks and Drivers toward Entities
The four layers, from innermost to outermost:
  • Entities — Enterprise-wide business rules. Things that would be true even if there were no software. Money.add belongs here.
  • Use Cases — Application-specific business rules. The orchestration of entities to achieve one user-visible operation. RegisterUser, PlaceOrder.
  • Interface Adapters — Translators between the use case world and the framework/IO world. Controllers, presenters, ORM mappers.
  • Frameworks and Drivers — Web framework, database driver, message broker, file system, external HTTP clients. The volatile edge of the system.
The number of circles is not sacred. Three is common. Five is possible. The Dependency Rule is what makes the layering useful, not the diagram.

8.2 The Dependency Rule in One Sentence

"Source code dependencies can only point inwards." The name of any class, function, or variable defined in an outer circle must not appear in code in an inner circle. A use case never imports a Postgres client. An entity never imports an HTTP framework. The compile-time graph and the runtime graph point opposite ways: data flows outward at runtime, but the file-level import statements always point inward.

DDD layered rendering of the dependency rule: Presentation and Infrastructure on the outside, Application in the middle, Domain at the core, with imports pointing inward
DDD layered rendering of the dependency rule: Presentation and Infrastructure on the outside, Application in the middle, Domain at the core, with imports pointing inward
The same rule rendered as horizontal layers maps more directly to project directory structure. Whether you draw circles or rectangles, the arrows point inward.

8.3 Bad: Use Case Imports the ORM Entity

A common mistake — especially in projects bootstrapped from a framework template — is to let the ORM-generated entity class leak into use cases.
// src/usecases/PlaceOrder.ts
import { OrderEntity } from "../infrastructure/orm/OrderEntity";  // <-- ORM type leaking in
import { dataSource } from "../infrastructure/orm/dataSource";

export class PlaceOrderUseCase {
  async execute(customerId: string, items: { productId: string; qty: number }[]) {
    const repo = dataSource.getRepository(OrderEntity);
    const order = new OrderEntity();
    order.customerId = customerId;
    order.items = items.map(/* ... */);
    order.status = "submitted";
    await repo.save(order);
  }
}
PlaceOrderUseCase lives in the application layer, but it imports an infrastructure-layer class and depends on the ORM's lifecycle methods. Any change in the ORM (schema annotations, decorator syntax, lazy-loading semantics) drags the use case along.

8.4 Good: Use Case Speaks in Domain Terms

The use case manipulates a domain Order (the aggregate from Section 7.5). An adapter in the infrastructure layer is responsible for turning that domain object into rows.
// src/usecases/PlaceOrder.ts
import { Order, OrderId } from "../domain/Order";
import { OrderRepository } from "../domain/OrderRepository";

export class PlaceOrderUseCase {
  constructor(private orders: OrderRepository) {}

  async execute(input: PlaceOrderInput): Promise<OrderId> {
    const order = Order.draft(OrderId.generate());
    for (const item of input.items) {
      order.addLine(/* ... */);
    }
    order.submit();
    await this.orders.save(order);
    return order.id;
  }
}
// src/infrastructure/orm/TypeOrmOrderRepository.ts
import { Order } from "../../domain/Order";
import { OrderRepository } from "../../domain/OrderRepository";
import { OrderEntity } from "./OrderEntity";

export class TypeOrmOrderRepository implements OrderRepository {
  // ... ORM-specific code lives entirely here.
  async save(order: Order): Promise<void> {
    const row = OrderEntity.fromDomain(order);
    await this.repo.save(row);
  }
}
The use case file does not contain the words OrderEntity or dataSource. Replace TypeORM with Prisma, with raw SQL, with DynamoDB — the use case is not aware.

8.5 Mapping Boundaries: DTO, Domain, ORM

In a Clean-Architecture-shaped codebase, three object representations typically coexist:
  • DTOs are flat, serializable, framework-aware. They enter and leave the system.
  • Domain objects are rich, behavioral, framework-blind. They live only in the use case and below.
  • ORM entities are flat, persistence-aware. They live only in the infrastructure layer.
Each boundary is a translation. The translations look like duplication, and they are — that is the point. The duplication buys independence between layers, so that changing the database schema does not require changing the HTTP contract, and vice versa.

8.6 What Clean Architecture Doesn't Mandate

Clean Architecture does not mandate four layers, four directories, or four-letter naming conventions. It does not mandate "one class per file" or "one interface per implementation." It does not mandate hexagonal port-adapter terminology. It mandates one rule — dependencies point inward — and lets you choose how many layers and what you name them.

Many production "Clean Architecture" projects ship with so many directories and so many ceremonial classes that the dependency rule is buried under the boilerplate. We turn to that next.

9. Anti-Pattern: Over-Application (Cargo Cult Architecture)

Every principle in this article has a failure mode where applying it harder makes things worse. The collective name for these failure modes is cargo cult architecture: building the visible structure of a principled codebase without the underlying decisions that justify the structure. The forms are recognizable across teams and stacks.

9.1 FactoryFactory and the Ceremony Tax

In a healthy codebase, a Factory exists when object construction is genuinely complex — multiple steps, conditional wiring, validation that does not belong in a constructor. A FactoryFactory is a sign that someone has forgotten why factories exist and is reaching for the pattern out of habit. Every layer of construction indirection has to be read, navigated, and held in working memory for every change. After two layers, the cognitive cost outweighs the flexibility.

9.2 The "One Interface Per Class" Reflex

A pattern visible in many "enterprise" codebases: UserService is implemented by UserServiceImpl, with the interface holding every method of the implementation, and the only implementation in production. This satisfies neither ISP (the interface is just as fat as the class) nor DIP (the interface is not owned by a higher-level module — it is owned by the implementation, which means the dependency arrow has not actually flipped).

The honest version is to write the class without the interface. When a second implementation is genuinely needed — a fake for tests, a different infrastructure variant — extract the interface then, sized to the calling site, named after the role.

9.3 Onion Layers in a CRUD App

A small CRUD service that maps HTTP routes to database tables does not need four layers. It does not need a domain layer with rich aggregates if there are no invariants beyond "this column is a positive integer." A two-layer structure — request handlers + a thin data-access module — is correct for the problem.

The overengineered version — controllers calling use cases calling domain services calling repositories calling DAOs — multiplies translation costs without buying anything. This article cares about Clean Architecture; it cares less about cargo-culting it into projects that do not need it.

9.4 Premature DDD in a Tactical Tool

DDD's tactical patterns assume there is a domain worth modeling — invariants, ubiquitous language, business stakeholders. A script that exports CSVs every Sunday does not have a domain. Building it with aggregates, value objects, and domain events is theatre, and the theatre slows down every future edit.

A useful test: can you state the invariant the aggregate enforces in one sentence, without using software vocabulary? If you cannot, you do not have an aggregate. You have a struct.

9.5 Symptoms of Over-Engineered Code

Symptoms that the principles are being over-applied:
  • Tracing one user request opens more than seven files.
  • Every test requires a setup helper that builds a graph of mocks.
  • Adding a field to a request requires editing five DTOs and three mappers.
  • The team's review comments are about layering and naming, not about behavior.
  • New hires take weeks to make their first non-trivial change.
When the code's form is taking attention away from the behavior, the principles have stopped serving and started ruling. That is the moment to bend them.

10. When to Bend the Rules

Every principle in this article was written by someone arguing against a specific pain. Martin's SOLID papers were written against the pain of brittle inheritance hierarchies and untestable singletons. Evans wrote DDD against the pain of code that drifted away from the language the business actually used. None of the original authors believed the principles applied to all software in all situations.

Dan North's CUPID essay (2021) goes further: he argues that the very framing of "principles" — bright-line rules you either follow or violate — pushes teams toward enforcement rather than judgment. He proposes properties (Composable, Unix-philosophy, Predictable, Idiomatic, Domain-based) as a centred-set alternative: directions to move toward, where context decides how far you go. Whether you adopt CUPID or stay with SOLID, the meta-lesson is the same: the principles are means, and the end is code that is cheap to change.

10.1 Optimize for Reading, Not for Theory

When two designs are both defensible, prefer the one that reads better. Code is read far more than it is written, and "principle-correct" is not the same as "easy to follow." A switch statement that fits on one screen often beats a strategy hierarchy spread across six files, even if the hierarchy is closer to OCP-perfection.

10.2 The Rule of Three Before Abstracting

The Rule of Three, attributed to Don Roberts and popularized in Refactoring by Martin Fowler, says: write something concretely the first time, copy it the second time, and only on the third occurrence consider extracting an abstraction. Two data points are not enough to triangulate the right abstraction. Three is usually enough.

The corollary: a single occurrence of a class with no subclass and no test double does not need an interface. Wait until reality gives you a reason.

10.3 Boundary First, Internal Structure Later

When the design is uncertain, invest in the boundaries — the contracts at the edges of the module — and let the internal structure stay simple. A clean external interface buys the freedom to refactor the internals later. An over-decomposed internal structure with a leaky boundary buys the opposite.

This is the half of Clean Architecture that survives even in projects that ignore the layered diagram: keep the seams between subsystems clean, even if the subsystems themselves are CRUD-shaped.

10.4 The Stable Core, Volatile Edges Heuristic

Push the stable, slow-changing rules to the center. Push the volatile, fast-changing details to the edges. The center should be the kind of code where merge conflicts are rare; the edges should be the kind of code where merge conflicts are routine. If your domain layer churns more than your controllers do, the architecture is upside-down.

10.5 A Decision Checklist for Reviewers

When a pull request reaches you and the principle question arises ("should this be split?", "does this violate OCP?", "is this an aggregate?"), the following five questions resolve most disagreements:
  1. What change becomes cheaper because of this design? If you cannot name one, the design is not earning its complexity.
  2. Has that kind of change actually happened, or is it speculative? Speculative complexity is the most common mistake.
  3. Does the code read better, or worse, than the simpler alternative? Reading cost is real cost.
  4. Could a new team member find their way through this in a week? If not, the code is optimized for the author, not for the team.
  5. What is the smallest change that would still address the underlying pain? Often this is the right answer.
The first time you walk through these on a real PR, the principles will start to feel less like rules to memorize and more like tools to pick up and put down. That is the goal. Once you can decide not to apply a principle for the right reasons, you have stopped reading the books and started writing software with them.

Code Review Checklist and Anti-Pattern Catalog: A Reviewer's Reference for Modern and AI-Augmented Codebases develops the reviewer's checklist further, including specific anti-patterns (Premature Abstraction, Defensive Programming Overkill, Tight Coupling via Concrete Types) that map directly to over-application of the principles in this article.

11. References

The principles in this article are decades-old; the literature is rich. The single most useful starting point for each topic:

Related Articles


References:
Tech Blog with curated related content

Written by Hidekazu Konishi