Some checks failed
release / tag (push) Has been cancelled
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]
7.4 KiB
7.4 KiB
Object-Oriented Design
Responsibility-Driven Design (RDD)
The key insight: Objects are defined by their responsibilities, not their data.
Finding Objects
Start with:
- Nouns in requirements → candidate objects
- Verbs → candidate methods/behaviors
- Domain concepts → value objects
Finding Responsibilities
Each object should answer:
- What does this object know?
- What does this object do?
- What does this object decide?
Object Stereotypes
Every class fits one (or maybe two) stereotypes:
| Stereotype | Purpose | Example |
|---|---|---|
| Information Holder | Knows things, holds data | User, Product, Address |
| Structurer | Maintains relationships | OrderItems, UserGroup |
| Service Provider | Performs work | PaymentProcessor, EmailSender |
| Coordinator | Orchestrates workflow | OrderFulfillmentService |
| Controller | Makes decisions, delegates | CheckoutController |
| Interfacer | Transforms between systems | UserAPIAdapter, DatabaseMapper |
The Two Questions
For every class, ask:
- "What pattern is this?" - Which stereotype? Which design pattern?
- "Is it doing too much?" - Check object calisthenics rules
If you can't answer clearly, the class needs refactoring.
Tell, Don't Ask
Command objects to do work. Don't interrogate them and do the work yourself.
// BAD: Asking, then doing
if (account.getBalance() >= amount) {
account.setBalance(account.getBalance() - amount);
// more logic here...
}
// GOOD: Telling
const result = account.withdraw(amount);
if (result.isSuccess()) {
// ...
}
The object that has the data should have the behavior.
Design by Contract (DbC)
Every method has:
- Preconditions - What must be true BEFORE calling
- Postconditions - What will be true AFTER calling
- Invariants - What is ALWAYS true about the object
class BankAccount {
private balance: Money;
// INVARIANT: balance is never negative
// PRECONDITION: amount > 0
// POSTCONDITION: balance decreased by amount OR error returned
withdraw(amount: Money): WithdrawResult {
if (amount.isNegativeOrZero()) {
return WithdrawResult.invalidAmount();
}
if (this.balance.isLessThan(amount)) {
return WithdrawResult.insufficientFunds();
}
this.balance = this.balance.minus(amount);
return WithdrawResult.success(this.balance);
}
}
Composition Over Inheritance
Prefer composing objects over extending classes.
Why Inheritance is Problematic:
- Tight coupling between parent and child
- Fragile base class problem
- Difficult to change parent without breaking children
- Forces "is-a" relationship that may not fit
When to Use Inheritance:
- True "is-a" relationship (rare)
- Framework requirements
- Template Method pattern (intentional)
Prefer Composition:
// BAD: Inheritance
class PremiumUser extends User {
getDiscount(): number { return 20; }
}
// GOOD: Composition
class User {
constructor(private discountPolicy: DiscountPolicy) {}
getDiscount(): number {
return this.discountPolicy.calculate();
}
}
// Now discount behavior is pluggable
new User(new PremiumDiscount());
new User(new StandardDiscount());
new User(new NoDiscount());
The Law of Demeter (Principle of Least Knowledge)
Only talk to your immediate friends.
A method should only call:
- Methods on
this - Methods on parameters
- Methods on objects it creates
- Methods on its direct components
// BAD: Reaching through objects
order.getCustomer().getAddress().getCity();
// GOOD: Ask the immediate friend
order.getShippingCity();
This reduces coupling - changes to Address don't ripple through all callers.
Encapsulation
Hide internal details, expose behavior.
Levels of Encapsulation:
- Data - private fields, no direct access
- Implementation - how things work internally
- Type - concrete class hidden behind interface
- Design - architectural decisions hidden from clients
// BAD: Exposed internals
class Order {
public items: Item[] = [];
public total: number = 0;
}
// Client can corrupt state
order.items.push(item);
order.total = -999; // Oops!
// GOOD: Encapsulated
class Order {
private items: OrderItems;
private total: Money;
addItem(item: Item): void {
this.items.add(item);
this.recalculateTotal();
}
getTotal(): Money {
return this.total; // Returns copy or immutable
}
}
Polymorphism
Replace conditionals with types.
// BAD: Type checking
function calculateShipping(method: string, value: number): number {
if (method === 'standard') return value < 50 ? 5 : 0;
if (method === 'express') return 15;
if (method === 'overnight') return 25;
throw new Error('Unknown method');
}
// GOOD: Polymorphism
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;
}
}
// Usage - no conditionals
function calculateShipping(method: ShippingMethod, value: number): number {
return method.calculateCost(value);
}
Value Objects vs Entities
Value Objects
- Defined by their attributes (no identity)
- Immutable
- Comparable by value
- Examples:
Money,Email,Address,DateRange
class Money {
constructor(
private readonly amount: number,
private readonly currency: string
) {}
equals(other: Money): boolean {
return this.amount === other.amount &&
this.currency === other.currency;
}
add(other: Money): Money {
if (this.currency !== other.currency) {
throw new CurrencyMismatch();
}
return new Money(this.amount + other.amount, this.currency);
}
}
Entities
- Have identity (survives attribute changes)
- Usually mutable (via methods)
- Comparable by identity
- Examples:
User,Order,Product
class User {
constructor(
private readonly id: UserId,
private email: Email,
private name: Name
) {}
equals(other: User): boolean {
return this.id.equals(other.id); // Identity comparison
}
changeEmail(newEmail: Email): void {
this.email = newEmail; // Still same user
}
}
Aggregates
A cluster of objects treated as a single unit for data changes.
- One object is the aggregate root (entry point)
- External code only references the root
- Root enforces invariants for the entire cluster
// Order is the aggregate root
class Order {
private items: OrderItem[] = [];
// All access through the root
addItem(product: Product, quantity: number): void {
const item = new OrderItem(product, quantity);
this.items.push(item);
this.validateTotal();
}
removeItem(itemId: ItemId): void {
this.items = this.items.filter(i => !i.id.equals(itemId));
}
// Root enforces invariants
private validateTotal(): void {
if (this.calculateTotal().exceeds(MAX_ORDER_VALUE)) {
throw new OrderTotalExceeded();
}
}
}
// BAD: Accessing items directly
order.items.push(new OrderItem(...)); // Bypasses validation!
// GOOD: Through the root
order.addItem(product, 2); // Validation happens