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]
329 lines
7.4 KiB
Markdown
329 lines
7.4 KiB
Markdown
# Object-Oriented Design
|
|
|
|
## Responsibility-Driven Design (RDD)
|
|
|
|
The key insight: **Objects are defined by their responsibilities, not their data.**
|
|
|
|
### Finding Objects
|
|
|
|
Start with:
|
|
1. **Nouns** in requirements → candidate objects
|
|
2. **Verbs** → candidate methods/behaviors
|
|
3. **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:
|
|
1. **"What pattern is this?"** - Which stereotype? Which design pattern?
|
|
2. **"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.**
|
|
|
|
```typescript
|
|
// 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
|
|
|
|
```typescript
|
|
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:
|
|
```typescript
|
|
// 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:
|
|
1. Methods on `this`
|
|
2. Methods on parameters
|
|
3. Methods on objects it creates
|
|
4. Methods on its direct components
|
|
|
|
```typescript
|
|
// 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:
|
|
1. **Data** - private fields, no direct access
|
|
2. **Implementation** - how things work internally
|
|
3. **Type** - concrete class hidden behind interface
|
|
4. **Design** - architectural decisions hidden from clients
|
|
|
|
```typescript
|
|
// 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.**
|
|
|
|
```typescript
|
|
// 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`
|
|
|
|
```typescript
|
|
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`
|
|
|
|
```typescript
|
|
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
|
|
|
|
```typescript
|
|
// 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
|
|
```
|