ntcoding/software-design-principles
Object-oriented design principles including object calisthenics, dependency inversion, fail-fast error handling, feature envy detection, and intention-revealing naming. Triggers on: writing new classes or functions, refactoring, code review, 'clean up', method longer than 10 lines, feature envy, primitive obsession, deep nesting.
npx skills add https://github.com/NTCoding/claude-skillz --skill software-design-principles
Professional software design patterns and principles for writing maintainable, well-structured code.
🚨 Fail-fast over silent fallbacks. Never use fallback chains (value ?? backup ?? 'unknown'). If data should exist, validate and throw a clear error.
🚨 Strive for maximum type-safety. 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.
Well-designed, maintainable code is far more important than getting things done quickly. Every design decision should favor:
Never write comments - write expressive code instead.
Apply object calisthenics principles:
Method uses another class's data more than its own? Move it there.
// ❌ FEATURE ENVY - obsessed with Order's data
class InvoiceGenerator {
generate(order: Order): Invoice {
const total = order.getItems().map(i => i.getPrice() * i.getQuantity()).reduce((a,b) => a+b, 0)
return new Invoice(total + total * order.getTaxRate() + order.calculateShipping())
}
}
// ✅ Move logic to the class it envies
class Order {
calculateTotal(): number { /* uses this.items, this.taxRate */ }
}
class InvoiceGenerator {
generate(order: Order): Invoice { return new Invoice(order.calculateTotal()) }
}
Detection: Count external vs own references. More external? Feature envy.
Don't instantiate dependencies inside methods. Inject them.
// ❌ TIGHT COUPLING
class OrderProcessor {
process(order: Order): void {
const validator = new OrderValidator() // Hard to test/change
const emailer = new EmailService() // Hidden dependency
}
}
// ✅ LOOSE COUPLING
class OrderProcessor {
constructor(private validator: OrderValidator, private emailer: EmailService) {}
process(order: Order): void {
this.validator.isValid(order) // Injected, mockable
this.emailer.send(...) // Explicit dependency
}
}
Scan for: new X() inside methods, static method calls. Extract to constructor.
NEVER use fallback chains:
value ?? backup ?? default ?? 'unknown' // ❌
Validate and throw clear errors instead:
// ❌ SILENT FAILURE - hides problems
return content.eventType ?? content.className ?? 'Unknown'
// ✅ FAIL FAST - immediate, debuggable
if (!content.eventType) {
throw new Error(`Expected 'eventType', got undefined. Keys: [${Object.keys(content)}]`)
}
return content.eventType
Error format: Expected [X]. Got [Y]. Context: [debugging info]
Principle: Use business domain terminology and intention-revealing names. Never use generic programmer jargon.
NEVER use these names:
datautilshelperscommonsharedmanagerhandlerprocessorThese names are meaningless - they tell you nothing about what the code actually does.
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 {
return taxCalculator.applyTax(order.subtotal, order.taxRate)
}
}
For classes:
For methods:
For variables:
When you encounter generic names:
Principle: Follow Scott Wlaschin's type-driven approach to domain modeling. Express domain concepts using the type system.
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
STRICTLY FORBIDDEN without explicit user approval:
any typeas type assertions (as unknown as, as any, as SomeType)@ts-ignore / @ts-expect-errorThere is always a better type-safe solution. These make code unsafe and defeat TypeScript's purpose.
// ✓ 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
}
Principle: Default to immutable data. Mutation is a source of bugs—unexpected changes, race conditions, and difficult debugging.
// 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!
// 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
const over let...) over mutationmap/filter/reduce over forEach with mutationPrinciple: 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.
// 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
STOP if you're about to:
?? chains → fail fast with clear error insteadany or as → fix the types, not the symptomsnew X() inside a method → inject through constructordata, utils, handler → use domain languageTake ntcoding/software-design-principles from the repository into ~/.claude/skills for personal
use, or into .claude/skills inside a project.
The agent identifies a skill by the name field in its header. Two skills with the
same name cannot sit side by side — one of them will be ignored.