| name | Software Design Principles |
| description | 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. |
| version | 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.
🚨 Use Zod for runtime validation. In TypeScript, use Zod schemas for parsing external data, API responses, and user input. Type inference from schemas keeps types and validation in sync.
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?