Software Design Principles
Foundational principles that guide decision-making when designing and writing software. These are heuristics — not laws — so apply them with judgment.
SOLID Principles
A set of five principles that make object-oriented designs more understandable, flexible, and maintainable.
S — Single Responsibility Principle
> A class should have one reason to change.
// ❌ BAD: Multiple responsibilities
class User {
constructor(public name: string, public email: string) {}
save() { db.save(this); } // persistence
sendWelcomeEmail() { mailer.send(); } // communication
generateReport() { return `...`; } // reporting
}
// ✅ GOOD: One responsibility each
class User {
constructor(public name: string, public email: string) {}
}
class UserRepository {
save(user: User) { db.save(user); }
}
class UserMailer {
sendWelcome(user: User) { mailer.send(user.email, 'Welcome!'); }
}
class UserReporter {
generate(user: User) { return `Report for ${user.name}`; }
}
O — Open/Closed Principle
> Open for extension, closed for modification.
// ❌ BAD: Must modify existing code for every new shape
class AreaCalculator {
calculate(shape) {
if (shape.type === 'circle') return Math.PI * shape.radius ** 2;
if (shape.type === 'square') return shape.side ** 2;
// Adding triangle requires editing this class
}
}
// ✅ GOOD: Extend by adding new classes, never touching existing ones
interface Shape {
area(): number;
}
class Circle implements Shape {
constructor(private radius: number) {}
area() { return Math.PI * this.radius ** 2; }
}
class Square implements Shape {
constructor(private side: number) {}
area() { return this.side ** 2; }
}
class Triangle implements Shape { // new shape — no existing code changes
constructor(private base: number, private height: number) {}
area() { return 0.5 * this.base * this.height; }
}
class AreaCalculator {
calculate(shape: Shape) { return shape.area(); }
}
L — Liskov Substitution Principle
> Subtypes must be substitutable for their base types without altering correctness.
// ❌ BAD: Square breaks the contract of Rectangle
class Rectangle {
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; // silently changes height — breaks expectations
}
}
function resizeAndPrint(rect: Rectangle) {
rect.setWidth(5);
rect.setHeight(10);
console.log(rect.area()); // Expected: 50. Square gives: 100 ❌
}
// ✅ GOOD: Separate types, no broken contract
interface Shape {
area(): number;
}
class Rectangle implements Shape {
constructor(private width: number, private height: number) {}
area() { return this.width * this.height; }
}
class Square implements Shape {
constructor(private side: number) {}
area() { return this.side ** 2; }
}
I — Interface Segregation Principle
> Clients should not be forced to depend on interfaces they don't use.
// ❌ BAD: Fat interface forces unnecessary implementation
interface Worker {
work(): void;
eat(): void;
sleep(): void;
}
class Robot implements Worker {
work() { /* works */ }
eat() { throw new Error('Robots do not eat'); } // forced stub
sleep() { throw new Error('Robots do not sleep'); } // forced stub
}
// ✅ GOOD: Focused interfaces
interface Workable { work(): void; }
interface Eatable { eat(): void; }
interface Sleepable { sleep(): void; }
class Human implements Workable, Eatable, Sleepable {
work() {}
eat() {}
sleep() {}
}
class Robot implements Workable {
work() {} // only what it needs
}
D — Dependency Inversion Principle
> Depend on abstractions, not concretions.
// ❌ BAD: High-level module depends on a low-level concrete class
class OrderService {
private db = new MySQLDatabase(); // hardcoded dependency
saveOrder(order: Order) {
this.db.insert('orders', order);
}
}
// ✅ GOOD: Depend on an abstraction, inject the implementation
interface Database {
insert(table: string, record: object): void;
}
class OrderService {
constructor(private db: Database) {} // accepts any database
saveOrder(order: Order) {
this.db.insert('orders', order);
}
}
// Easy to swap
const mysqlService = new OrderService(new MySQLDatabase());
const inMemoryService = new OrderService(new InMemoryDatabase()); // for tests
DRY — Don't Repeat Yourself
> Every piece of knowledge should have a single, authoritative representation.
// ❌ BAD: Validation logic duplicated in three places
function createUser(data) {
if (!data.email || !/\S+@\S+\.\S+/.test(data.email)) throw new Error('Invalid email');
// ...
}
function updateUser(id, data) {
if (!data.email || !/\S+@\S+\.\S+/.test(data.email)) throw new Error('Invalid email');
// ...
}
function inviteUser(email) {
if (!email || !/\S+@\S+\.\S+/.test(email)) throw new Error('Invalid email');
// ...
}
// ✅ GOOD: Single source of truth
function validateEmail(email: string) {
if (!email || !/\S+@\S+\.\S+/.test(email)) throw new Error('Invalid email');
}
function createUser(data) { validateEmail(data.email); /* ... */ }
function updateUser(id, data) { validateEmail(data.email); /* ... */ }
function inviteUser(email) { validateEmail(email); /* ... */ }
> Note: DRY is about knowledge, not just text. Two identical-looking snippets that represent different concepts should stay separate.
KISS — Keep It Simple, Stupid
> Prefer the simplest solution that solves the problem.
// ❌ BAD: Overengineered for a simple use case
class UserNameFormatterFactory {
createFormatter(strategy: string): UserNameFormatter {
return FormatterRegistry.getInstance().resolve(strategy);
}
}
// ✅ GOOD: Just write the function
function formatUserName(firstName: string, lastName: string): string {
return `${firstName} ${lastName}`.trim();
}
Complexity signals:
YAGNI — You Aren't Gonna Need It
> Don't build features until they are actually needed.
// ❌ BAD: Built for a future that may never come
class PaymentService {
processPayment(amount, method, currency, region, taxRate, loyaltyPoints, giftCard) {
// Handles every possible scenario "just in case"
}
}
// ✅ GOOD: Solve what's needed now, refactor when the need is real
class PaymentService {
processPayment(amount: number, method: 'card' | 'paypal') {
// Handles what we actually need today
}
}
Law of Demeter (Principle of Least Knowledge)
> A module should only talk to its immediate friends, not strangers.
// ❌ BAD: Reaching through multiple levels
class Order {
getTotal() {
return this.customer.wallet.balance.getAmount(); // knows too much
}
}
// ✅ GOOD: Ask the direct collaborator
class Order {
getTotal() {
return this.customer.getBalance(); // customer encapsulates the rest
}
}
Rule of thumb: limit method chains to one dot when crossing object boundaries.
Fail Fast
> Detect and report errors as early as possible.
// ❌ BAD: Accepts invalid input, fails mysteriously later
function createOrder(userId, items) {
const order = { userId, items };
// items might be empty — the bug surfaces much later at payment
processPayment(order);
}
// ✅ GOOD: Validate at the entry point
function createOrder(userId: string, items: Item[]) {
if (!userId) throw new Error('userId is required');
if (!items?.length) throw new Error('Order must have at least one item');
const order = { userId, items };
processPayment(order);
}
Principle of Least Astonishment
> A component should behave in a way that users and developers expect.
// ❌ BAD: getUser sounds read-only but has a side effect
function getUser(id) {
const user = db.find(id);
auditLog.record(`Fetched user ${id}`); // surprise side effect
return user;
}
// ✅ GOOD: Separate concerns, no hidden surprises
function getUser(id) {
return db.find(id);
}
function getAndAuditUser(id) {
const user = getUser(id);
auditLog.record(`Fetched user ${id}`);
return user;
}
Principles at a Glance
| Principle | One-Line Rule |
|---|---|
| SRP | One class, one reason to change |
| OCP | Extend by adding, not modifying |
| LSP | Subclasses must honor the parent's contract |
| ISP | Small interfaces over fat ones |
| DIP | Depend on abstractions, inject concretions |
| DRY | Every fact has one home |
| KISS | Choose the simpler option |
| YAGNI | Build what you need, when you need it |
| LoD | Don't talk to strangers |
| Fail Fast | Surface errors early |
| PoLA | No surprises |
When to Break the Rules
Principles are guidelines, not laws. Break them deliberately when:
The key is to know the rule well enough to know when breaking it is the right call, and to document why.