Phase 1 of mathias/skills extraction (infra#62 Track D — homelab next-step plan addendum). Imports ~/dev/.skills/ verbatim (19 skill dirs + SKILLS_INDEX.md) and adds the installation surface: - Taskfile.yml — install / update / list / release / check targets - install.sh — bootstrap installer for hosts without Task. Idempotent symlink wirer; default checkout at ~/.local/share/skills/ on every host; SKILLS_REF env var pins a tag (default: main). - .gitea/workflows/release.yml — auto-tag every push to main by Bump-Type footer (major/minor/patch, default patch). Skipped when commit contains [skip-release]. - README — usage, versioning, contribution flow, secret-hygiene rule. Phase 1 wires Claude Code only (~/.claude/skills/<name> global + <repo>/.claude/skills/<name> per-repo). Phase 2 adds Crush, opencode, antigravity, and gitea-resident agents (cobalt-dingo, agentsquad) once their skill conventions are researched. Public repo, markdown-only — no secrets, no client names. Verified via pre-push grep before initial push. [skip-release]
6.9 KiB
SOLID Principles
Overview
SOLID helps structure software to be flexible, maintainable, and testable. These principles reduce coupling and increase cohesion.
S - Single Responsibility Principle (SRP)
"A class should have one, and only one, reason to change."
Problem It Solves
God objects that do everything - hard to test, hard to change, hard to understand.
How to Apply
Each class handles ONE responsibility. If you find yourself saying "and" when describing what a class does, split it.
// BAD: Multiple responsibilities
class Order {
calculateTotal(): number { ... }
saveToDatabase(): void { ... } // Persistence
generateInvoice(): string { ... } // Presentation
}
// GOOD: Single responsibility each
class Order {
private items: OrderItem[] = [];
addItem(item: OrderItem): void { ... }
calculateTotal(): number { ... }
}
class OrderRepository {
save(order: Order): Promise<void> { ... }
}
class InvoiceGenerator {
generate(order: Order): Invoice { ... }
}
Detection Questions
- Does this class have multiple reasons to change?
- Can I describe it without using "and"?
- Would different stakeholders request changes to different parts?
O - Open/Closed Principle (OCP)
"Software entities should be open for extension but closed for modification."
Problem It Solves
Having to modify existing, tested code every time requirements change. Risk of breaking working features.
How to Apply
Design abstractions that allow new behavior through new classes, not edits to existing ones.
// BAD: Must modify to add new shipping
class ShippingCalculator {
calculate(type: string, value: number): number {
if (type === 'standard') return value < 50 ? 5 : 0;
if (type === 'express') return 15;
// Must add more ifs for new types!
}
}
// GOOD: Open for extension
interface ShippingMethod {
calculateCost(orderValue: number): number;
}
class StandardShipping implements ShippingMethod {
calculateCost(orderValue: number): number {
return orderValue < 50 ? 5 : 0;
}
}
class ExpressShipping implements ShippingMethod {
calculateCost(orderValue: number): number {
return 15;
}
}
// Add new shipping by creating new class, not modifying existing
class SameDayShipping implements ShippingMethod {
calculateCost(orderValue: number): number {
return 25;
}
}
Architectural Insight
OCP at architecture level means: design your codebase so new features are added by adding code, not changing existing code.
L - Liskov Substitution Principle (LSP)
"Subtypes must be substitutable for their base types without altering program correctness."
Problem It Solves
Subclasses that break expectations, requiring type-checking and special cases.
How to Apply
Subclasses must honor the contract of the parent. If the parent returns positive numbers, subclasses cannot return negatives.
// BAD: Violates parent's contract
class DiscountPolicy {
getDiscount(value: number): number {
return 0; // Non-negative expected
}
}
class WeirdDiscount extends DiscountPolicy {
getDiscount(value: number): number {
return -5; // Increases cost! Breaks expectations
}
}
// GOOD: Enforces contract
class DiscountPolicy {
constructor(private discount: number) {
if (discount < 0) throw new Error("Discount must be non-negative");
}
getDiscount(): number {
return this.discount;
}
}
Key Insight
This is why you can swap InMemoryUserRepo for PostgresUserRepo - they both honor the UserRepo interface contract.
I - Interface Segregation Principle (ISP)
"Clients should not be forced to depend on methods they do not use."
Problem It Solves
Fat interfaces that force partial implementations, empty methods, or throws.
How to Apply
Split large interfaces into smaller, cohesive ones. Clients depend only on what they need.
// BAD: Fat interface
interface WarehouseDevice {
printLabel(orderId: string): void;
scanBarcode(): string;
packageItem(orderId: string): void;
}
class BasicPrinter implements WarehouseDevice {
printLabel(orderId: string): void { /* works */ }
scanBarcode(): string { throw new Error("Not supported"); } // Forced!
packageItem(orderId: string): void { throw new Error("Not supported"); }
}
// GOOD: Segregated interfaces
interface LabelPrinter {
printLabel(orderId: string): void;
}
interface BarcodeScanner {
scanBarcode(): string;
}
interface ItemPackager {
packageItem(orderId: string): void;
}
class BasicPrinter implements LabelPrinter {
printLabel(orderId: string): void { /* only what it does */ }
}
Detection
If you see throw new Error("Not implemented") or empty method bodies, the interface is too fat.
D - Dependency Inversion Principle (DIP)
"High-level modules should not depend on low-level modules. Both should depend on abstractions."
Problem It Solves
Tight coupling to specific implementations (databases, APIs, frameworks). Hard to test, hard to swap.
How to Apply
Depend on interfaces, inject implementations.
// BAD: Direct dependency on concrete class
class OrderService {
private emailService = new SendGridEmailService(); // Locked in!
confirmOrder(email: string): void {
this.emailService.send(email, "Order confirmed");
}
}
// GOOD: Depend on abstraction
interface EmailService {
send(to: string, message: string): void;
}
class OrderService {
constructor(private emailService: EmailService) {}
confirmOrder(email: string): void {
this.emailService.send(email, "Order confirmed");
}
}
// Now can inject any implementation
new OrderService(new SendGridEmailService());
new OrderService(new SESEmailService());
new OrderService(new MockEmailService()); // For tests!
The Dependency Rule
Source code dependencies should point inward toward high-level policies (domain logic), never toward low-level details (infrastructure).
Infrastructure → Application → Domain
↑ ↑ ↑
(outer) (middle) (inner)
Dependencies flow: outer → inner
Never: inner → outer
Applying SOLID at Architecture Level
These principles scale beyond classes:
| Principle | Architecture Application |
|---|---|
| SRP | Each bounded context has one responsibility |
| OCP | New features = new modules, not edits to existing |
| LSP | Microservices with same contract are substitutable |
| ISP | Thin interfaces between services |
| DIP | High-level business logic doesn't know about databases/frameworks |
Quick Reference
| Principle | One-Liner | Red Flag |
|---|---|---|
| SRP | One reason to change | "This class handles X and Y and Z" |
| OCP | Add, don't modify | if/else chains for types |
| LSP | Subtypes are substitutable | Type-checking in calling code |
| ISP | Small, focused interfaces | Empty method implementations |
| DIP | Depend on abstractions | new ConcreteClass() in business logic |