Software Design Principles
Early in my career, I measured code quality by whether it worked. If the tests passed and the feature shipped, the code was "good." But I noticed something: some codebases were a pleasure to work in—changes were easy, bugs were obvious, features could be added without rewriting everything. Others were a minefield—every change broke something, every feature required touching ten files, and nobody wanted to go near certain modules. The difference wasn't talent. It was design.
Software design principles are the accumulated wisdom of decades of engineering practice, distilled into guidelines for structuring code that stays maintainable as it grows. They're not rules to follow blindly—they're tradeoffs to understand and apply with judgment.
Coupling and Cohesion
Before SOLID, before clean architecture, before any named principle—there are two fundamental metrics for code organization: coupling and cohesion.
Cohesion measures how closely related the responsibilities within a module are. High cohesion means everything in the module serves a single, clear purpose.
// High cohesion: everything in this module is about user authentication
// auth/service.ts
export async function login(email: string, password: string) { ... }
export async function logout(sessionId: string) { ... }
export async function refreshToken(token: string) { ... }
export async function verifySession(sessionId: string) { ... }// Low cohesion: this module does everything
// utils.ts
export function formatDate(d: Date) { ... }
export function hashPassword(pw: string) { ... }
export function sendEmail(to: string, body: string) { ... }
export function calculateTax(amount: number) { ... }Coupling measures how much one module depends on another. Low coupling means modules can change independently.
// High coupling: OrderService knows exactly how UserService stores data
class OrderService {
createOrder(userId: string) {
const user = this.userService.users.find((u) => u._id === userId)
const discount = user._internalDiscountLevel * 0.1
// ...
}
}
// Low coupling: OrderService only depends on a stable interface
class OrderService {
createOrder(userId: string) {
const discount = await this.userService.getDiscount(userId)
// ...
}
}The goal is high cohesion (each module does one thing well) and low coupling (modules interact through stable interfaces). Every design principle is ultimately about achieving this.
SOLID Principles
SOLID is five principles that Robert C. Martin popularized for object-oriented design. They're useful beyond OOP—they apply to modules, functions, and system architecture.
S — Single Responsibility Principle
A module should have one reason to change. Not "one thing it does" (too restrictive) but one stakeholder or axis of change it serves.
// Violates SRP: this class changes when business rules change AND when email format changes
class UserService {
createUser(data: CreateUserData) { ... }
sendWelcomeEmail(user: User) { ... }
generateMonthlyReport() { ... }
}
// Better: separate concerns
class UserService {
createUser(data: CreateUserData) { ... }
}
class EmailService {
sendWelcomeEmail(user: User) { ... }
}
class ReportService {
generateMonthlyReport() { ... }
}The pragmatic take: Don't create a new file for every function. Group things that change together. If email logic changes never require changes to user creation logic, they belong in separate modules.
O — Open/Closed Principle
Modules should be open for extension but closed for modification. You should be able to add new behavior without changing existing code.
// Closed for modification: adding a new discount type requires editing this function
function applyDiscount(order: Order, type: string) {
if (type === "percentage") return order.total * 0.9
if (type === "fixed") return order.total - 10
if (type === "bogo") return order.total / 2
// every new type = edit this function
}
// Open for extension: new discount types are added without touching existing code
interface DiscountStrategy {
apply(total: number): number
}
const discounts: Record<string, DiscountStrategy> = {
percentage: { apply: (total) => total * 0.9 },
fixed: { apply: (total) => total - 10 },
}
function applyDiscount(order: Order, type: string) {
return discounts[type].apply(order.total)
}Adding a new discount is a matter of registering a new entry—no existing code changes.
L — Liskov Substitution Principle
Subtypes must be substitutable for their base types without breaking the program. If a function accepts a Bird, it should work with Penguin even though penguins can't fly.
// Violates LSP: Square overrides Rectangle's behavior in a surprising way
class Rectangle {
constructor(
public width: number,
public height: number
) {}
setWidth(w: number) {
this.width = w
}
setHeight(h: number) {
this.height = h
}
area() {
return this.width * this.height
}
}
class Square extends Rectangle {
setWidth(w: number) {
this.width = w
this.height = w
} // surprise!
setHeight(h: number) {
this.width = h
this.height = h
}
}
function stretch(rect: Rectangle) {
rect.setWidth(rect.width * 2)
return rect.area() // caller expects width*2, height unchanged
}
stretch(new Square(5)) // returns 100, not 50 — LSP violatedThe pragmatic take: Don't inherit if the subclass changes the parent's contract. Prefer composition over inheritance.
I — Interface Segregation Principle
Clients shouldn't depend on interfaces they don't use. Keep interfaces small and focused.
// Too fat: a read-only component is forced to depend on write methods
interface Repository<T> {
findAll(): Promise<T[]>
findById(id: string): Promise<T | null>
create(data: T): Promise<T>
update(id: string, data: Partial<T>): Promise<T>
delete(id: string): Promise<void>
}
// Segregated: consumers depend only on what they need
interface ReadRepository<T> {
findAll(): Promise<T[]>
findById(id: string): Promise<T | null>
}
interface WriteRepository<T> {
create(data: T): Promise<T>
update(id: string, data: Partial<T>): Promise<T>
delete(id: string): Promise<void>
}A reporting service depends on ReadRepository. A CRUD API depends on both. Neither is forced to care about methods it doesn't use.
D — Dependency Inversion Principle
High-level modules should not depend on low-level modules. Both should depend on abstractions.
// Without DIP: high-level OrderService depends on low-level PostgresDB
class OrderService {
private db = new PostgresDB()
async createOrder(data: OrderData) {
return this.db.query("INSERT INTO orders ...")
}
}
// With DIP: OrderService depends on an abstract interface
interface OrderRepository {
create(data: OrderData): Promise<Order>
}
class OrderService {
constructor(private repo: OrderRepository) {}
async createOrder(data: OrderData) {
return this.repo.create(data)
}
}
// The implementation is injected from outside
const service = new OrderService(new PostgresOrderRepository())
// For tests:
const testService = new OrderService(new InMemoryOrderRepository())DIP enables testing (inject a fake), flexibility (swap implementations), and layering (business logic doesn't know about databases).
Layered Architecture
Most well-structured applications are organized in layers, each with a clear responsibility:
The key rule: Dependencies point inward. The domain layer doesn't know about databases, HTTP, or frameworks. The infrastructure layer implements interfaces defined by the domain.
In a Next.js project:
app/ → Presentation (routes, pages, components)
features/
orders/
api/ → Application (use cases, data fetching)
components/ → Presentation (UI components)
types.ts → Domain (types, business rules)
packages/
cms/ → Infrastructure (CMS client)
auth/ → Infrastructure (auth provider)
Clean Architecture
Clean architecture (Hexagonal, Ports and Adapters, Onion) formalizes layering with one additional rule: the domain is at the center and depends on nothing.
The domain defines ports (interfaces) that the outside world implements:
// Domain port — defines what the domain needs, not how it's provided
interface PaymentGateway {
charge(amount: number, customerId: string): Promise<ChargeResult>
}
// Infrastructure adapter — implements the port with a specific provider
class StripePaymentGateway implements PaymentGateway {
async charge(amount: number, customerId: string) {
return stripe.charges.create({ amount, customer: customerId })
}
}
// Domain logic uses the port, not the adapter
class OrderService {
constructor(private payments: PaymentGateway) {}
async checkout(order: Order) {
const result = await this.payments.charge(order.total, order.customerId)
if (result.success) order.markPaid()
return result
}
}Switching from Stripe to PayPal means writing a new adapter—the domain logic doesn't change. Testing the domain means passing a mock gateway—no real payments involved.
When clean architecture is overkill: For small applications, CRUD APIs, and prototypes, the layering overhead isn't worth it. A Next.js app that calls a CMS API and renders pages doesn't need ports and adapters. Use clean architecture when the domain logic is complex enough that protecting it from infrastructure changes pays dividends.
Composition Over Inheritance
Inheritance creates rigid hierarchies. Composition creates flexible assemblies. The advice to "prefer composition over inheritance" is the most universally applicable design principle I've encountered.
// Inheritance: rigid, changes ripple through the hierarchy
class Animal { move() { ... } }
class Bird extends Animal { fly() { ... } }
class Penguin extends Bird { fly() { throw new Error("Can't fly!") } } // awkward
// Composition: flexible, behaviors are mixed and matched
type Movable = { move: () => void }
type Flyable = { fly: () => void }
type Swimmable = { swim: () => void }
function createPenguin(): Movable & Swimmable {
return {
move() { ... },
swim() { ... },
}
}React adopted this philosophy entirely. Components compose through children and props, not inheritance. Higher-order components and hooks are compositional patterns.
Boundaries
The most important design decision in any system is where to draw boundaries. A boundary is a line between two things that can change independently.
Good boundaries:
- Between your application and the database (so you can change databases).
- Between your API and the UI (so the frontend can evolve independently).
- Between your business logic and third-party services (so you can switch providers).
- Between packages in a monorepo (so teams can work independently).
The boundary is implemented as an interface or contract. The thinner the contract, the more independently the sides can evolve.
// Thick boundary (leaky): the consumer knows about the database shape
export function getUsers() {
return prisma.user.findMany({
include: { orders: { where: { status: 'active' } } }
})
}
// Thin boundary: the consumer gets a domain shape
export function getActiveUsers(): Promise<UserWithOrders[]> {
const raw = await prisma.user.findMany({ ... })
return raw.map(toUserWithOrders)
}The thin boundary means the consumer doesn't know or care that you're using Prisma. If you switch to Drizzle or raw SQL, the consumer's code doesn't change.
The Pragmatic Takeaway
Design principles are guidelines, not laws. Every principle has a context where it helps and a context where it's overhead.
For a weekend project, SRP and clean architecture are overkill—just ship it. For a production system maintained by a team over years, these principles prevent the accretion of coupling that makes every change risky and every feature slow.
The pattern I've observed in experienced engineers: they internalize these principles to the point where they apply them instinctively—not as ceremonies, but as natural consequences of thinking clearly about responsibility, dependency, and change.
Start with coupling and cohesion. If you always ask "does this belong here?" and "what would need to change if this requirement changed?", you'll arrive at SOLID, layering, and clean architecture naturally—without memorizing acronyms.