Episode 9 — System Design / 9.2 — Design Principles

9.2.d — Writing Extensible Code

In one sentence: Extensible code is designed so that new features, behaviors, and integrations can be added without rewriting or destabilizing existing functionality — achieved through plugin architectures, strategy patterns, dependency injection, configuration, and feature flags.

Navigation: ← Introduction to Design Patterns · Overview →


Table of Contents

  1. What "Extensible" Really Means
  2. Designing for Change
  3. Plugin Architectures
  4. Strategy Pattern for Extensibility
  5. Configuration over Code
  6. Feature Flags
  7. Dependency Injection (DI)
  8. Real-World Extensibility Examples
  9. Extensibility Anti-Patterns
  10. Key Takeaways
  11. Explain-It Challenge

1. What "Extensible" Really Means

Extensibility is the ability to add new functionality with minimal changes to existing code. It's the practical outcome of applying OCP (Open/Closed Principle) throughout your system.

Extensible vs Not Extensible

NOT EXTENSIBLE:
  "To add a new payment method, I need to modify the PaymentProcessor class,
   update the switch statement, add a new case to the validation function,
   change the database schema, and update three test files."

EXTENSIBLE:
  "To add a new payment method, I create a new class that implements
   the PaymentStrategy interface and register it. Done."

The Extension Spectrum

Hardcoded ←————————————————————————————————→ Fully Extensible

1. Hardcoded        Everything is inline, changing anything means editing source
2. Configurable     Behavior controlled by environment variables or config files
3. Parameterized    Behavior controlled by function arguments and options
4. Pluggable        New behavior added by implementing interfaces and registering
5. Fully Extensible Runtime loading of plugins, hot-swappable components

Most production code should aim for levels 2-4. Level 5 is only for platforms and frameworks.

The Cost of Extensibility

Extensibility is not free. Every extension point adds:

  • Indirection — More interfaces, more files, harder to trace execution
  • Abstraction tax — New developers must understand the extension mechanism
  • Testing surface — Each extension point needs tests for the contract

Rule: Add extension points where you know things will change. Don't add them "just in case" (YAGNI).


2. Designing for Change

The key insight: some parts of your system change frequently, others are stable. Good design isolates the parts that change.

Identify What Varies

In an e-commerce system:

STABLE (changes rarely):           VARIES (changes often):
- Order workflow                   - Payment methods
- User authentication flow         - Notification channels
- Database connection logic        - Discount/promotion rules
- HTTP request/response cycle      - Shipping rate calculations
                                   - Tax rules by region
                                   - UI themes
                                   - Feature availability

Encapsulate What Varies

// BEFORE: Tax calculation is inline — every regional change requires code edits

function calculateOrderTotal(order: Order, region: string): number {
  let tax: number;
  
  if (region === 'US-CA') {
    tax = order.subtotal * 0.0725;
  } else if (region === 'US-NY') {
    tax = order.subtotal * 0.08;
  } else if (region === 'UK') {
    tax = order.subtotal * 0.20;
  } else if (region === 'DE') {
    tax = order.subtotal * 0.19;
  } else if (region === 'JP') {
    tax = order.subtotal * 0.10;
  }
  // ... 50 more regions
  
  return order.subtotal + tax;
}

// AFTER: Tax calculation is encapsulated — new regions are new data, not new code

interface TaxCalculator {
  calculate(subtotal: number): number;
}

class PercentageTax implements TaxCalculator {
  constructor(private rate: number) {}
  calculate(subtotal: number): number {
    return subtotal * this.rate;
  }
}

class CompoundTax implements TaxCalculator {
  constructor(private federal: number, private state: number) {}
  calculate(subtotal: number): number {
    const federalTax = subtotal * this.federal;
    return federalTax + (subtotal + federalTax) * this.state;
  }
}

// Tax rules are DATA, not CODE
const taxRules = new Map<string, TaxCalculator>([
  ['US-CA', new PercentageTax(0.0725)],
  ['US-NY', new PercentageTax(0.08)],
  ['UK', new PercentageTax(0.20)],
  ['DE', new PercentageTax(0.19)],
  ['JP', new PercentageTax(0.10)],
  ['CA-QC', new CompoundTax(0.05, 0.09975)],
]);

// Adding a new region: add ONE line to the map. No code changes.
function calculateOrderTotal(order: Order, region: string): number {
  const calculator = taxRules.get(region);
  if (!calculator) throw new Error(`No tax rules for region: ${region}`);
  return order.subtotal + calculator.calculate(order.subtotal);
}

The "Seam" Concept

A seam is a place in your code where you can alter behavior without editing the code itself. Extension points are seams.

// Seams in a typical Express application:

// SEAM 1: Middleware — add behavior to the request pipeline
app.use(newMiddleware());

// SEAM 2: Route handlers — add new endpoints
app.use('/api/v2/widgets', widgetRouter);

// SEAM 3: Event hooks — react to application events
app.on('order:created', newEventHandler);

// SEAM 4: Configuration — change behavior via environment
const cacheDriver = config.get('CACHE_DRIVER'); // 'redis' | 'memory' | 'none'

// SEAM 5: Dependency injection — swap implementations
const service = new OrderService(
  config.get('DB') === 'postgres' ? new PostgresRepo() : new MongoRepo()
);

3. Plugin Architectures

A plugin architecture lets you add functionality at runtime without modifying the core system.

How Plugins Work

┌─────────────────────────────────────────────────┐
│                    CORE SYSTEM                   │
│                                                  │
│   ┌──────────┐   ┌──────────┐   ┌──────────┐   │
│   │  Plugin   │   │  Plugin   │   │  Plugin   │   │
│   │ Registry  │   │ Lifecycle │   │   API     │   │
│   └──────────┘   └──────────┘   └──────────┘   │
│                                                  │
│   ┌──────────────────────────────────────────┐   │
│   │            Plugin Interface               │   │
│   │  - name: string                           │   │
│   │  - version: string                        │   │
│   │  - init(app: App): void                   │   │
│   │  - destroy?(): void                       │   │
│   └──────────────────────────────────────────┘   │
│                      ▲  ▲  ▲                     │
└──────────────────────┼──┼──┼─────────────────────┘
                       │  │  │
              ┌────────┘  │  └────────┐
              │           │           │
        ┌─────────┐ ┌─────────┐ ┌─────────┐
        │ Plugin A│ │ Plugin B│ │ Plugin C│
        │ (Auth)  │ │(Logging)│ │(Metrics)│
        └─────────┘ └─────────┘ └─────────┘

Building a Plugin System in TypeScript

// Step 1: Define the plugin interface (the contract)
interface Plugin {
  readonly name: string;
  readonly version: string;
  
  // Called when the plugin is registered
  init(app: Application): void | Promise<void>;
  
  // Called when the plugin is removed (optional cleanup)
  destroy?(): void | Promise<void>;
}

// Step 2: Define what the application exposes to plugins
interface Application {
  // Hooks — plugins can tap into these
  on(event: string, handler: (...args: any[]) => void): void;
  
  // Services — plugins can extend these
  registerRoute(method: string, path: string, handler: RequestHandler): void;
  registerMiddleware(middleware: RequestHandler): void;
  
  // Config — plugins can read/write config
  getConfig(key: string): any;
  setConfig(key: string, value: any): void;
}

// Step 3: Build the plugin manager
class PluginManager {
  private plugins = new Map<string, Plugin>();
  private app: Application;

  constructor(app: Application) {
    this.app = app;
  }

  async register(plugin: Plugin): Promise<void> {
    if (this.plugins.has(plugin.name)) {
      throw new Error(`Plugin "${plugin.name}" is already registered`);
    }
    
    console.log(`Loading plugin: ${plugin.name} v${plugin.version}`);
    await plugin.init(this.app);
    this.plugins.set(plugin.name, plugin);
    console.log(`Plugin loaded: ${plugin.name}`);
  }

  async unregister(name: string): Promise<void> {
    const plugin = this.plugins.get(name);
    if (!plugin) return;
    
    if (plugin.destroy) {
      await plugin.destroy();
    }
    this.plugins.delete(name);
    console.log(`Plugin unloaded: ${name}`);
  }

  getPlugin(name: string): Plugin | undefined {
    return this.plugins.get(name);
  }

  listPlugins(): string[] {
    return Array.from(this.plugins.keys());
  }
}

// Step 4: Create plugins
const authPlugin: Plugin = {
  name: 'auth',
  version: '1.0.0',
  
  init(app: Application) {
    // Add authentication middleware
    app.registerMiddleware((req, res, next) => {
      const token = req.headers.authorization;
      if (!token) return res.status(401).json({ error: 'Unauthorized' });
      // Verify token...
      next();
    });
    
    // Add auth-specific routes
    app.registerRoute('POST', '/auth/login', loginHandler);
    app.registerRoute('POST', '/auth/logout', logoutHandler);
    app.registerRoute('POST', '/auth/refresh', refreshHandler);
    
    console.log('Auth plugin: routes and middleware registered');
  },
  
  destroy() {
    console.log('Auth plugin: cleaned up');
  },
};

const loggingPlugin: Plugin = {
  name: 'logging',
  version: '1.0.0',
  
  init(app: Application) {
    app.registerMiddleware((req, res, next) => {
      const start = Date.now();
      res.on('finish', () => {
        const duration = Date.now() - start;
        console.log(`${req.method} ${req.url} ${res.statusCode} ${duration}ms`);
      });
      next();
    });
  },
};

const rateLimitPlugin: Plugin = {
  name: 'rate-limit',
  version: '1.0.0',
  
  init(app: Application) {
    const requests = new Map<string, number[]>();
    const limit = app.getConfig('rateLimit') || 100;
    const window = app.getConfig('rateLimitWindow') || 60000;
    
    app.registerMiddleware((req, res, next) => {
      const ip = req.ip;
      const now = Date.now();
      const timestamps = (requests.get(ip) || []).filter(t => now - t < window);
      
      if (timestamps.length >= limit) {
        return res.status(429).json({ error: 'Too many requests' });
      }
      
      timestamps.push(now);
      requests.set(ip, timestamps);
      next();
    });
  },
};

// Step 5: Wire it all together
const app = createApplication();
const pluginManager = new PluginManager(app);

// Register plugins — the core app never changes
await pluginManager.register(loggingPlugin);
await pluginManager.register(authPlugin);
await pluginManager.register(rateLimitPlugin);

// Adding a new feature? Write a new plugin. Zero changes to existing code.

Real Plugin Systems You Use

ToolPlugin Mechanism
Expressapp.use(middleware) — middleware is a plugin
Webpackplugins: [new HtmlWebpackPlugin()]
Babelplugins: ['@babel/plugin-transform-runtime']
ESLintplugins: ['@typescript-eslint']
VS CodeExtension API with activation events
Fastifyfastify.register(plugin, options)
NestJSModule system with @Module() decorators

4. Strategy Pattern for Extensibility

The Strategy pattern is the simplest and most powerful extensibility tool. It lets you swap algorithms at runtime.

Full Strategy Implementation

// Problem: An e-commerce platform needs multiple discount strategies
// that can be combined, configured, and swapped per customer segment.

// Strategy interface
interface DiscountStrategy {
  readonly name: string;
  calculate(orderTotal: number, context: DiscountContext): number;
  isApplicable(context: DiscountContext): boolean;
}

interface DiscountContext {
  customerTier: 'basic' | 'silver' | 'gold' | 'platinum';
  orderTotal: number;
  itemCount: number;
  couponCode?: string;
  isFirstOrder: boolean;
}

// Concrete strategies
class PercentageDiscount implements DiscountStrategy {
  readonly name: string;
  
  constructor(
    name: string,
    private percentage: number,
    private minOrder: number = 0
  ) {
    this.name = name;
  }
  
  calculate(orderTotal: number): number {
    return orderTotal * (this.percentage / 100);
  }
  
  isApplicable(context: DiscountContext): boolean {
    return context.orderTotal >= this.minOrder;
  }
}

class FlatDiscount implements DiscountStrategy {
  readonly name: string;
  
  constructor(name: string, private amount: number, private minOrder: number) {
    this.name = name;
  }
  
  calculate(): number {
    return this.amount;
  }
  
  isApplicable(context: DiscountContext): boolean {
    return context.orderTotal >= this.minOrder;
  }
}

class TierDiscount implements DiscountStrategy {
  readonly name = 'loyalty-tier';
  
  private rates: Record<string, number> = {
    basic: 0,
    silver: 5,
    gold: 10,
    platinum: 15,
  };
  
  calculate(orderTotal: number, context: DiscountContext): number {
    const rate = this.rates[context.customerTier] || 0;
    return orderTotal * (rate / 100);
  }
  
  isApplicable(context: DiscountContext): boolean {
    return context.customerTier !== 'basic';
  }
}

class FirstOrderDiscount implements DiscountStrategy {
  readonly name = 'first-order';
  
  calculate(orderTotal: number): number {
    return orderTotal * 0.15; // 15% off first order
  }
  
  isApplicable(context: DiscountContext): boolean {
    return context.isFirstOrder;
  }
}

// Discount engine — composable and extensible
class DiscountEngine {
  private strategies: DiscountStrategy[] = [];
  private maxDiscount: number = 0.50; // Never discount more than 50%

  register(strategy: DiscountStrategy): void {
    this.strategies.push(strategy);
  }

  calculateBestDiscount(context: DiscountContext): { strategy: string; amount: number } {
    let bestDiscount = { strategy: 'none', amount: 0 };
    const maxAmount = context.orderTotal * this.maxDiscount;
    
    for (const strategy of this.strategies) {
      if (!strategy.isApplicable(context)) continue;
      
      const discount = Math.min(
        strategy.calculate(context.orderTotal, context),
        maxAmount
      );
      
      if (discount > bestDiscount.amount) {
        bestDiscount = { strategy: strategy.name, amount: discount };
      }
    }
    
    return bestDiscount;
  }

  calculateStackedDiscount(context: DiscountContext): { strategies: string[]; total: number } {
    const applied: string[] = [];
    let total = 0;
    const maxAmount = context.orderTotal * this.maxDiscount;
    
    for (const strategy of this.strategies) {
      if (!strategy.isApplicable(context)) continue;
      
      const discount = strategy.calculate(context.orderTotal - total, context);
      if (total + discount <= maxAmount) {
        total += discount;
        applied.push(strategy.name);
      }
    }
    
    return { strategies: applied, total };
  }
}

// Usage
const engine = new DiscountEngine();
engine.register(new PercentageDiscount('summer-sale', 10, 50));
engine.register(new FlatDiscount('save-20', 20, 100));
engine.register(new TierDiscount());
engine.register(new FirstOrderDiscount());

// Black Friday? Add a new strategy. No existing code modified.
engine.register(new PercentageDiscount('black-friday', 25, 0));

const discount = engine.calculateBestDiscount({
  customerTier: 'gold',
  orderTotal: 150,
  itemCount: 3,
  isFirstOrder: false,
});
console.log(discount); // { strategy: 'black-friday', amount: 37.5 }

5. Configuration over Code

Instead of hardcoding behavior, drive behavior from configuration files or environment variables.

The Configuration Hierarchy

Code (hardcoded) → Environment Variables → Config Files → Database Config → Admin Panel

More flexible ─────────────────────────────────────────────────────────► More dynamic

Before/After: Configuration-Driven Behavior

// BEFORE: Hardcoded behavior — changing anything requires a deploy

class NotificationService {
  async notify(userId: string, event: string) {
    // Hardcoded: always send email and push
    await this.sendEmail(userId, event);
    await this.sendPush(userId, event);
    // Want to add SMS? Edit code, test, deploy.
    // Want to disable push for some events? Edit code, test, deploy.
  }
}

// AFTER: Configuration-driven behavior — change behavior without deploying

// notification-config.json
// {
//   "order:placed": {
//     "channels": ["email", "push"],
//     "priority": "high",
//     "template": "order-confirmation"
//   },
//   "order:shipped": {
//     "channels": ["email", "push", "sms"],
//     "priority": "medium",
//     "template": "shipping-update"
//   },
//   "marketing:weekly": {
//     "channels": ["email"],
//     "priority": "low",
//     "template": "weekly-digest"
//   }
// }

interface NotificationConfig {
  channels: string[];
  priority: 'low' | 'medium' | 'high';
  template: string;
}

class ConfigurableNotificationService {
  private channels = new Map<string, NotificationChannel>();
  private config: Record<string, NotificationConfig>;

  constructor(configPath: string) {
    this.config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
  }

  registerChannel(name: string, channel: NotificationChannel): void {
    this.channels.set(name, channel);
  }

  async notify(userId: string, event: string, data: any): Promise<void> {
    const eventConfig = this.config[event];
    if (!eventConfig) {
      console.warn(`No notification config for event: ${event}`);
      return;
    }

    const promises = eventConfig.channels
      .map(channelName => this.channels.get(channelName))
      .filter(Boolean)
      .map(channel => channel!.send(userId, {
        template: eventConfig.template,
        priority: eventConfig.priority,
        data,
      }));

    await Promise.allSettled(promises);
  }
}

// Add SMS notifications? Update the JSON config. No code change.
// Disable push for marketing events? Update the JSON config. No code change.

Environment-Based Configuration

// config.ts — centralized, typed configuration

interface AppConfig {
  server: {
    port: number;
    host: string;
    cors: string[];
  };
  database: {
    url: string;
    poolSize: number;
    ssl: boolean;
  };
  cache: {
    driver: 'redis' | 'memory' | 'none';
    ttl: number;
    url?: string;
  };
  features: {
    enableNewCheckout: boolean;
    enableBetaSearch: boolean;
    maxUploadSize: number;
  };
}

function loadConfig(): AppConfig {
  return {
    server: {
      port: parseInt(process.env.PORT || '3000'),
      host: process.env.HOST || '0.0.0.0',
      cors: (process.env.CORS_ORIGINS || 'http://localhost:3000').split(','),
    },
    database: {
      url: process.env.DATABASE_URL || 'postgres://localhost/myapp',
      poolSize: parseInt(process.env.DB_POOL_SIZE || '10'),
      ssl: process.env.DB_SSL === 'true',
    },
    cache: {
      driver: (process.env.CACHE_DRIVER as AppConfig['cache']['driver']) || 'memory',
      ttl: parseInt(process.env.CACHE_TTL || '3600'),
      url: process.env.REDIS_URL,
    },
    features: {
      enableNewCheckout: process.env.FEATURE_NEW_CHECKOUT === 'true',
      enableBetaSearch: process.env.FEATURE_BETA_SEARCH === 'true',
      maxUploadSize: parseInt(process.env.MAX_UPLOAD_SIZE || '5242880'),
    },
  };
}

export const config = loadConfig();

// Now behavior changes between environments via env vars:
// Development: CACHE_DRIVER=memory, FEATURE_BETA_SEARCH=true
// Production:  CACHE_DRIVER=redis,  FEATURE_BETA_SEARCH=false

6. Feature Flags

Feature flags (also called feature toggles) let you enable or disable features at runtime without deploying new code.

Why Feature Flags Exist

Traditional deployment:
  Code → Test → Deploy → ALL users get the feature

Feature flags:
  Code → Test → Deploy → Feature is OFF → Enable for 1% → 10% → 50% → 100%
                                          ↕ Can disable instantly if problems arise

Implementing Feature Flags

// Simple feature flag system

interface FeatureFlag {
  name: string;
  enabled: boolean;
  
  // Optional: gradual rollout
  rolloutPercentage?: number;
  
  // Optional: target specific users
  enabledForUsers?: string[];
  
  // Optional: target specific groups
  enabledForGroups?: string[];
}

class FeatureFlagService {
  private flags = new Map<string, FeatureFlag>();

  constructor(flagConfig: FeatureFlag[]) {
    for (const flag of flagConfig) {
      this.flags.set(flag.name, flag);
    }
  }

  isEnabled(flagName: string, context?: { userId?: string; group?: string }): boolean {
    const flag = this.flags.get(flagName);
    if (!flag) return false;
    
    // Global kill switch
    if (!flag.enabled) return false;
    
    // Check user-specific targeting
    if (flag.enabledForUsers && context?.userId) {
      if (flag.enabledForUsers.includes(context.userId)) return true;
    }
    
    // Check group targeting
    if (flag.enabledForGroups && context?.group) {
      if (flag.enabledForGroups.includes(context.group)) return true;
    }
    
    // Check rollout percentage
    if (flag.rolloutPercentage !== undefined && context?.userId) {
      const hash = this.hashUserId(context.userId);
      return hash < flag.rolloutPercentage;
    }
    
    return flag.enabled;
  }

  private hashUserId(userId: string): number {
    // Simple consistent hash — same user always gets same result
    let hash = 0;
    for (let i = 0; i < userId.length; i++) {
      hash = ((hash << 5) - hash) + userId.charCodeAt(i);
      hash |= 0;
    }
    return Math.abs(hash) % 100;
  }

  // Update flags at runtime (from database, API, etc.)
  updateFlag(name: string, updates: Partial<FeatureFlag>): void {
    const flag = this.flags.get(name);
    if (flag) {
      Object.assign(flag, updates);
    }
  }
}

// Configuration
const featureFlags = new FeatureFlagService([
  {
    name: 'new-checkout-flow',
    enabled: true,
    rolloutPercentage: 25,  // 25% of users
  },
  {
    name: 'beta-search',
    enabled: true,
    enabledForGroups: ['beta-testers', 'employees'],
  },
  {
    name: 'dark-mode',
    enabled: true,  // Available to everyone
  },
  {
    name: 'experimental-ai-features',
    enabled: true,
    enabledForUsers: ['user_123', 'user_456'],  // Specific users only
  },
]);

// Usage in Express routes
app.post('/api/checkout', authenticate, async (req, res) => {
  const context = { userId: req.user.id, group: req.user.group };
  
  if (featureFlags.isEnabled('new-checkout-flow', context)) {
    // New checkout logic
    return newCheckoutHandler(req, res);
  }
  
  // Original checkout logic
  return originalCheckoutHandler(req, res);
});

// Usage in services
class SearchService {
  constructor(private features: FeatureFlagService) {}

  async search(query: string, userId: string): Promise<SearchResult[]> {
    if (this.features.isEnabled('beta-search', { userId })) {
      return this.betaSearch(query);
    }
    return this.standardSearch(query);
  }

  private async standardSearch(query: string): Promise<SearchResult[]> {
    // Original search using simple text matching
    return db.query('SELECT * FROM products WHERE name ILIKE $1', [`%${query}%`]);
  }

  private async betaSearch(query: string): Promise<SearchResult[]> {
    // New search using full-text search with ranking
    return db.query(
      `SELECT *, ts_rank(search_vector, plainto_tsquery($1)) as rank
       FROM products
       WHERE search_vector @@ plainto_tsquery($1)
       ORDER BY rank DESC`,
      [query]
    );
  }
}

Feature Flag Best Practices

PracticeWhy
Clean up old flagsFlags accumulate; remove them after full rollout
Default to OFFNew features should require explicit opt-in
Log flag evaluationsHelps debug "why did user X see feature Y?"
Test both pathsTest with flag ON and OFF
Use a naming conventionfeature.checkout-v2, experiment.ai-search, ops.debug-logging
Set expiration datesFlags without cleanup dates become permanent debt

Feature Flag Types

TypePurposeExampleLifespan
Release flagDecouple deployment from releaseNew checkout flowDays to weeks
Experiment flagA/B testingSearch algorithm comparisonWeeks
Ops flagOperational controlDisable email sendingPermanent
Permission flagUser-level accessPremium featuresPermanent

7. Dependency Injection (DI)

DI is the primary mechanism for making code extensible at the component level. It's the practical application of the Dependency Inversion Principle.

Three Types of Dependency Injection

// TYPE 1: Constructor Injection (most common, recommended)
class OrderService {
  constructor(
    private readonly db: Database,
    private readonly emailer: EmailService,
    private readonly logger: Logger
  ) {}
  
  async placeOrder(order: Order): Promise<void> {
    await this.db.insert('orders', order);
    await this.emailer.send(order.email, 'Order Placed', '...');
    this.logger.log(`Order placed: ${order.id}`);
  }
}

// TYPE 2: Setter Injection (useful for optional dependencies)
class ReportGenerator {
  private formatter: ReportFormatter = new DefaultFormatter();
  private exporter: ReportExporter = new ConsoleExporter();
  
  setFormatter(formatter: ReportFormatter): void {
    this.formatter = formatter;
  }
  
  setExporter(exporter: ReportExporter): void {
    this.exporter = exporter;
  }
  
  generate(data: ReportData): void {
    const formatted = this.formatter.format(data);
    this.exporter.export(formatted);
  }
}

// TYPE 3: Interface Injection (less common in JS/TS)
interface DatabaseAware {
  setDatabase(db: Database): void;
}

class UserRepository implements DatabaseAware {
  private db!: Database;
  
  setDatabase(db: Database): void {
    this.db = db;
  }
  
  async findById(id: string): Promise<User> {
    return this.db.query('SELECT * FROM users WHERE id = $1', [id]);
  }
}

Building a Simple DI Container

// A lightweight DI container for TypeScript

type Constructor<T> = new (...args: any[]) => T;
type Factory<T> = () => T;

class DIContainer {
  private singletons = new Map<string, any>();
  private factories = new Map<string, Factory<any>>();

  // Register a singleton (created once, reused)
  registerSingleton<T>(token: string, instance: T): void {
    this.singletons.set(token, instance);
  }

  // Register a factory (new instance each time)
  registerFactory<T>(token: string, factory: Factory<T>): void {
    this.factories.set(token, factory);
  }

  // Resolve a dependency
  resolve<T>(token: string): T {
    // Check singletons first
    if (this.singletons.has(token)) {
      return this.singletons.get(token);
    }
    
    // Check factories
    const factory = this.factories.get(token);
    if (factory) {
      return factory();
    }
    
    throw new Error(`No registration found for: ${token}`);
  }
}

// Bootstrap the application
function bootstrap(): DIContainer {
  const container = new DIContainer();
  
  // Infrastructure (singletons)
  container.registerSingleton('Database', new PostgresDatabase(config.databaseUrl));
  container.registerSingleton('Cache', new RedisCache(config.redisUrl));
  container.registerSingleton('Logger', new WinstonLogger());
  
  // Services (singletons that depend on infrastructure)
  container.registerSingleton('EmailService', new SendGridEmailer(config.sendgridKey));
  
  // Repositories
  container.registerSingleton('UserRepository',
    new UserRepository(container.resolve('Database'))
  );
  container.registerSingleton('OrderRepository',
    new OrderRepository(container.resolve('Database'))
  );
  
  // Application services
  container.registerSingleton('UserService',
    new UserService(
      container.resolve('UserRepository'),
      container.resolve('EmailService'),
      container.resolve('Logger')
    )
  );
  container.registerSingleton('OrderService',
    new OrderService(
      container.resolve('OrderRepository'),
      container.resolve('UserService'),
      container.resolve('EmailService'),
      container.resolve('Logger')
    )
  );
  
  return container;
}

// Test bootstrap — swap real dependencies for mocks
function bootstrapTest(): DIContainer {
  const container = new DIContainer();
  
  container.registerSingleton('Database', new InMemoryDatabase());
  container.registerSingleton('Cache', new NoOpCache());
  container.registerSingleton('Logger', new SilentLogger());
  container.registerSingleton('EmailService', new MockEmailer());
  
  // Same services, different dependencies
  container.registerSingleton('UserRepository',
    new UserRepository(container.resolve('Database'))
  );
  // ... same wiring, but with test implementations
  
  return container;
}

// Usage
const container = process.env.NODE_ENV === 'test' ? bootstrapTest() : bootstrap();
const userService = container.resolve<UserService>('UserService');

DI Without a Container (Manual DI)

You don't always need a DI framework. Manual DI in the composition root is often simpler:

// composition-root.ts — where all dependencies are wired together

import { PostgresDatabase } from './infrastructure/database';
import { RedisCache } from './infrastructure/cache';
import { SendGridEmailer } from './infrastructure/email';
import { UserRepository } from './repositories/userRepository';
import { OrderRepository } from './repositories/orderRepository';
import { UserService } from './services/userService';
import { OrderService } from './services/orderService';
import { UserController } from './controllers/userController';
import { OrderController } from './controllers/orderController';

// Wire everything up in ONE place
const db = new PostgresDatabase(process.env.DATABASE_URL);
const cache = new RedisCache(process.env.REDIS_URL);
const emailer = new SendGridEmailer(process.env.SENDGRID_KEY);

const userRepo = new UserRepository(db);
const orderRepo = new OrderRepository(db);

const userService = new UserService(userRepo, emailer);
const orderService = new OrderService(orderRepo, userService, emailer);

export const userController = new UserController(userService);
export const orderController = new OrderController(orderService);

// routes.ts — just wiring, no instantiation
import { userController, orderController } from './composition-root';

app.post('/api/users', userController.create);
app.get('/api/users/:id', userController.getById);
app.post('/api/orders', orderController.create);

8. Real-World Extensibility Examples

Example 1: Express Middleware Chain (Chain of Responsibility + Plugin)

// Express itself is built on extensibility principles

// The core is tiny — almost everything is a plugin (middleware)
const app = express();

// Each middleware extends the app without modifying it
app.use(helmet());           // Security headers
app.use(cors());             // CORS
app.use(compression());      // Response compression
app.use(express.json());     // Body parsing
app.use(morgan('combined')); // Request logging

// Custom middleware — same extension mechanism
app.use(requestId());        // Add unique ID to each request
app.use(authenticate());     // Verify JWT tokens
app.use(rateLimit());        // Throttle requests
app.use(validateRequest());  // Schema validation

// Adding a new cross-cutting concern? Write middleware. 
// Zero changes to existing code.

Example 2: Validation Library (Strategy + Registry)

// Extensible validation system

type Validator = (value: any, options?: any) => string | null;

class ValidationRegistry {
  private validators = new Map<string, Validator>();

  register(name: string, validator: Validator): void {
    this.validators.set(name, validator);
  }

  validate(value: any, rules: Record<string, any>): string[] {
    const errors: string[] = [];
    
    for (const [ruleName, options] of Object.entries(rules)) {
      const validator = this.validators.get(ruleName);
      if (!validator) {
        throw new Error(`Unknown validation rule: ${ruleName}`);
      }
      const error = validator(value, options);
      if (error) errors.push(error);
    }
    
    return errors;
  }
}

// Built-in validators
const registry = new ValidationRegistry();

registry.register('required', (value) => {
  if (value === undefined || value === null || value === '') {
    return 'This field is required';
  }
  return null;
});

registry.register('minLength', (value, min: number) => {
  if (typeof value === 'string' && value.length < min) {
    return `Must be at least ${min} characters`;
  }
  return null;
});

registry.register('maxLength', (value, max: number) => {
  if (typeof value === 'string' && value.length > max) {
    return `Must be at most ${max} characters`;
  }
  return null;
});

registry.register('email', (value) => {
  if (typeof value === 'string' && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) {
    return 'Must be a valid email address';
  }
  return null;
});

// Custom validators — extend without modifying existing code
registry.register('strongPassword', (value) => {
  if (typeof value !== 'string') return 'Must be a string';
  if (!/[A-Z]/.test(value)) return 'Must contain an uppercase letter';
  if (!/[0-9]/.test(value)) return 'Must contain a number';
  if (!/[!@#$%^&*]/.test(value)) return 'Must contain a special character';
  return null;
});

registry.register('uniqueEmail', async (value) => {
  const exists = await db.query('SELECT 1 FROM users WHERE email = $1', [value]);
  if (exists.rows.length > 0) return 'Email already registered';
  return null;
});

// Usage
const errors = registry.validate('ab', {
  required: true,
  minLength: 3,
  maxLength: 50,
});
// ['Must be at least 3 characters']

Example 3: Event-Driven Architecture (Observer + Plugin)

// Event bus for decoupled, extensible application logic

type EventHandler = (data: any) => void | Promise<void>;

class EventBus {
  private handlers = new Map<string, Set<EventHandler>>();

  on(event: string, handler: EventHandler): () => void {
    if (!this.handlers.has(event)) {
      this.handlers.set(event, new Set());
    }
    this.handlers.get(event)!.add(handler);
    
    // Return unsubscribe function
    return () => this.handlers.get(event)?.delete(handler);
  }

  async emit(event: string, data: any): Promise<void> {
    const handlers = this.handlers.get(event);
    if (!handlers) return;
    
    const promises = Array.from(handlers).map(handler => {
      try {
        return Promise.resolve(handler(data));
      } catch (err) {
        console.error(`Error in handler for ${event}:`, err);
        return Promise.resolve();
      }
    });
    
    await Promise.allSettled(promises);
  }
}

// Core application emits events
class OrderService {
  constructor(
    private repo: OrderRepository,
    private events: EventBus
  ) {}

  async placeOrder(data: CreateOrderDTO): Promise<Order> {
    const order = await this.repo.create(data);
    
    // Emit event — OrderService doesn't know or care who listens
    await this.events.emit('order:placed', order);
    
    return order;
  }

  async cancelOrder(orderId: string): Promise<void> {
    await this.repo.updateStatus(orderId, 'cancelled');
    await this.events.emit('order:cancelled', { orderId });
  }
}

// Extensions — each module subscribes to events it cares about
// Adding a new reaction to "order placed" = add a new subscriber. Zero changes to OrderService.

const events = new EventBus();

// Email notification module
events.on('order:placed', async (order) => {
  await emailService.send(order.email, 'Order Confirmation', orderTemplate(order));
});

// Inventory module
events.on('order:placed', async (order) => {
  for (const item of order.items) {
    await inventoryService.decrementStock(item.productId, item.quantity);
  }
});

// Analytics module
events.on('order:placed', async (order) => {
  await analytics.track('purchase', {
    orderId: order.id,
    total: order.total,
    itemCount: order.items.length,
  });
});

// Fraud detection module (added later — zero changes to existing code)
events.on('order:placed', async (order) => {
  const risk = await fraudService.assessRisk(order);
  if (risk > 0.8) {
    await orderService.flagForReview(order.id);
  }
});

// Refund module
events.on('order:cancelled', async ({ orderId }) => {
  await paymentService.refund(orderId);
});

9. Extensibility Anti-Patterns

Anti-Pattern 1: Extension Points Everywhere

// BAD: Every function is "extensible" via callbacks
function add(a: number, b: number, {
  beforeAdd,
  afterAdd,
  onError,
  transformResult,
  validateInputs,
}: AddOptions = {}): number {
  if (validateInputs) validateInputs(a, b);
  if (beforeAdd) beforeAdd(a, b);
  const result = a + b;
  const transformed = transformResult ? transformResult(result) : result;
  if (afterAdd) afterAdd(transformed);
  return transformed;
}

// This is addition. It doesn't need 5 extension points.
// GOOD:
function add(a: number, b: number): number {
  return a + b;
}

Anti-Pattern 2: Premature Abstraction

// BAD: Abstract everything "for future flexibility"
interface IUserRepository { /* ... */ }
interface IUserService { /* ... */ }
interface IUserController { /* ... */ }
interface IUserValidator { /* ... */ }
interface IUserMapper { /* ... */ }
interface IUserSerializer { /* ... */ }
// You have ONE implementation of each. The interfaces add noise, not value.

// GOOD: Extract an interface WHEN you need a second implementation
class UserRepository { /* ... */ }
// When you need InMemoryUserRepository for testing: THEN extract the interface.

Anti-Pattern 3: The "Framework" Trap

// BAD: Building a framework before building the product
// "Let me build a generic extensible notification framework..."
// ...3 weeks later, the framework is done, but the product isn't.

// GOOD: Build the product. Extract the framework if a real pattern emerges.
// Build email sending → Build SMS sending → Notice the pattern → THEN extract a notification framework.

10. Key Takeaways

  1. Extensibility means new features = new code, not modified code. The best extension point is one where adding a feature means creating a new file, not editing an existing one.
  2. Plugin architectures separate the core from extensions via a well-defined interface. Express middleware, webpack plugins, and VS Code extensions all follow this pattern.
  3. The Strategy pattern is your most versatile tool for extensibility — swap algorithms, behaviors, and implementations at runtime.
  4. Configuration over code moves decisions from source files to config files, environment variables, or databases — enabling changes without deployments.
  5. Feature flags decouple deployment from release, enabling gradual rollouts, A/B testing, and instant kill switches.
  6. Dependency Injection makes every component extensible by allowing its dependencies to be swapped — essential for testing and for supporting multiple implementations.
  7. Events decouple producers from consumers. The order service doesn't know about email, inventory, or analytics — it just emits events.
  8. Don't over-extend. Add extension points where you KNOW things will change. YAGNI still applies — extensibility has a cost.

11. Explain-It Challenge

  1. Architecture design: You're building a logging system that currently writes to the console. Requirements say it might need to also write to files, a remote service (like Datadog), or a database. Design the system using DI and the Strategy pattern so that adding a new log destination requires zero changes to existing code.

  2. Feature flag decision: Your product team wants to test a new recommendation algorithm on 10% of users, employees always see it, and specific beta users can opt in. Design the feature flag configuration.

  3. Plugin critique: A junior developer writes a plugin system where plugins can directly modify the application's internal state (e.g., plugin.init(app) { app._routes.push(...) }). What's wrong with this approach? How should it be fixed?

  4. Config vs code: Your team debates whether pricing rules (discounts, tax rates, shipping costs) should be in code or configuration. Make the case for each side and recommend an approach.


Return to Overview