Episode 9 — System Design / 9.2 — Design Principles

9.2.b — DRY and Other Principles

In one sentence: Beyond SOLID, a handful of time-tested principles — DRY, KISS, YAGNI, Composition over Inheritance, Law of Demeter, and Separation of Concerns — give you a practical toolkit for deciding what to build, how to structure it, and when to stop adding complexity.

Navigation: ← SOLID Principles · Next → Introduction to Design Patterns


Table of Contents

  1. DRY — Don't Repeat Yourself
  2. KISS — Keep It Simple, Stupid
  3. YAGNI — You Ain't Gonna Need It
  4. Composition over Inheritance
  5. Law of Demeter (Principle of Least Knowledge)
  6. Separation of Concerns (SoC)
  7. Over-Engineering vs Under-Engineering
  8. When Each Principle Applies — Decision Matrix
  9. Key Takeaways
  10. Explain-It Challenge

1. DRY — Don't Repeat Yourself

"Every piece of knowledge must have a single, unambiguous, authoritative representation within a system." — Andy Hunt & Dave Thomas, The Pragmatic Programmer

DRY is not about eliminating duplicate lines of code. It's about eliminating duplicate knowledge — the same business rule, algorithm, or decision expressed in multiple places.

What DRY Really Means

DRY is about KNOWLEDGE, not about CODE.

Two identical lines of code that represent DIFFERENT concepts
are NOT a DRY violation.

Two different-looking blocks that encode the SAME business rule
ARE a DRY violation.

DRY Violation: Duplicated Business Logic

// BEFORE: The same validation rule is expressed in THREE places

// In the signup route
app.post('/signup', (req, res) => {
  if (req.body.password.length < 8) {
    return res.status(400).json({ error: 'Password must be at least 8 characters' });
  }
  if (!/[A-Z]/.test(req.body.password)) {
    return res.status(400).json({ error: 'Password must contain uppercase' });
  }
  // ...
});

// In the password reset route
app.post('/reset-password', (req, res) => {
  if (req.body.newPassword.length < 8) {
    return res.status(400).json({ error: 'Password must be at least 8 characters' });
  }
  if (!/[A-Z]/.test(req.body.newPassword)) {
    return res.status(400).json({ error: 'Password must contain uppercase' });
  }
  // ...
});

// In the admin panel
app.post('/admin/create-user', (req, res) => {
  if (req.body.password.length < 8) {
    return res.status(400).json({ error: 'Password must be at least 8 characters' });
  }
  if (!/[A-Z]/.test(req.body.password)) {
    return res.status(400).json({ error: 'Password must contain uppercase' });
  }
  // ...
});

// Problem: Security team says "add a special character requirement."
// You update signup... forget reset-password... admin panel stays stale.
// Now your system has INCONSISTENT password rules.
// AFTER: One authoritative source of truth for password validation

interface ValidationResult {
  valid: boolean;
  errors: string[];
}

function validatePassword(password: string): ValidationResult {
  const errors: string[] = [];
  
  if (password.length < 8) {
    errors.push('Password must be at least 8 characters');
  }
  if (!/[A-Z]/.test(password)) {
    errors.push('Password must contain an uppercase letter');
  }
  if (!/[!@#$%^&*]/.test(password)) {
    errors.push('Password must contain a special character');
  }
  
  return { valid: errors.length === 0, errors };
}

// All three routes use the same function
app.post('/signup', (req, res) => {
  const result = validatePassword(req.body.password);
  if (!result.valid) return res.status(400).json({ errors: result.errors });
  // ...
});

app.post('/reset-password', (req, res) => {
  const result = validatePassword(req.body.newPassword);
  if (!result.valid) return res.status(400).json({ errors: result.errors });
  // ...
});

// Change the rule in ONE place, it's consistent EVERYWHERE.

When Identical Code Is NOT a DRY Violation

// These two functions look similar but represent DIFFERENT business concepts

function calculateEmployeeTax(salary: number): number {
  return salary * 0.30;  // Employee income tax rate
}

function calculateSalesTax(amount: number): number {
  return amount * 0.30;  // Sales tax rate (coincidentally the same)
}

// DO NOT merge these into one function!
// They look the same today but change for DIFFERENT reasons:
// - Employee tax changes when tax law changes
// - Sales tax changes when the business enters a new state/country
// Merging them creates COUPLING between unrelated concepts.

DRY for Configuration

// BEFORE: Magic numbers scattered everywhere
app.post('/api/upload', (req, res) => {
  if (req.file.size > 5242880) { /* ... */ }  // What is 5242880?
});
app.post('/api/avatar', (req, res) => {
  if (req.file.size > 5242880) { /* ... */ }  // Same magic number
});

// AFTER: Single source of truth
const LIMITS = {
  MAX_FILE_SIZE: 5 * 1024 * 1024,  // 5 MB — self-documenting
  MAX_AVATAR_SIZE: 2 * 1024 * 1024, // 2 MB
  MAX_FILES_PER_UPLOAD: 10,
} as const;

app.post('/api/upload', (req, res) => {
  if (req.file.size > LIMITS.MAX_FILE_SIZE) { /* ... */ }
});

The WET Spectrum (Write Everything Twice)

Some teams follow the "Rule of Three": don't abstract until you see the pattern three times. This avoids premature abstraction.

1st occurrence: Just write it.
2nd occurrence: Note the duplication, but leave it.
3rd occurrence: Now extract a shared abstraction.

2. KISS — Keep It Simple, Stupid

"Simplicity is the ultimate sophistication." — Leonardo da Vinci

The simplest solution that works is usually the best. Complexity is a cost, not a feature.

KISS Violation: Over-Clever Code

// BEFORE: "Look how clever I am!"
const getActiveAdminEmails = (users: User[]) =>
  users.reduce((acc, u) => 
    u.role === 'admin' && u.active 
      ? [...acc, u.email] 
      : acc, 
    [] as string[]
  );

// AFTER: "Look how clear I am."
function getActiveAdminEmails(users: User[]): string[] {
  return users
    .filter(user => user.role === 'admin' && user.active)
    .map(user => user.email);
}
// Same result, but anyone can read it in 2 seconds.

KISS Violation: Premature Architecture

// BEFORE: "What if we need to support multiple queue providers?"
// (You have ONE queue. You use Redis. You will use Redis for the next 3 years.)

interface QueueProvider {
  enqueue(job: Job): Promise<void>;
  dequeue(): Promise<Job | null>;
  acknowledge(jobId: string): Promise<void>;
}

interface QueueSerializer {
  serialize(job: Job): string;
  deserialize(data: string): Job;
}

interface QueueRetryStrategy {
  shouldRetry(job: Job, attempts: number): boolean;
  getDelay(attempts: number): number;
}

class QueueManager {
  constructor(
    private provider: QueueProvider,
    private serializer: QueueSerializer,
    private retryStrategy: QueueRetryStrategy,
    private logger: Logger,
    private metrics: MetricsCollector
  ) {}
  // 200 lines of orchestration...
}

// AFTER: Just use Bull/BullMQ. Done.
import { Queue, Worker } from 'bullmq';

const emailQueue = new Queue('emails', { connection: redisConfig });

emailQueue.add('welcome', { userId: '123', template: 'welcome' });

new Worker('emails', async (job) => {
  await sendEmail(job.data);
}, { connection: redisConfig });

KISS Decision Framework

SituationSimple ApproachOver-Engineered Approach
Need a config file.env + dotenvCustom YAML parser with schema validation and hot reloading
Need to call an APIfetch() or axiosCustom HTTP client with retry, circuit breaker, and request signing
Need a state machineObject with status field + switchFull state machine library with transition guards and plugins
Need to schedule a tasksetInterval or cron jobCustom job scheduler with priority queues and distributed locking

Rule of thumb: Use libraries and existing tools before building your own. Build your own only when existing solutions genuinely don't fit.


3. YAGNI — You Ain't Gonna Need It

"Always implement things when you actually need them, never when you just foresee that you need them." — Ron Jeffries

YAGNI fights speculative complexity — code written "just in case" that never gets used.

YAGNI Violation: Building for a Future That Never Comes

// BEFORE: "What if we need multi-tenancy someday?"

interface TenantConfig {
  tenantId: string;
  databaseUrl: string;
  features: string[];
  theme: ThemeConfig;
  customDomain?: string;
}

class TenantAwareRepository<T> {
  constructor(
    private tenantResolver: TenantResolver,
    private connectionPool: TenantConnectionPool,
    private cacheStrategy: TenantCacheStrategy
  ) {}

  async find(id: string): Promise<T> {
    const tenant = this.tenantResolver.getCurrentTenant();
    const connection = await this.connectionPool.getConnection(tenant.tenantId);
    const cacheKey = `${tenant.tenantId}:${id}`;
    // ... 50 more lines of tenant-aware logic
  }
}

// Reality: You have ONE customer. You will have ONE customer for the next year.
// You just spent 3 weeks building multi-tenancy nobody asked for.

// AFTER: Build what you need NOW
class UserRepository {
  async find(id: string): Promise<User | null> {
    const result = await db.query('SELECT * FROM users WHERE id = $1', [id]);
    return result.rows[0] || null;
  }
}

// When you actually get a second tenant, THEN design multi-tenancy —
// and you'll design it better because you'll understand the real requirements.

The Cost of YAGNI Violations

Speculative feature written today:
  + 2 days to build
  + 1 day to test
  + Ongoing maintenance cost
  + Adds complexity that slows down REAL features
  + 70% chance it's never used
  + If it IS used, requirements have changed and it needs rewriting anyway

Feature built when actually needed:
  + Built with real requirements (not guesses)
  + Designed with actual use cases
  + Minimal wasted effort

YAGNI Does NOT Mean

  • "Never plan ahead" — Architecture decisions (database choice, API style) DO need forethought.
  • "Don't write clean code" — YAGNI is about features, not quality.
  • "Don't use design patterns" — Use patterns when you have a real problem, not a hypothetical one.
YAGNI applies to: Features, abstractions, support for hypothetical scenarios
YAGNI does NOT apply to: Tests, error handling, security, documentation

4. Composition over Inheritance

"Favor object composition over class inheritance." — Gang of Four, Design Patterns

Inheritance creates rigid hierarchies. Composition creates flexible, interchangeable parts.

The Inheritance Problem: The Diamond of Death

// BEFORE: Inheritance hierarchy that becomes a trap

class Animal {
  eat() { console.log('Eating'); }
  sleep() { console.log('Sleeping'); }
}

class FlyingAnimal extends Animal {
  fly() { console.log('Flying'); }
}

class SwimmingAnimal extends Animal {
  swim() { console.log('Swimming'); }
}

// Duck can fly AND swim. Which do we extend?
// JavaScript doesn't have multiple inheritance!
class Duck extends FlyingAnimal {
  // We get fly() from FlyingAnimal, but we also need swim()!
  swim() { console.log('Swimming'); }  // Manually duplicate — violates DRY
}

// Penguin swims but doesn't fly...
class Penguin extends SwimmingAnimal {
  // OK, but what if penguins need to waddle like a LandAnimal?
}

// As you add more behaviors (running, climbing, burrowing),
// the hierarchy becomes impossible to manage.

The Fix: Composition with Mixins/Interfaces

// AFTER: Compose behaviors instead of inheriting them

// Define behaviors as independent, composable units
interface Swimmer {
  swim(): void;
}

interface Flyer {
  fly(): void;
}

interface Runner {
  run(): void;
}

// Behavior implementations (reusable)
const swimmable = {
  swim() { console.log(`${this.name} is swimming`); }
};

const flyable = {
  fly() { console.log(`${this.name} is flying`); }
};

const runnable = {
  run() { console.log(`${this.name} is running`); }
};

// Compose behaviors freely
class Duck implements Swimmer, Flyer, Runner {
  name = 'Duck';
  swim = swimmable.swim.bind(this);
  fly = flyable.fly.bind(this);
  run = runnable.run.bind(this);
}

class Penguin implements Swimmer, Runner {
  name = 'Penguin';
  swim = swimmable.swim.bind(this);
  run = runnable.run.bind(this);
  // No fly — penguins can't fly, and we don't pretend they can
}

Composition in Practice: TypeScript Class Composition

// Real-world example: Building user notification preferences

// INHERITANCE APPROACH (rigid)
class BaseNotifier {
  notify(message: string) { /* ... */ }
}
class EmailNotifier extends BaseNotifier { /* ... */ }
class EmailAndSMSNotifier extends EmailNotifier { /* adds SMS somehow */ }
class EmailAndSMSAndPushNotifier extends EmailAndSMSNotifier { /* adds push somehow */ }
// Explosion of classes for every combination!

// COMPOSITION APPROACH (flexible)
interface NotificationChannel {
  send(userId: string, message: string): Promise<void>;
}

class EmailChannel implements NotificationChannel {
  async send(userId: string, message: string): Promise<void> {
    console.log(`Email to ${userId}: ${message}`);
  }
}

class SMSChannel implements NotificationChannel {
  async send(userId: string, message: string): Promise<void> {
    console.log(`SMS to ${userId}: ${message}`);
  }
}

class PushChannel implements NotificationChannel {
  async send(userId: string, message: string): Promise<void> {
    console.log(`Push to ${userId}: ${message}`);
  }
}

class SlackChannel implements NotificationChannel {
  async send(userId: string, message: string): Promise<void> {
    console.log(`Slack to ${userId}: ${message}`);
  }
}

// Compose any combination dynamically
class NotificationService {
  private channels: NotificationChannel[] = [];

  addChannel(channel: NotificationChannel): void {
    this.channels.push(channel);
  }

  async notifyAll(userId: string, message: string): Promise<void> {
    await Promise.all(
      this.channels.map(channel => channel.send(userId, message))
    );
  }
}

// Usage — compose at runtime based on user preferences
const notifier = new NotificationService();
notifier.addChannel(new EmailChannel());
notifier.addChannel(new PushChannel());

// User changes preferences? Add/remove channels. No class hierarchy changes.
if (userPreferences.wantsSMS) {
  notifier.addChannel(new SMSChannel());
}

When Inheritance IS Appropriate

Inheritance is not evil — it's just overused. Use it when:

Use Inheritance WhenUse Composition When
There's a genuine is-a relationshipThere's a has-a or uses-a relationship
Subtypes truly share behavior AND identityObjects share some behaviors but not others
The hierarchy is shallow (2-3 levels max)You need to combine behaviors flexibly
You're extending a framework class (React Component)You're building your own domain model

5. Law of Demeter (Principle of Least Knowledge)

"Only talk to your immediate friends. Don't talk to strangers."

A method should only call methods on:

  1. Its own object (this)
  2. Objects passed as parameters
  3. Objects it creates
  4. Its direct component objects (properties)

The Violation: Train Wrecks

// BEFORE: Chaining through objects you don't own — "train wreck" code

// Getting a user's city requires knowing the internal structure of 4 objects
const city = order.getCustomer().getAddress().getCity().getName();

// What happens when:
// - Customer changes how addresses are stored? Code breaks.
// - Address becomes optional? NullPointerException.
// - City is renamed to Municipality? Every caller must change.

// This function knows WAY too much about the internal structure
function getShippingLabel(order: Order): string {
  const customer = order.getCustomer();
  const address = customer.getAddress();
  const city = address.getCity();
  const state = address.getState();
  const zip = address.getZipCode();
  
  return `${customer.getName()}\n${address.getStreet()}\n${city}, ${state} ${zip}`;
}
// AFTER: Each object exposes what its callers need

class Order {
  getShippingLabel(): string {
    return this.customer.getFormattedAddress();
  }
  
  getDeliveryCity(): string {
    return this.customer.getCity();
  }
}

class Customer {
  getFormattedAddress(): string {
    return this.address.format();
  }
  
  getCity(): string {
    return this.address.getCity();
  }
}

class Address {
  format(): string {
    return `${this.street}\n${this.city}, ${this.state} ${this.zip}`;
  }
  
  getCity(): string {
    return this.city;
  }
}

// Caller only talks to its immediate friend (order)
const label = order.getShippingLabel();

Law of Demeter in Express Middleware

// BAD: Reaching deep into req object internals
function getUsername(req: Request): string {
  return req.session.passport.user.profile.displayName;
  // If ANY of these nested objects changes, this breaks
}

// GOOD: Provide a clean accessor
function getUsername(req: Request): string {
  return req.user?.displayName ?? 'Anonymous';
  // Middleware sets req.user — we don't care how authentication works internally
}

When to Bend the Law of Demeter

The Law of Demeter is a guideline, not a law. It's acceptable to chain through:

  • Data structuresuser.address.city on a plain data object is fine
  • Fluent APIsquery.where('age', '>', 18).orderBy('name').limit(10) (each method returns the same builder)
  • Functional chainsarray.filter(...).map(...).reduce(...) (each returns a new value)

The law targets behavioral objects, not data containers or builder patterns.


6. Separation of Concerns (SoC)

"Divide your program such that each section addresses a separate concern." — Edsger W. Dijkstra

A concern is a distinct area of functionality. SoC says that code handling different concerns should be in different modules.

SoC in a Full-Stack Application

┌─────────────────────────────────────────────┐
│                  FRONTEND                    │
│  ┌──────────┐ ┌──────────┐ ┌──────────┐    │
│  │   View   │ │  State   │ │  Routing │    │
│  │ (React)  │ │ (Redux)  │ │ (Router) │    │
│  └──────────┘ └──────────┘ └──────────┘    │
├─────────────────────────────────────────────┤
│                   API LAYER                  │
│  ┌──────────┐ ┌──────────┐ ┌──────────┐    │
│  │  Routes  │ │  Auth    │ │ Validation│   │
│  │          │ │Middleware│ │Middleware │    │
│  └──────────┘ └──────────┘ └──────────┘    │
├─────────────────────────────────────────────┤
│              BUSINESS LOGIC                  │
│  ┌──────────┐ ┌──────────┐ ┌──────────┐    │
│  │  User    │ │  Order   │ │ Payment  │    │
│  │ Service  │ │ Service  │ │ Service  │    │
│  └──────────┘ └──────────┘ └──────────┘    │
├─────────────────────────────────────────────┤
│                DATA ACCESS                   │
│  ┌──────────┐ ┌──────────┐ ┌──────────┐    │
│  │  User    │ │  Order   │ │ Payment  │    │
│  │  Repo    │ │  Repo    │ │  Repo    │    │
│  └──────────┘ └──────────┘ └──────────┘    │
└─────────────────────────────────────────────┘

SoC Violation and Fix

// BEFORE: One function handles HTTP, validation, business logic, and data access

app.post('/api/orders', async (req, res) => {
  // CONCERN 1: HTTP parsing
  const { items, address, paymentMethod } = req.body;
  
  // CONCERN 2: Validation
  if (!items || items.length === 0) {
    return res.status(400).json({ error: 'No items' });
  }
  if (!address) {
    return res.status(400).json({ error: 'No address' });
  }
  
  // CONCERN 3: Business logic
  let total = 0;
  for (const item of items) {
    const product = await db.query('SELECT price FROM products WHERE id = $1', [item.id]);
    total += product.rows[0].price * item.quantity;
  }
  const tax = total * 0.08;
  const shipping = total > 50 ? 0 : 9.99;
  
  // CONCERN 4: Payment processing
  const charge = await stripe.charges.create({
    amount: Math.round((total + tax + shipping) * 100),
    currency: 'usd',
    source: paymentMethod,
  });
  
  // CONCERN 5: Data persistence
  await db.query(
    'INSERT INTO orders (items, total, tax, shipping, charge_id) VALUES ($1, $2, $3, $4, $5)',
    [JSON.stringify(items), total, tax, shipping, charge.id]
  );
  
  // CONCERN 6: Notification
  await sendOrderConfirmationEmail(req.user.email, { items, total, tax, shipping });
  
  res.status(201).json({ orderId: charge.id, total: total + tax + shipping });
});
// AFTER: Each concern is separated into its own layer

// Validation (concern: input correctness)
const validateOrder = (req: Request, res: Response, next: NextFunction) => {
  const { error } = orderSchema.validate(req.body);
  if (error) return res.status(400).json({ error: error.message });
  next();
};

// Controller (concern: HTTP interface)
class OrderController {
  constructor(private orderService: OrderService) {}

  create = async (req: Request, res: Response) => {
    try {
      const order = await this.orderService.placeOrder(req.body, req.user);
      res.status(201).json(order);
    } catch (err) {
      if (err instanceof PaymentError) {
        res.status(402).json({ error: err.message });
      } else {
        res.status(500).json({ error: 'Order failed' });
      }
    }
  };
}

// Service (concern: business logic)
class OrderService {
  constructor(
    private orderRepo: OrderRepository,
    private productRepo: ProductRepository,
    private paymentService: PaymentService,
    private notificationService: NotificationService
  ) {}

  async placeOrder(data: CreateOrderDTO, user: User): Promise<OrderResult> {
    const total = await this.calculateTotal(data.items);
    const tax = total * 0.08;
    const shipping = total > 50 ? 0 : 9.99;
    
    const charge = await this.paymentService.charge(
      total + tax + shipping,
      data.paymentMethod
    );
    
    const order = await this.orderRepo.create({
      items: data.items,
      total, tax, shipping,
      chargeId: charge.id,
    });
    
    await this.notificationService.sendOrderConfirmation(user.email, order);
    
    return { orderId: order.id, total: total + tax + shipping };
  }

  private async calculateTotal(items: OrderItem[]): Promise<number> {
    let total = 0;
    for (const item of items) {
      const product = await this.productRepo.findById(item.id);
      total += product.price * item.quantity;
    }
    return total;
  }
}

// Repository (concern: data access)
class OrderRepository {
  async create(data: CreateOrderData): Promise<Order> {
    const result = await db.query(
      'INSERT INTO orders (items, total, tax, shipping, charge_id) VALUES ($1, $2, $3, $4, $5) RETURNING *',
      [JSON.stringify(data.items), data.total, data.tax, data.shipping, data.chargeId]
    );
    return result.rows[0];
  }
}

// Route — thin wiring
app.post('/api/orders', authenticate, validateOrder, orderController.create);

7. Over-Engineering vs Under-Engineering

Finding the right balance is the hardest skill in software design.

Signs of Over-Engineering

// Over-engineered: A "Hello World" API with enterprise architecture

// AbstractGreetingStrategyFactory.ts
// GreetingStrategyFactoryImpl.ts
// IGreetingService.ts
// GreetingServiceImpl.ts
// GreetingDTO.ts
// GreetingMapper.ts
// GreetingValidator.ts
// GreetingRepository.ts
// GreetingController.ts
// GreetingMiddleware.ts
// GreetingConfig.ts

// All to do this:
app.get('/hello', (req, res) => res.json({ message: 'Hello, World!' }));

Signs of Under-Engineering

// Under-engineered: An e-commerce platform in one file

// app.js — 3000 lines
// - Database queries inline
// - Business logic mixed with HTTP handling
// - No error handling
// - Hardcoded credentials
// - No validation
// - Copy-pasted code everywhere

The Balance Spectrum

Under-Engineered          Just Right              Over-Engineered
──────────────────────────────────────────────────────────────────
Spaghetti code           Clean, readable          Enterprise Java
No abstractions          Meaningful abstractions   Abstraction for its own sake
No patterns              Patterns where needed     Patterns everywhere
"It works"               "It works and is clear"   "It works but nobody can follow it"
1 file, 3000 lines       Right-sized modules       200 files, 50 lines each
Changes break everything  Changes are localized     Changes require touching 12 files

Decision Framework

FactorLean Toward SimpleLean Toward Architecture
Team size1-2 developers5+ developers
Expected lifespanPrototype / script / one-offProduction system for years
Change frequencyRarely changesChanges weekly
Domain complexitySimple CRUDComplex business rules
CriticalityInternal toolPayment processing, healthcare

The "Will I Thank Myself?" Test

Before adding abstraction, ask:

  1. "Am I solving a problem I HAVE, or a problem I MIGHT have?" If might, wait (YAGNI).
  2. "Can a new team member understand this in 10 minutes?" If not, simplify (KISS).
  3. "If I delete this abstraction, does the code still work?" If yes, it's unnecessary.
  4. "Am I adding this because the code NEEDS it, or because I SAW it in a blog post?" Be honest.

8. When Each Principle Applies — Decision Matrix

PrincipleApply WhenDon't Over-Apply When
DRYSame business rule in 3+ placesTwo code blocks look similar but represent different concepts
KISSAlways — default to simplicityNever stop applying; complexity should be justified
YAGNIYou're tempted to build "just in case"Making fundamental architecture decisions (DB, API style)
Composition > InheritanceYou need to combine behaviors flexiblyGenuine is-a relationships with shallow hierarchies
Law of DemeterBehavioral objects with encapsulationData objects, fluent APIs, functional chains
Separation of ConcernsCode grows beyond one person's comprehensionTiny scripts or prototypes

Principles That Can Conflict

Principle APrinciple BConflictResolution
DRYKISSDRY abstraction makes code harder to readIf the duplication is simple and local, keep it
DRYYAGNIDRY requires building a shared abstractionWait for the Rule of Three
SoCKISSSeparation adds more files and indirectionSeparate only when complexity justifies it
OCPYAGNIOCP says design for extension; YAGNI says don'tBuild the extension point when you need the 2nd extension

9. Key Takeaways

  1. DRY is about knowledge, not code. Two functions with identical syntax but different purposes are NOT a DRY violation. Two different-looking functions encoding the same business rule ARE.
  2. KISS is the meta-principle. When in doubt, choose the simpler option. Cleverness is a liability in a codebase maintained by a team.
  3. YAGNI saves you from your future self. The features you anticipate needing are usually wrong. Build for today's requirements and refactor when real requirements emerge.
  4. Composition over Inheritance gives you flexibility. Compose small, focused behaviors instead of building deep class trees. Mixins, interfaces, and injection are your tools.
  5. Law of Demeter limits ripple effects. The fewer objects your code knows about, the fewer things can break when something changes.
  6. Separation of Concerns is about team scale. At small scale it's nice; at large scale it's essential. Separate along boundaries that change independently.
  7. Over-engineering is as dangerous as under-engineering. The right level of abstraction depends on team size, expected lifespan, change frequency, and domain complexity.
  8. Principles conflict. Learning when to prioritize one over another is the mark of experience.

10. Explain-It Challenge

Test your understanding by explaining these scenarios:

  1. DRY debate: A teammate extracts two validation functions into one because they "look the same." One validates user signups, the other validates payment details. Both happen to check for non-empty strings and valid emails. Is this a good DRY extraction? Why or why not?

  2. KISS vs Enterprise: Your tech lead wants to implement a full CQRS (Command Query Responsibility Segregation) pattern for a CRUD app with 100 users. Make the case for KISS.

  3. YAGNI judgment call: You're building an API that currently serves a web app. The product roadmap shows a mobile app in 6 months. Should you build the mobile-specific endpoints now? Where does YAGNI apply and where doesn't it?

  4. Composition challenge: You have Printable, Scannable, Faxable, and Copyable capabilities for office machines. Design a system using composition that lets you create a BasicPrinter (print only), MultiFunctionPrinter (all four), and ScannerOnly (scan only).


Next → 9.2.c — Introduction to Design Patterns