Core software engineering principles and guidelines — SOLID, DRY, KISS, YAGNI, cohesion, coupling, and structural design patterns for maintainable systems
npx skills add m10rten/typescript-bits --skill software-principles-guidelinesskill.md · 378 lines~3.4kFoundational software engineering principles for designing maintainable, scalable, and robust systems. Apply these when making architectural decisions, writing code, or reviewing pull requests.
A module, class, or function should have one reason to change — one clearly defined responsibility.
// ❌ Mixed concerns — parsing + validation + persistence
function processUserData(raw: unknown): void { ... }
// ✅ Separate each concern
function parseUserData(raw: unknown): UserData { ... }
function validateUserData(data: UserData): ValidationResult { ... }
function saveUserData(data: UserData): Promise<void> { ... }`validateAndSave`), it does too much.Software entities should be open for extension, closed for modification.
// ❌ Adding a new shape requires editing this function
function area(shape: Shape): number {
if (shape.kind === "circle") return Math.PI * shape.radius ** 2;
if (shape.kind === "rectangle") return shape.width * shape.height;
throw new Error("unknown shape");
}
// ✅ New shapes implement the interface — no modification needed
interface Shape {
area(): number;
}
class Circle implements Shape {
constructor(public radius: number) {}
area(): number {
return Math.PI * this.radius ** 2;
}
}
class Rectangle implements Shape {
constructor(
public w: number,
public h: number,
) {}
area(): number {
return this.w * this.h;
}
}Subtypes must be substitutable for their base types without altering correctness.
// ❌ Violates LSP — narrows return type, strengthens preconditions
class Rectangle {
setWidth(w: number): void {
this.width = w;
}
setHeight(h: number): void {
this.height = h;
}
}
class Square extends Rectangle {
setWidth(w: number): void {
this.width = w;
this.height = w;
} // side effect: changes height
setHeight(h: number): void {
this.width = h;
this.height = h;
}
}No client should be forced to depend on methods it does not use.
// ❌ Fat interface — every printer must implement scan/fax
interface AllInOne {
print(doc: Document): void;
scan(): Document;
fax(doc: Document): void;
}
// ✅ Segregated interfaces
interface Printer {
print(doc: Document): void;
}
interface Scanner {
scan(): Document;
}
interface Fax {
fax(doc: Document): void;
}High-level modules should not depend on low-level modules. Both should depend on abstractions.
// ❌ High-level depends on low-level detail
class UserService { constructor(private db: PostgresDatabase) {} }
// ✅ Both depend on abstraction
interface UserRepository { find(id: string): Promise<User>; }
class PostgresUserRepository implements UserRepository { ... }
class UserService { constructor(private repo: UserRepository) {} }`new` inside a class.Every piece of knowledge must have a single, unambiguous representation within a system.
// ❌ Duplicated validation logic
function createUser(data: unknown) { if (!data.name) throw new Error("name required"); ... }
function updateUser(data: unknown) { if (!data.name) throw new Error("name required"); ... }
// ✅ Single source of truth
function requireName(data: unknown): asserts data is { name: string } {
if (!data || typeof data.name !== "string") throw new Error("name required");
}Simple systems are easier to understand, test, and change. Complexity is a liability.
// ❌ Over-engineered
class UserBuilder {
#user: Partial<User> = {};
withName(name: string): this {
this.#user.name = name;
return this;
}
withEmail(email: string): this {
this.#user.email = email;
return this;
}
build(): User {
return this.#user as User;
}
}
// ✅ Simple factory function
function createUser(name: string, email: string): User {
return { name, email };
}Always implement things when you actually need them, never when you merely anticipate that you might need them.
Favor composing behaviors from small, focused units over deep inheritance hierarchies.
// ❌ Deep inheritance
class Animal {}
class Mammal extends Animal {}
class Dog extends Mammal {
bark(): void {}
}
// ✅ Composed behaviors
type Bark = { bark(): void };
type Walk = { walk(): void };
function createDog(): Bark & Walk {
return { bark() {}, walk() {} };
}Different concerns (data access, business logic, presentation, configuration) belong in different modules.
Push side-effects (IO, network, mutations) to a thin outer shell; keep business logic as pure functions at the center.
`Result` type pattern embodies this.A unit should only talk to its immediate neighbors — not to the neighbor of a neighbor.
// ❌ Train wreck — knows too much about the graph
const city = order.customer.address.city;
// ✅ Tell, don't ask — or restructure
const city = order.getShippingCity();Related behaviors belong together. Everything in a module should contribute to a single purpose.
`this` belong elsewhere.Modules should depend on abstractions, not concrete implementations. Changes in one module should rarely force changes in another.
| Coupling Type | Indicators | Fix |
|---|---|---|
| Content coupling | Reading another module's internals | Encapsulate behind API |
| Common coupling | Shared global mutable state | Pass data explicitly |
| Control coupling | Passing flags that alter control flow | Split into separate functions |
| Stamp coupling | Passing entire objects when only one field is needed | Pass only what's needed |
| Data coupling | Passing only primitive arguments | Acceptable — preferred coupling |
Detect and report errors as early as possible — at construction/startup, not deep in a transaction.
// ❌ Latent failure — fails at call time
class Config {
constructor(private raw: unknown) {}
get port(): number {
return (this.raw as any).port;
}
}
// ✅ Fail fast — validate at construction
class Config {
readonly port: number;
constructor(raw: unknown) {
if (!raw || typeof raw !== "object" || typeof (raw as any).port !== "number") throw new Error("Invalid config");
this.port = (raw as any).port;
}
}Be conservative in what you send, be liberal in what you accept.
Methods should either be commands (mutate state, return void) or queries (return data, no side effects), but not both.
// ❌ Mixes command and query
function pop<T>(stack: T[]): T | undefined {
return stack.pop();
}
// ✅ Separate
function peek<T>(stack: T[]): T | undefined {
return stack.at(-1);
}
function pop<T>(stack: T[]): T | undefined {
return stack.pop();
}Code should behave in ways that users (and other developers) would reasonably expect.
`deleteUser` should delete, not archive.Leave the codebase cleaner than you found it.
| Anti-Pattern | Description | Fix |
|---|---|---|
| God class | A single class knows/does everything | Split by responsibility (SRP) |
| Spaghetti code | Entangled control flow with no structure | Extract functions, layer architecture |
| Golden hammer | Overusing a favorite pattern/abstraction | Choose the right tool for the problem |
| Copy-paste programming | Duplicating code without understanding | Extract shared logic (DRY) |
| Premature abstraction | Abstracting before the pattern is clear | Write concrete, then refactor (YAGNI) |
| Shotgun surgery | One change requires edits everywhere | Centralize the concern behind an API |
| Feature envy | A method uses another class's data excessively | Move the method closer to the data |
| Magic numbers/strings | Bare literals without named constants | Extract to well-named constants |