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
- What "Extensible" Really Means
- Designing for Change
- Plugin Architectures
- Strategy Pattern for Extensibility
- Configuration over Code
- Feature Flags
- Dependency Injection (DI)
- Real-World Extensibility Examples
- Extensibility Anti-Patterns
- Key Takeaways
- 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
| Tool | Plugin Mechanism |
|---|---|
| Express | app.use(middleware) — middleware is a plugin |
| Webpack | plugins: [new HtmlWebpackPlugin()] |
| Babel | plugins: ['@babel/plugin-transform-runtime'] |
| ESLint | plugins: ['@typescript-eslint'] |
| VS Code | Extension API with activation events |
| Fastify | fastify.register(plugin, options) |
| NestJS | Module 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
| Practice | Why |
|---|---|
| Clean up old flags | Flags accumulate; remove them after full rollout |
| Default to OFF | New features should require explicit opt-in |
| Log flag evaluations | Helps debug "why did user X see feature Y?" |
| Test both paths | Test with flag ON and OFF |
| Use a naming convention | feature.checkout-v2, experiment.ai-search, ops.debug-logging |
| Set expiration dates | Flags without cleanup dates become permanent debt |
Feature Flag Types
| Type | Purpose | Example | Lifespan |
|---|---|---|---|
| Release flag | Decouple deployment from release | New checkout flow | Days to weeks |
| Experiment flag | A/B testing | Search algorithm comparison | Weeks |
| Ops flag | Operational control | Disable email sending | Permanent |
| Permission flag | User-level access | Premium features | Permanent |
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
- 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.
- Plugin architectures separate the core from extensions via a well-defined interface. Express middleware, webpack plugins, and VS Code extensions all follow this pattern.
- The Strategy pattern is your most versatile tool for extensibility — swap algorithms, behaviors, and implementations at runtime.
- Configuration over code moves decisions from source files to config files, environment variables, or databases — enabling changes without deployments.
- Feature flags decouple deployment from release, enabling gradual rollouts, A/B testing, and instant kill switches.
- Dependency Injection makes every component extensible by allowing its dependencies to be swapped — essential for testing and for supporting multiple implementations.
- Events decouple producers from consumers. The order service doesn't know about email, inventory, or analytics — it just emits events.
- Don't over-extend. Add extension points where you KNOW things will change. YAGNI still applies — extensibility has a cost.
11. Explain-It Challenge
-
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.
-
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.
-
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? -
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