19 KiB
name, description, version
| name | description | version |
|---|---|---|
| Software Design Principles | Object-oriented design principles including object calisthenics, dependency inversion, fail-fast error handling, feature envy detection, and intention-revealing naming. Activates during code refactoring, design reviews, or when user requests design improvements. | 1.0.0 |
Software Design Principles
Professional software design patterns and principles for writing maintainable, well-structured code.
Critical Rules
🚨 Fail-fast over silent fallbacks. Never use fallback chains (value ?? backup ?? 'unknown'). If data should exist, validate and throw a clear error.
🚨 No any. No as. Type escape hatches defeat TypeScript's purpose. There's always a type-safe solution.
🚨 Make illegal states unrepresentable. Use discriminated unions, not optional fields. If a state combination shouldn't exist, make the type system forbid it.
🚨 Inject dependencies, don't instantiate. No new SomeService() inside methods. Pass dependencies through constructors.
🚨 Intention-revealing names only. Never use data, utils, helpers, handler, processor. Name things for what they do in the domain.
🚨 No code comments. Comments are a failure to express intent in code. If you need a comment to explain what code does, the code isn't clear enough—refactor it.
When This Applies
- Writing new code (these are defaults, not just refactoring goals)
- Refactoring existing code
- Code reviews and design reviews
- During TDD REFACTOR phase
- When analyzing coupling and cohesion
Core Philosophy
Well-designed, maintainable code is far more important than getting things done quickly. Every design decision should favor:
- Clarity over cleverness
- Explicit over implicit
- Fail-fast over silent fallbacks
- Loose coupling over tight integration
- Intention-revealing over generic
Code Without Comments
Principle: Never write code comments - prefer to write expressive code.
Well-named functions, classes, and variables should make the code self-documenting. Comments are often a sign that the code isn't clear enough.
Instead of comments, use:
- Intention-revealing names
- Small, focused functions
- Clear structure
- Domain language
Bad (needs comments):
// Check if user can access resource
function check(u: User, r: Resource): boolean {
// Admin users bypass all checks
if (u.role === 'admin') return true
// Check ownership
if (r.ownerId === u.id) return true
// Check shared access
return r.sharedWith.includes(u.id)
}
Good (self-documenting):
function canUserAccessResource(user: User, resource: Resource): boolean {
if (user.isAdmin()) return true
if (resource.isOwnedBy(user)) return true
return resource.isSharedWith(user)
}
The code reads like prose - no comments needed.
Object Calisthenics
Apply object calisthenics strictly to all code. Refactor existing code to comply with these principles:
The Nine Rules
-
One level of indentation per method
- Improves readability
- Forces extraction of helper methods
- Makes code easier to test
-
Don't use the ELSE keyword
- Use early returns instead
- Reduces nesting
- Clarifies happy path
-
Wrap all primitives and strings
- Create value objects
- Encapsulate validation logic
- Make domain concepts explicit
-
First class collections
- Classes with collections should contain nothing else
- Enables collection-specific operations
- Improves cohesion
-
One dot per line
- Reduces coupling
- Prevents feature envy
- Honors Law of Demeter
-
Don't abbreviate
- Use full, descriptive names
- Code is read more than written
- Self-documenting code reduces comments
-
Keep all entities small
- Small classes (< 150 lines)
- Small methods (< 10 lines)
- Small packages/modules
- Easier to understand and maintain
-
No classes with more than two instance variables
- High cohesion
- Clear single responsibility
- Easier to test
-
No getters/setters/properties
- Tell, don't ask
- Objects should do work, not expose data
- Prevents feature envy
When to Apply
During refactoring:
- Review code against all nine rules
- Identify violations
- Refactor to comply
- Verify tests still pass after each change
During code review:
- Check new code complies with calisthenics
- Suggest refactorings for violations
- Explain rationale using domain language
Feature Envy Detection
What is Feature Envy? When a method in one class is more interested in the data or behavior of another class than its own.
Warning Signs
// FEATURE ENVY - method is obsessed with Order's data
class InvoiceGenerator {
generate(order: Order): Invoice {
const total = order.getItems()
.map(item => item.getPrice() * item.getQuantity())
.reduce((sum, val) => sum + val, 0)
const tax = total * order.getTaxRate()
const shipping = order.calculateShipping()
return new Invoice(total + tax + shipping)
}
}
This method should live in the Order class - it's using Order's data extensively.
How to Fix
Move the method to the class it envies:
class Order {
calculateTotal(): number {
const subtotal = this.items
.map(item => item.getPrice() * item.getQuantity())
.reduce((sum, val) => sum + val, 0)
const tax = subtotal * this.taxRate
const shipping = this.calculateShipping()
return subtotal + tax + shipping
}
}
class InvoiceGenerator {
generate(order: Order): Invoice {
return new Invoice(order.calculateTotal())
}
}
Detection Protocol
During refactoring:
- For each method, count references to external objects vs own object
- If external references > own references → likely feature envy
- Consider moving method to the envied class
- Re-run tests to verify behavior preserved
Dependency Inversion Principle
Principle: Depend on abstractions, not concretions. Do not directly instantiate dependencies within methods.
The Problem: Hard Dependencies
// TIGHT COUPLING - hard dependency
class OrderProcessor {
process(order: Order): void {
const validator = new OrderValidator() // ❌ Direct instantiation
if (!validator.isValid(order)) {
throw new Error('Invalid order')
}
const emailer = new EmailService() // ❌ Direct instantiation
emailer.send(order.customerEmail, 'Order confirmed')
}
}
Issues:
- Hard to test (can't mock dependencies)
- Hard to change (coupled to concrete implementations)
- Hidden dependencies (not visible in constructor)
- Violates Single Responsibility (creates AND uses)
The Solution: Dependency Inversion
// LOOSE COUPLING - dependencies injected
class OrderProcessor {
constructor(
private validator: OrderValidator,
private emailer: EmailService
) {}
process(order: Order): void {
if (!this.validator.isValid(order)) {
throw new Error('Invalid order')
}
this.emailer.send(order.customerEmail, 'Order confirmed')
}
}
Benefits:
- Easy to test (inject mocks)
- Easy to change (swap implementations)
- Explicit dependencies (visible in constructor)
- Single Responsibility (only uses, doesn't create)
Application Rules
NEVER do this:
const service = new SomeService() // ❌ Direct instantiation
const result = SomeUtil.staticMethod() // ❌ Static method call
ALWAYS do this:
this.service.doWork() // ✓ Use injected dependency
this.util.calculate() // ✓ Delegate to dependency
Enforcement
During refactoring:
- Scan for
newkeywords inside methods (except value objects) - Scan for static method calls to other classes
- Extract to constructor parameters or method parameters
- Verify tests pass after injection
Fail-Fast Error Handling
Principle: Do not fall back to whatever data is available when expected data is missing. FAIL FAST with clear errors.
The Problem: Silent Fallbacks
// SILENT FAILURE - hides the problem
function extractName(content: Content): string {
return content.eventType ?? content.className ?? 'Unknown'
}
Issues:
- You never know that
eventTypeis missing - 'Unknown' propagates through system
- Hard to debug when it causes issues later
- Masks real problems
The Solution: Explicit Validation
// FAIL FAST - immediate, clear error
function extractName(content: Content): string {
if (!content.eventType) {
throw new Error(
`Expected 'eventType' to exist in content, but it was not found. ` +
`Content keys: [${Object.keys(content).join(', ')}]`
)
}
return content.eventType
}
Benefits:
- Fails immediately at the source
- Clear error message shows exactly what's wrong
- Easy to debug (stack trace points to problem)
- Forces fixing the root cause
Application Rules
NEVER use fallback chains:
value ?? backup ?? default ?? 'unknown' // ❌
ALWAYS validate and fail explicitly:
if (!value) {
throw new Error(`Expected value, got ${value}. Context: ${debug}`)
}
return value // ✓
When to Apply
During implementation:
- When accessing data that "should" exist
- When preconditions must be met
- When invariants must hold
Error message format:
throw new Error(
`Expected [what you expected]. ` +
`Got [what you actually got]. ` +
`Context: [helpful debugging info like available keys, current state, etc.]`
)
Naming Conventions
Principle: Use business domain terminology and intention-revealing names. Never use generic programmer jargon.
Forbidden Generic Names
NEVER use these names:
datautilshelperscommonsharedmanagerhandlerprocessor
These names are meaningless - they tell you nothing about what the code actually does.
Intention-Revealing Names
Instead of generic names, use specific domain language:
// ❌ GENERIC - meaningless
class DataProcessor {
processData(data: any): any {
const utils = new DataUtils()
return utils.transform(data)
}
}
// ✓ INTENTION-REVEALING - clear purpose
class OrderTotalCalculator {
calculateTotal(order: Order): Money {
const taxCalculator = new TaxCalculator()
return taxCalculator.applyTax(order.subtotal, order.taxRate)
}
}
Naming Checklist
For classes:
- Does the name reveal what the class is responsible for?
- Is it a noun (or noun phrase) from the domain?
- Would a domain expert recognize this term?
For methods:
- Does the name reveal what the method does?
- Is it a verb (or verb phrase)?
- Does it describe the business operation?
For variables:
- Does the name reveal what the variable contains?
- Is it specific to this context?
- Could someone understand it without reading the code?
Refactoring Generic Names
When you encounter generic names:
- Understand the purpose: What is this really doing?
- Ask domain experts: What would they call this?
- Extract domain concept: Is there a domain term for this?
- Rename comprehensively: Update all references
- Verify tests: Ensure behavior unchanged
Examples
// ❌ GENERIC
function getData(id: string): any
const userHelpers = new UserHelpers()
const result = processData(input)
// ✓ SPECIFIC
function findCustomerById(customerId: string): Customer
const addressValidator = new AddressValidator()
const confirmedOrder = confirmOrder(pendingOrder)
Type-Driven Design
Principle: Follow Scott Wlaschin's type-driven approach to domain modeling. Express domain concepts using the type system.
Make Illegal States Unrepresentable
Use types to encode business rules:
// ❌ PRIMITIVE OBSESSION - illegal states possible
interface Order {
status: string // Could be any string
shippedDate: Date | null // Could be set when status != 'shipped'
}
// ✓ TYPE-SAFE - illegal states impossible
type UnconfirmedOrder = { type: 'unconfirmed', items: Item[] }
type ConfirmedOrder = { type: 'confirmed', items: Item[], confirmationNumber: string }
type ShippedOrder = { type: 'shipped', items: Item[], confirmationNumber: string, shippedDate: Date }
type Order = UnconfirmedOrder | ConfirmedOrder | ShippedOrder
Avoid Type Escape Hatches
STRICTLY FORBIDDEN without explicit user approval:
anytypeastype assertions (as unknown as,as any,as SomeType)@ts-ignore/@ts-expect-error
Before using these, you MUST get user approval.
There is always a better type-safe solution. These make code unsafe and defeat TypeScript's purpose.
Use the Type System for Validation
// ✓ TYPE-SAFE - validates at compile time
type PositiveNumber = number & { __brand: 'positive' }
function createPositive(value: number): PositiveNumber {
if (value <= 0) {
throw new Error(`Expected positive number, got ${value}`)
}
return value as PositiveNumber
}
// Can only be called with validated positive numbers
function calculateDiscount(price: PositiveNumber, rate: number): Money {
// price is guaranteed positive by type system
}
Prefer Immutability
Principle: Default to immutable data. Mutation is a source of bugs—unexpected changes, race conditions, and difficult debugging.
The Problem: Mutable State
// MUTABLE - hard to reason about
function processOrder(order: Order): void {
order.status = 'processing' // Mutates input!
order.items.push(freeGift) // Side effect!
}
// Caller has no idea their object changed
const myOrder = getOrder()
processOrder(myOrder)
// myOrder is now different - surprise!
The Solution: Return New Values
// IMMUTABLE - predictable
function processOrder(order: Order): Order {
return {
...order,
status: 'processing',
items: [...order.items, freeGift]
}
}
// Caller controls what happens
const myOrder = getOrder()
const processedOrder = processOrder(myOrder)
// myOrder unchanged, processedOrder is new
Application Rules
- Prefer
constoverlet - Prefer spread (
...) over mutation - Prefer
map/filter/reduceoverforEachwith mutation - If you must mutate, make it explicit and contained
YAGNI - You Aren't Gonna Need It
Principle: Don't build features until they're actually needed. Speculative code is waste—it costs time to write, time to maintain, and is often wrong when requirements become clear.
The Problem: Speculative Generalization
// YAGNI VIOLATION - over-engineered for "future" needs
interface PaymentProcessor {
process(payment: Payment): Result
refund(payment: Payment): Result
partialRefund(payment: Payment, amount: Money): Result
schedulePayment(payment: Payment, date: Date): Result
recurringPayment(payment: Payment, schedule: Schedule): Result
// ... 10 more methods "we might need"
}
// Only ONE method is actually used today
The Solution: Build What You Need Now
// YAGNI RESPECTED - minimal interface for current needs
interface PaymentProcessor {
process(payment: Payment): Result
}
// Add refund() when you actually need refunds
// Add scheduling when you actually need scheduling
// Not before
Application Rules
- Build the simplest thing that works
- Add capabilities when requirements demand them, not before
- "But we might need it" is not a requirement
- Unused code is a maintenance burden and a lie about the system
- Delete speculative code ruthlessly
Integration with Other Skills
With TDD Process
This skill is automatically applied during the REFACTOR state of the TDD process:
TDD REFACTOR state post-conditions check:
- ✓ Object calisthenics applied
- ✓ No feature envy
- ✓ Dependencies inverted
- ✓ Names are intention-revealing
TDD Rule #8 enforces fail-fast error handling TDD Rule #9 enforces dependency inversion
Standalone Usage
Activate when:
- Reviewing code for design quality
- Refactoring existing code
- Analyzing coupling and cohesion
- Planning architecture changes
When Tempted to Cut Corners
-
If you're about to use a fallback chain (
??chains): STOP. Silent fallbacks hide bugs. When that'unknown'propagates through your system and causes a failure three layers later, you'll spend hours debugging. Fail fast with a clear error now. -
If you're about to use
anyoras: STOP. You're lying to the compiler to make an error go away. The error is telling you something—your types are wrong. Fix the types, not the symptoms. -
If you're about to instantiate a dependency inside a method: STOP. You're creating tight coupling that makes testing painful and changes risky. Take 30 seconds to inject it through the constructor.
-
If you're about to name something
data,utils, orhandler: STOP. These names are meaningless. What does it actually DO? Name it for its purpose in the domain. Future you will thank present you. -
If you're about to add a getter just to access data: STOP. Ask why the caller needs that data. Can the object do the work instead? Tell, don't ask.
-
If you're about to skip the refactor because "it works": STOP. Working code that's hard to change is technical debt. The refactor IS part of the work, not optional polish.
-
If you're about to write a comment explaining code: STOP. Comments rot—they become lies as code changes. Instead, extract a well-named function, rename a variable, or restructure the code. If the code needs explanation, the code is the problem.
-
If you're about to mutate a parameter: STOP. Return a new value instead. Mutation creates invisible dependencies—the caller doesn't know their data changed. Make data flow explicit.
-
If you're about to build something "we might need later": STOP. You're guessing at future requirements, and you're probably wrong. Build what you need now. Add capabilities when they're actually required.
When NOT to Apply
Don't over-apply these principles:
- Value objects and DTOs may violate object calisthenics (that's okay)
- Simple scripts don't need dependency inversion
- Configuration objects can have getters (they're data, not behavior)
- Test code can be less strict about calisthenics
- Library integration code may need type assertions
Use judgment - principles serve code quality, not vice versa.
Summary Checklist
When refactoring or reviewing code, check:
- Object calisthenics: Does code follow the 9 rules?
- Feature envy: Do methods belong in the right classes?
- Dependencies: Are they injected, not instantiated?
- Error handling: Does code fail-fast with clear errors?
- Naming: Are names intention-revealing (no data/utils/helpers)?
- Types: Do types make illegal states impossible?
- Type safety: No
any, Noasassertions?