Asher Cohen
Back to posts

Inversion of Control Pattern

Decouple components by inverting control flow - a fundamental pattern for flexible, testable code

Introduction

Inversion of Control (IoC) is a design principle that flips traditional control flow. Instead of a component creating its dependencies, they're provided from the outside. This leads to more flexible, testable, and maintainable code.

What is Inversion of Control?

The Hollywood Principle: "Don't call us, we'll call you."

Instead of your code controlling the flow, you provide callbacks or dependencies that get called when needed.

Traditional vs. IoC

Without IoC

// ❌ Tight coupling - component controls dependencies
class UserService {
  constructor() {
    this.database = new MySQLDatabase(); // Hard-coded dependency
    this.logger = new FileLogger(); // Hard-coded dependency
  }

  async getUser(id) {
    this.logger.log(`Fetching user ${id}`);
    return await this.database.query(`SELECT * FROM users WHERE id = ${id}`);
  }
}

With IoC

// ✅ Loose coupling - dependencies injected
class UserService {
  constructor(database, logger) {
    this.database = database; // Provided from outside
    this.logger = logger; // Provided from outside
  }

  async getUser(id) {
    this.logger.log(`Fetching user ${id}`);
    return await this.database.query(`SELECT * FROM users WHERE id = ${id}`);
  }
}

// Usage - control is inverted
const service = new UserService(new PostgreSQLDatabase(), new ConsoleLogger());

Benefits of IoC

Testability

// Easy to test with mocks
const mockDB = { query: jest.fn().mockResolvedValue({ id: 1, name: 'Test' }) };
const mockLogger = { log: jest.fn() };

const service = new UserService(mockDB, mockLogger);
const user = await service.getUser(1);

expect(mockDB.query).toHaveBeenCalled();
expect(mockLogger.log).toHaveBeenCalled();

Flexibility

// Swap implementations without changing UserService
const service1 = new UserService(new MySQLDatabase(), new FileLogger());
const service2 = new UserService(new PostgreSQLDatabase(), new ConsoleLogger());
const service3 = new UserService(new MockDatabase(), new TestLogger());

Maintainability

Changes to dependencies don't require changes to the component itself.

IoC Patterns

1. Dependency Injection

Pass dependencies as constructor parameters or function arguments.

// Constructor injection
class PaymentProcessor {
  constructor(paymentGateway, notificationService) {
    this.gateway = paymentGateway;
    this.notifier = notificationService;
  }
}

// Function injection
function processPayment({ gateway, notifier, amount, userId }) {
  const result = await gateway.charge(amount);
  notifier.send(userId, result);
  return result;
}

2. Callback Functions

Provide callbacks instead of controlling flow.

// ❌ Without IoC - component controls timing
function fetchData() {
  const data = getData();
  processData(data);
  saveData(data);
}

// ✅ With IoC - caller controls flow
function fetchData({ onSuccess, onError }) {
  getData().then(onSuccess).catch(onError);
}

// Usage
fetchData({
  onSuccess: (data) => {
    processData(data);
    saveData(data);
  },
  onError: (error) => {
    logError(error);
  },
});

3. Strategy Pattern

Inject different algorithms or behaviors.

class Sorter {
  constructor(strategy) {
    this.strategy = strategy;
  }

  sort(array) {
    return this.strategy.execute(array);
  }
}

// Different strategies
const quickSort = {
  execute: (arr) => {
    /* ... */
  },
};
const mergeSort = {
  execute: (arr) => {
    /* ... */
  },
};

// Use different strategies
const sorter1 = new Sorter(quickSort);
const sorter2 = new Sorter(mergeSort);

4. React Props

React's props system is IoC in action.

// Parent controls child behavior
function Parent() {
  const handleClick = () => console.log('Clicked!');

  return <Button onClick={handleClick} />;
}

// Child receives control
function Button({ onClick, children }) {
  return <button onClick={onClick}>{children}</button>;
}

Real-World Examples

Express.js Middleware

// IoC: Express calls your middleware
app.use((req, res, next) => {
  console.log('Request received');
  next(); // Control returns to Express
});

app.get('/users', (req, res) => {
  res.json({ users: [] });
});

React Hooks

// IoC: React controls when effects run
useEffect(() => {
  // Your code runs when React decides
  fetchData();
}, [dependency]);

Event-Driven Architecture

// IoC: Event emitter controls when callbacks execute
emitter.on('user:created', (user) => {
  sendWelcomeEmail(user);
});

emitter.on('user:created', (user) => {
  updateAnalytics(user);
});

// Multiple handlers, control inverted to emitter

IoC in Functional Programming

Higher-Order Functions

// Array methods use IoC
const doubled = [1, 2, 3].map((n) => n * 2);
const evens = [1, 2, 3].filter((n) => n % 2 === 0);
const sum = [1, 2, 3].reduce((acc, n) => acc + n, 0);

// You provide the function, array method controls iteration

Function Composition

const pipe =
  (...fns) =>
  (value) =>
    fns.reduce((acc, fn) => fn(acc), value);

const process = pipe(validate, transform, save);

// Control flows through composed functions

Common Pitfalls

Over-Engineering

// ❌ Too much abstraction
class FactoryProviderFactory {
  constructor(providerFactory) {
    this.providerFactory = providerFactory;
  }
}

// ✅ Just what you need
function createService({ db, logger }) {
  return new UserService(db, logger);
}

Losing Control Where You Need It

Sometimes you need direct control. Don't invert when:

  • Performance is critical
  • The abstraction adds confusion
  • Simple is better

Best Practices

Inject What Varies

// ✅ Inject what changes
class EmailService {
  constructor(smtpProvider) {
    this.smtp = smtpProvider;
  }
}

// ❌ Don't inject constants
class EmailService {
  constructor(smtpProvider, maxRetries) {
    this.smtp = smtpProvider;
    this.maxRetries = 3; // Just use a constant
  }
}

Document Dependencies

/**
 * UserService - Manages user operations
 * @param {Database} database - Database interface
 * @param {Logger} logger - Logging service
 */
class UserService {
  constructor(database, logger) {
    this.database = database;
    this.logger = logger;
  }
}

Use Interfaces/Contracts

// Define what you need, not specific implementations
class UserService {
  constructor(database) {
    // database must implement: query(), insert(), update()
    this.database = database;
  }
}

Conclusion

Inversion of Control is a fundamental pattern for building flexible, testable systems. By inverting control flow and injecting dependencies, you create code that's easier to test, modify, and extend. Start with dependency injection and callbacks—they're the gateway to more advanced IoC patterns.

#architecture #design-patterns #javascript