Software Design Essentials
Core concepts every developer should know to design software that is maintainable, scalable, and easy to understand.
What is Software Design?
Software design is the process of defining structure, components, interfaces, and behavior before writing code:
> "Good design is not about making grand plans, but about making things easy to change." - Martin Fowler
Separation of Concerns (SoC)
Each part of the system should address one distinct concern:
// ❌ BAD: Mixed concerns in one place
function renderUserProfile(userId) {
const user = db.query(`SELECT * FROM users WHERE id = ${userId}`);
const html = `<div class="profile">
<h1>${user.name}</h1>
<p>${user.email}</p>
</div>`;
document.getElementById('app').innerHTML = html;
}
// ✅ GOOD: Concerns separated
// Data layer
function fetchUser(userId) {
return db.query('SELECT * FROM users WHERE id = ?', [userId]);
}
// Presentation layer
function UserProfile({ user }) {
return (
<div className="profile">
<h1>{user.name}</h1>
<p>{user.email}</p>
</div>
);
}
// Orchestration layer
async function renderUserProfile(userId) {
const user = await fetchUser(userId);
render(<UserProfile user={user} />, document.getElementById('app'));
}
Abstraction
Hide complexity behind simple, well-defined interfaces:
// ❌ BAD: Caller must know implementation details
const response = await fetch('https://api.example.com/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` },
body: JSON.stringify({ name, email }),
});
const data = await response.json();
if (!response.ok) throw new Error(data.message);
// ✅ GOOD: Abstracted behind a clean interface
const user = await userApi.create({ name, email });
Levels of Abstraction
Stay consistent within each level — don't mix high and low level:
// ❌ BAD: Mixed abstraction levels
function processOrder(order) {
validateOrder(order); // high level
const tax = order.total * 0.08; // low level
applyDiscount(order); // high level
order.grandTotal = order.total + tax; // low level
sendConfirmation(order); // high level
}
// ✅ GOOD: Consistent abstraction level
function processOrder(order) {
validateOrder(order);
calculateOrderTotals(order);
applyDiscount(order);
sendConfirmation(order);
}
Encapsulation
Keep data and behavior together, hide internal details:
// ❌ BAD: Data exposed, logic scattered
const account = { balance: 1000, owner: 'Alice' };
function deposit(account, amount) {
account.balance += amount; // anyone can mutate directly too
}
function withdraw(account, amount) {
if (amount > account.balance) throw new Error('Insufficient funds');
account.balance -= amount;
}
// ✅ GOOD: Encapsulated in a class
class BankAccount {
#balance;
#owner;
constructor(owner, initialBalance = 0) {
this.#owner = owner;
this.#balance = initialBalance;
}
deposit(amount) {
if (amount <= 0) throw new Error('Deposit must be positive');
this.#balance += amount;
}
withdraw(amount) {
if (amount > this.#balance) throw new Error('Insufficient funds');
this.#balance -= amount;
}
get balance() {
return this.#balance;
}
}
Coupling & Cohesion
The two most important metrics for evaluating design:
Coupling — how much modules depend on each other
Aim for low coupling:
// ❌ HIGH COUPLING: OrderService reaches into UserService internals
class OrderService {
placeOrder(order) {
const user = UserService.userRepository.db.find(order.userId); // knows too much
if (!user.subscription.isPremium) throw new Error('Premium only');
}
}
// ✅ LOW COUPLING: Through a defined interface
class OrderService {
constructor(private userService: UserService) {}
placeOrder(order) {
if (!this.userService.isPremiumUser(order.userId)) {
throw new Error('Premium only');
}
}
}
Cohesion — how closely related things inside a module are
Aim for high cohesion:
// ❌ LOW COHESION: Unrelated responsibilities grouped together
class Utils {
formatDate(date) {}
sendEmail(to, subject, body) {}
calculateTax(amount) {}
resizeImage(image, width, height) {}
generatePDF(data) {}
}
// ✅ HIGH COHESION: Related responsibilities together
class DateFormatter { formatDate(date) {} }
class EmailService { sendEmail(to, subject, body) {} }
class TaxCalculator { calculateTax(amount, rate) {} }
Modularity
Break systems into independent, interchangeable modules:
// ✅ GOOD: Clear module boundaries
src/
├── auth/
│ ├── authService.ts # Authentication logic
│ ├── authMiddleware.ts # Request authentication
│ └── authRoutes.ts # Auth endpoints
├── users/
│ ├── userService.ts # User business logic
│ ├── userRepository.ts # Data access
│ └── userRoutes.ts # User endpoints
├── orders/
│ ├── orderService.ts
│ ├── orderRepository.ts
│ └── orderRoutes.ts
└── shared/
├── errors.ts # Shared error types
└── validators.ts # Shared validation
Each module:
Composition over Inheritance
Prefer assembling behavior from small pieces over deep class hierarchies:
// ❌ BAD: Deep inheritance hierarchy
class Animal {}
class Pet extends Animal {}
class Dog extends Pet {}
class GuideDog extends Dog {}
class PoliceDog extends Dog {}
// GuideDog and PoliceDog can't share traits without re-inheritance
// ✅ GOOD: Compose behavior
const canBark = (state) => ({
bark: () => console.log(`${state.name} says: Woof!`),
});
const canGuide = (state) => ({
guide: () => console.log(`${state.name} is guiding you`),
});
const canPatrol = (state) => ({
patrol: () => console.log(`${state.name} is on patrol`),
});
function createGuideDog(name) {
const state = { name };
return { ...canBark(state), ...canGuide(state) };
}
function createPoliceDog(name) {
const state = { name };
return { ...canBark(state), ...canPatrol(state) };
}
Designing for Change
Design systems so that change is local — modifying one feature shouldn't ripple across the codebase:
// ❌ BAD: Adding a new payment method requires touching many files
function checkout(cart, paymentType) {
if (paymentType === 'card') { /* ... */ }
else if (paymentType === 'paypal') { /* ... */ }
else if (paymentType === 'crypto') { /* ... */ } // added everywhere
}
// ✅ GOOD: New payment method = new class, nothing else changes
interface PaymentMethod {
charge(amount: number): Promise<Receipt>;
}
class CardPayment implements PaymentMethod { /* ... */ }
class PayPalPayment implements PaymentMethod { /* ... */ }
class CryptoPayment implements PaymentMethod { /* ... */ } // just add here
class CheckoutService {
constructor(private payment: PaymentMethod) {}
async checkout(cart) {
return this.payment.charge(cart.total);
}
}
Common Design Mistakes
| Mistake | Problem | Fix |
|---|---|---|
| God object | One class knows everything | Split into focused classes |
| Shotgun surgery | One change touches many files | Centralize related logic |
| Feature envy | Class uses another class's data more than its own | Move behavior to where the data is |
| Primitive obsession | Using primitives instead of domain objects | Introduce value objects |
| Anemic domain model | Data classes with no behavior | Add behavior where data lives |
Tips
UserManager that sends emails has a naming problem