Skip to content
Bitloops - Give your AI agents high-signal context in milliseconds.
HomeAbout us
DocsBlog
ResourcesSoftware DesignDesigning for Change and Evolution: Building Flexible Systems

Designing for Change and Evolution: Building Flexible Systems

Build systems that bend instead of break when requirements change. The trick is designing for extensibility without building abstractions you don't need yet—watch for the pattern, then make it flexible.

7 min readUpdated March 4, 2026Software Design

The Core Principle: Open-Closed

A system should be open for extension and closed for modification — the O in SOLID. You should be able to add new features without changing existing code.

Sounds impossible? It's not. But it requires strategy.

// Closed for modification: Existing code stays stable
class OrderProcessor {
  // This method doesn't change when we add new payment types
  process(order, paymentHandler) {
    const result = paymentHandler.process(order.total);
    if (result.success) {
      order.markAsPaid(result.transactionId);
    }
    return result;
  }
}

// Open for extension: New payment types are added by creating new handlers
class CreditCardHandler {
  process(amount) {
    // Credit card logic
  }
}

class PayPalHandler {
  process(amount) {
    // PayPal logic
  }

class ApplePayHandler {
  process(amount) {
    // Apple Pay logic
  }
}

// Adding a new payment type doesn't require changing OrderProcessor
javascript

The OrderProcessor code never changes. New payment types extend the system without modification.

Over-Engineering vs. Under-Engineering

The biggest mistake is designing for flexibility you don't need. This is the premature optimization anti-pattern applied to architecture.

Under-Engineering: Too Rigid

// Tightly coupled, rigid
class OrderProcessor {
  chargeViaCreditCard(order) {
    // Credit card specific logic
    creditCardAPI.charge(order.customer.card, order.total);
  }

  chargeViaPayPal(order) {
    // PayPal specific logic
    paypalAPI.charge(order.customer.email, order.total);
  }

  chargeViaApplePay(order) {
    // Apple Pay specific logic
    applePayAPI.charge(order.customer.token, order.total);
  }
}
javascript

Every new payment type requires changing OrderProcessor. This couples the processor to payment details it shouldn't care about.

Over-Engineering: Too Flexible

// Overly abstracted, hard to understand
interface PaymentProcessor {
  canProcess(order, context): boolean;
  getProcessingStrategy(order): ProcessingStrategy;
  getPriority(): number;
  supportsRefund(): boolean;
  supportsPartialCapture(): boolean;
  supportsAsyncProcessing(): boolean;
  getWebhookUrl(): URL;
  validateSignature(payload, signature): boolean;
  // ... 20 more methods
}

// For a simple feature, this is 10x the code you need
javascript

You're designing for flexibility that might never be needed.

Balanced Design

// Just enough abstraction
interface PaymentHandler {
  process(amount): Promise<PaymentResult>;
}

class CreditCardHandler implements PaymentHandler {
  process(amount) {
    return creditCardAPI.charge(this.card, amount);
  }
}

class PayPalHandler implements PaymentHandler {
  process(amount) {
    return paypalAPI.charge(this.email, amount);
  }
}

class OrderProcessor {
  process(order, paymentHandler) {
    const result = paymentHandler.process(order.total);
    // ... handle result
  }
}
javascript

Simple. Flexible enough for new payment types. Not over-designed.

Strategies for Designing Extensible Systems

Dependency Injection

Inject what changes, hard-code what doesn't.

// Bad: Hard-coded dependencies
class OrderService {
  processOrder(order) {
    const notifier = new EmailNotifier(); // Always email
    const calculator = new TaxCalculator(); // Always uses this calculator
    // ...
  }
}

// Good: Injected dependencies
class OrderService {
  constructor(notifier, calculator) {
    this.notifier = notifier;
    this.calculator = calculator;
  }

  processOrder(order) {
    // Use injected dependencies
    // Can be swapped without changing OrderService
  }
}

// Swap for different implementations
const emailNotifier = new EmailNotifier();
const smsNotifier = new SMSNotifier();
const orderService = new OrderService(smsNotifier, calculator);
javascript

Strategy Pattern

Encapsulate algorithms, make them interchangeable.

// Different strategies for calculating shipping
interface ShippingCalculator {
  calculate(order): Money;
}

class StandardShippingCalculator implements ShippingCalculator {
  calculate(order) {
    return new Money(10);
  }
}

class ExpressShippingCalculator implements ShippingCalculator {
  calculate(order) {
    return new Money(20);
  }
}

class FreeShippingCalculator implements ShippingCalculator {
  calculate(order) {
    return new Money(0);
  }
}

class Order {
  calculateTotal(shippingCalculator) {
    const subtotal = this.getSubtotal();
    const shipping = shippingCalculator.calculate(this);
    const tax = this.calculateTax(subtotal + shipping);
    return subtotal + shipping + tax;
  }
}

// Easy to swap strategies
const expressOrder = order.calculateTotal(new ExpressShippingCalculator());
const freeOrder = order.calculateTotal(new FreeShippingCalculator());
javascript

Plugin Architecture

Design systems to load behaviors dynamically.

class ReportGenerator {
  constructor(pluginRegistry) {
    this.plugins = pluginRegistry;
  }

  generate(reportType, data) {
    const plugin = this.plugins.get(reportType);
    if (!plugin) {
      throw new Error(`Unknown report type: ${reportType}`);
    }
    return plugin.generate(data);
  }
}

// Register plugins
const registry = new PluginRegistry();
registry.register('pdf', new PDFReportPlugin());
registry.register('excel', new ExcelReportPlugin());
registry.register('json', new JSONReportPlugin());

// Later: add new report type without changing generator
registry.register('csv', new CSVReportPlugin());
javascript

Template Method Pattern

Define algorithm structure, let subclasses fill in details.

// Structure is fixed, steps can vary
abstract class DataProcessor {
  process(data) {
    const validated = this.validate(data);
    const transformed = this.transform(validated);
    const result = this.store(transformed);
    return result;
  }

  abstract validate(data);
  abstract transform(data);
  abstract store(data);
}

class UserProcessor extends DataProcessor {
  validate(data) {
    // User-specific validation
  }

  transform(data) {
    // User-specific transformation
  }

  store(data) {
    // Store in user table
  }
}

class OrderProcessor extends DataProcessor {
  validate(data) {
    // Order-specific validation
  }

  transform(data) {
    // Order-specific transformation
  }

  store(data) {
    // Store in order table
  }
}
javascript

Configuration Over Code

Externalize what changes frequently.

// Bad: Hardcoded values
const MAX_ORDER_VALUE = 10000;
const TAX_RATE = 0.1;
const SHIPPING_COST = 15;
const DISCOUNT_PERCENT = 0.05;

// Good: Configuration
const config = {
  maxOrderValue: 10000,
  taxRate: 0.1,
  shippingCost: 15,
  discountPercent: 0.05
};

// Even better: from configuration service/file
class ConfigService {
  getMaxOrderValue() {
    return this.config.maxOrderValue;
  }
}

// Change without code redeploy
// maxOrderValue: 15000  (in config file or database)
javascript

When to Design for Change

Design for change when:

  • The feature is a known area of variation (payment types, report formats, notification channels)
  • Multiple clients need different behaviors (different customer segments need different rules)
  • The feature is in the domain core (business logic that drives competitive advantage)
  • You've seen the pattern before (you know three implementations are coming)

Don't design for change when:

  • You're guessing at what might change
  • The feature is simple and stable
  • The overhead of abstraction is bigger than the code itself
  • You're in a spike/exploration phase

Practical Example: Extensible Order System

// Start simple
class Order {
  constructor(id, customer, items) {
    this.id = id;
    this.customer = customer;
    this.items = items;
  }

  getTotal() {
    return this.items.reduce((sum, item) => sum + item.price * item.qty, 0);
  }
}

class OrderService {
  constructor(repository) {
    this.repository = repository;
  }

  placeOrder(order) {
    this.repository.save(order);
  }
}

// Requirement 1: "Different customers need different pricing"
// Add pricing strategy (now it's worth abstracting)
interface PricingStrategy {
  calculatePrice(item, customer): Money;
}

class RegularPricing implements PricingStrategy {
  calculatePrice(item, customer) {
    return item.basePrice;
  }
}

class LoyaltyPricing implements PricingStrategy {
  calculatePrice(item, customer) {
    return item.basePrice.times(0.9); // 10% discount for loyal customers
  }
}

class Order {
  constructor(id, customer, items, pricingStrategy) {
    this.id = id;
    this.customer = customer;
    this.items = items;
    this.pricingStrategy = pricingStrategy;
  }

  getTotal() {
    return this.items.reduce(
      (sum, item) => sum + this.pricingStrategy.calculatePrice(item, this.customer),
      0
    );
  }
}

// Requirement 2: "Different shipping methods"
// Add shipping strategy
interface ShippingStrategy {
  calculateCost(order): Money;
  estimatedDelivery(order): Date;
}

class StandardShipping implements ShippingStrategy {
  calculateCost(order) {
    return new Money(10);
  }

  estimatedDelivery(order) {
    return new Date(Date.now() + 5 * 24 * 60 * 60 * 1000); // 5 days
  }
}

class ExpressShipping implements ShippingStrategy {
  calculateCost(order) {
    return new Money(25);
  }

  estimatedDelivery(order) {
    return new Date(Date.now() + 1 * 24 * 60 * 60 * 1000); // 1 day
  }
}

// Requirement 3: "Notifications via different channels"
interface NotificationChannel {
  send(order, message): void;
}

class EmailChannel implements NotificationChannel {
  send(order, message) {
    emailService.send(order.customer.email, message);
  }
}

class SMSChannel implements NotificationChannel {
  send(order, message) {
    smsService.send(order.customer.phone, message);
  }
}

class OrderService {
  constructor(repository, notificationChannels) {
    this.repository = repository;
    this.channels = notificationChannels;
  }

  placeOrder(order, shippingStrategy) {
    this.repository.save(order);
    const cost = shippingStrategy.calculateCost(order);
    for (let channel of this.channels) {
      channel.send(order, `Order placed. Shipping cost: ${cost}`);
    }
  }
}

// Usage: Easy to extend, no changes to existing code
const loyaltyOrder = new Order(1, loyalCustomer, items, new LoyaltyPricing());
const expressOrder = orderService.placeOrder(loyalOrder, new ExpressShipping());
javascript

FAQ

How do I know when to introduce abstraction?

When you see it would be useful for different implementations. Not before. Code once, twice is okay, three times then abstract.

Isn't this just the Strategy pattern?

Often yes. That's fine. Patterns are reusable solutions. Use them.

Can I add abstraction later?

Usually yes, if you've designed with modularity in mind. If everything is tightly coupled, adding abstraction is harder.

What if I get the abstraction wrong?

You change it. If you've separated concerns well, changes are localized. That's the point.

How do I test extensible code?

Each strategy can be tested independently. The main code tests with mock implementations. TDD pairs well with extensible designs — write the test for the strategy interface first, then implement each strategy.

Primary Sources

  • Robert Martin's guide to organizing architecture layers and dependencies for flexibility. Clean Architecture
  • Gang of Four's 23 design patterns for creating flexible and reusable object-oriented code. Design Patterns
  • Foundational domain-driven design text on modeling domains to support evolutionary change. Domain-Driven Design
  • Practical strategies for implementing domain-driven design in complex enterprise systems. Implementing DDD
  • Martin Fowler's guide to refactoring techniques for safely evolving designs. Refactoring
  • Kent Beck's test-driven development approach for designing flexible systems. TDD by Example

Get Started with Bitloops.

Apply what you learn in these hubs to real AI-assisted delivery workflows with shared context, traceable reasoning, and architecture-aware engineering practices.

curl -sSL https://bitloops.com/install.sh | bash