Skip to content
~/sph.sh

Death of the Factory Pattern: How We Eliminated 40% of Our Node.js Code with Pure Functions

After removing all factories, services, and dependency injection from our Node.js microservices, we shipped 3x faster with 65% fewer bugs. Here's why functions beat classes for event-driven architectures.

The 847-Line Service That Did Nothing

I was debugging a payment processing bug that should have taken 20 minutes to fix. Instead, I spent 3 hours navigating through 847 lines of "enterprise architecture" just to change a single validation rule.

The culprit? Our PaymentService class - a masterpiece of over-engineering that included a factory, dependency injection, 12 different interfaces, and enough abstraction to make a Java developer weep with joy.

typescript
// The monster we created in the name of "clean architecture"class PaymentServiceFactory {  static create(config: PaymentConfig): PaymentService {    const validator = new PaymentValidator(      new CreditCardValidator(),      new BillingAddressValidator(),      new FraudDetectionValidator(config.fraudConfig)    );
    const processor = new PaymentProcessor(      new StripeAdapter(config.stripeConfig),      new PayPalAdapter(config.paypalConfig),      new BankAdapter(config.bankConfig)    );
    const logger = new PaymentLogger(      new CloudWatchLogger(),      new DatadogLogger()    );
    return new PaymentService(validator, processor, logger);  }}
class PaymentService implements IPaymentService {  constructor(    private validator: IPaymentValidator,    private processor: IPaymentProcessor,    private logger: IPaymentLogger  ) {}
  async processPayment(request: PaymentRequest): Promise<PaymentResult> {    // 200+ lines of orchestration logic    // that could have been a 15-line function  }}

The bug I was trying to fix? A simple validation: "Credit card numbers should not contain spaces."

After our migration to pure functions and event-driven architecture, the same functionality became:

typescript
// After: Simple, testable, debuggableexport const validateCreditCard = (cardNumber: string): ValidationResult => {  if (!cardNumber) return { valid: false, error: 'Card number required' };  if (cardNumber.includes(' ')) return { valid: false, error: 'Remove spaces from card number' };  if (!luhnCheck(cardNumber)) return { valid: false, error: 'Invalid card number' };
  return { valid: true };};
export const processPayment = async (event: PaymentEvent): Promise<void> => {  const validation = validateCreditCard(event.cardNumber);  if (!validation.valid) {    await publishEvent('payment.failed', { ...event, error: validation.error });    return;  }
  const result = await chargeCard(event);  await publishEvent('payment.processed', result);};

Bug fix time: 3 hours → 15 minutes. Lines of code: 847 → 89. Test complexity: 156 test cases → 12 test cases.

This is the story of how we killed our factories, services, and dependency injection containers - and why our Node.js applications became faster, more reliable, and infinitely more pleasant to work with.

The Java-fication of Node.js: How We Got Here

When we started building microservices, we came from Java and C# backgrounds. We brought enterprise patterns that made sense in those ecosystems:

  • Dependency Injection for "testability"
  • Factory patterns for "flexibility"
  • Service layers for "separation of concerns"
  • Repository patterns for "data abstraction"

Eventually, our Node.js codebase looked more like Spring Boot than idiomatic JavaScript. Every simple operation required navigating through multiple abstraction layers:

typescript
// To process a simple order, you needed to understand:
// 1. OrderServiceFactory (decides which OrderService implementation)class OrderServiceFactory {  static create(): IOrderService {    return new OrderService(      InventoryServiceFactory.create(),      PaymentServiceFactory.create(),      ShippingServiceFactory.create(),      NotificationServiceFactory.create()    );  }}
// 2. OrderService (orchestrates other services)class OrderService implements IOrderService {  constructor(    private inventory: IInventoryService,    private payment: IPaymentService,    private shipping: IShippingService,    private notification: INotificationService  ) {}
  async processOrder(order: Order): Promise<OrderResult> {    // 150 lines of service orchestration  }}
// 3. Each injected service had its own factory and dependencies// 4. Integration tests required mocking 47 different dependencies// 5. Simple changes cascaded through 12 different files

The wake-up call: Adding a "send order confirmation email" feature required changes across multiple files and services. Understanding the dependency graph became a significant challenge for new team members.

The Event-Driven Epiphany

The breakthrough came when we realized that most of our "services" were just event handlers in disguise.

Instead of synchronous service calls:

typescript
// Synchronous coupling nightmareawait orderService.processOrder(orderData);await inventoryService.updateStock(orderData.items);await paymentService.chargeCard(orderData.payment);await shippingService.scheduleDelivery(orderData.shipping);await notificationService.sendConfirmation(orderData.customer);

We could model the same flow as events:

typescript
// Event-driven decoupling blissawait publishEvent('order.created', orderData);
// Separate handlers react independently:// - inventory-handler updates stock// - payment-handler processes payment// - shipping-handler schedules delivery// - notification-handler sends confirmation

The insight: If every operation is an event handler, why do we need classes at all?

The Great Refactoring: From Classes to Functions

Phase 1: Identify Pure Operations (Week 1)

We started by identifying operations that were:

  • Stateless (no instance variables)
  • Side-effect free (except for database/API calls)
  • Easily testable (input → output)
typescript
// Before: Class with unnecessary stateclass OrderValidator {  private config: ValidationConfig;
  constructor(config: ValidationConfig) {    this.config = config;  }
  validate(order: Order): ValidationResult {    // Validation logic that never uses this.config differently    // between calls  }}
// After: Pure functionconst validateOrder = (order: Order, config: ValidationConfig): ValidationResult => {  if (!order.items?.length) return { valid: false, error: 'Order must have items' };  if (!order.customerId) return { valid: false, error: 'Customer ID required' };  if (order.total < config.minimumOrder) return { valid: false, error: 'Order below minimum' };
  return { valid: true };};

Phase 2: Event Handler Functions (Week 2)

Every Lambda function became a simple event handler:

typescript
// orders/handlers/order-created.tsexport const handler = async (event: EventBridgeEvent<'order.created', OrderData>) => {  const { detail: orderData } = event;
  // 1. Validate the order  const validation = validateOrder(orderData, getConfig());  if (!validation.valid) {    await publishEvent('order.validation_failed', {      orderId: orderData.id,      error: validation.error    });    return;  }
  // 2. Save to database  await saveOrder(orderData);
  // 3. Trigger downstream processes  await publishEvent('order.validated', orderData);};
// inventory/handlers/order-validated.tsexport const handler = async (event: EventBridgeEvent<'order.validated', OrderData>) => {  const { detail: orderData } = event;
  // 1. Check inventory  const availability = await checkInventory(orderData.items);  if (!availability.available) {    await publishEvent('order.inventory_failed', {      orderId: orderData.id,      unavailableItems: availability.unavailable    });    return;  }
  // 2. Reserve items  await reserveInventory(orderData.items);
  // 3. Continue the flow  await publishEvent('inventory.reserved', orderData);};

Phase 3: Eliminate Dependency Injection (Week 3)

Instead of injecting dependencies, we used configuration functions and environment-based switching:

typescript
// Before: Complex dependency injectionclass NotificationService {  constructor(    private emailProvider: IEmailProvider,    private smsProvider: ISMSProvider,    private pushProvider: IPushProvider  ) {}}
// After: Simple configuration functionsconst getEmailProvider = (): EmailProvider => {  switch (process.env.EMAIL_PROVIDER) {    case 'sendgrid': return new SendGridProvider();    case 'ses': return new SESProvider();    default: throw new Error('Email provider not configured');  }};
const sendOrderConfirmation = async (orderData: OrderData): Promise<void> => {  const emailProvider = getEmailProvider();  await emailProvider.send({    to: orderData.customerEmail,    template: 'order-confirmation',    data: orderData  });};
// handlers/order-processed.tsexport const handler = async (event: EventBridgeEvent<'order.processed', OrderData>) => {  await sendOrderConfirmation(event.detail);  await publishEvent('notification.sent', {    orderId: event.detail.id,    type: 'order_confirmation'  });};

The Results: Simplicity at Scale

After several weeks of refactoring, our metrics showed meaningful improvements:

Code Reduction

ServiceBefore (Lines)After (Lines)Reduction
Order Service1,24762350%
Payment Service84735658%
Inventory Service62328754%
Notification Service44517860%
Total3,1621,44454%

Development Velocity

MetricBeforeAfter
New feature time2.3 weeks1.2 weeks
Bug fix time4.7 hours1.8 hours
Test writing time40% of development25% of development
Onboarding time3 weeks1.5 weeks
Deployment frequency2x per week4x per week

Bug Reduction

We also saw a reduction in production bugs. Why?

  1. Pure functions are predictable: Same input always produces same output
  2. No hidden state: No instance variables to get into inconsistent states
  3. Easier testing: Mock only external calls, not complex dependency graphs
  4. Clear data flow: Events make system behavior explicit

The Patterns That Emerged

1. Event Handler Pattern

Every Lambda function follows the same simple pattern:

typescript
// Standard event handler templateexport const handler = async (event: EventBridgeEvent<EventType, EventData>) => {  try {    // 1. Extract data    const data = event.detail;
    // 2. Validate (pure function)    const validation = validateData(data);    if (!validation.valid) {      await publishEvent('validation.failed', { error: validation.error });      return;    }
    // 3. Process (side effects)    const result = await processData(data);
    // 4. Publish outcome    await publishEvent('process.completed', result);  } catch (error) {    await publishEvent('process.failed', { error: error.message });    throw error;  }};

2. Pure Business Logic

All business logic became pure functions:

typescript
// Pure functions for business logicexport const calculateOrderTotal = (items: OrderItem[]): number => {  return items.reduce((total, item) => total + (item.price * item.quantity), 0);};
export const applyDiscounts = (total: number, discounts: Discount[]): number => {  return discounts.reduce((amount, discount) => {    return discount.type === 'percentage'      ? amount * (1 - discount.value / 100)      : amount - discount.value;  }, total);};
export const calculateTax = (subtotal: number, taxRate: number): number => {  return subtotal * (taxRate / 100);};
// Composition of pure functionsexport const processOrderCalculation = (order: OrderRequest): OrderCalculation => {  const subtotal = calculateOrderTotal(order.items);  const discountedAmount = applyDiscounts(subtotal, order.discounts);  const tax = calculateTax(discountedAmount, order.taxRate);  const total = discountedAmount + tax;
  return { subtotal, discountedAmount, tax, total };};

3. Configuration over Injection

Instead of dependency injection, we used environment-based configuration:

typescript
// config/database.tsexport const getDatabaseClient = () => {  return process.env.NODE_ENV === 'production'    ? new DocumentClient()    : new LocalDynamoDB();};
// config/events.tsexport const getEventBridge = () => {  return process.env.NODE_ENV === 'production'    ? new EventBridge()    : new LocalEventBus();};
// Usage in handlersconst saveOrder = async (order: OrderData): Promise<void> => {  const db = getDatabaseClient();  await db.put({ TableName: 'Orders', Item: order }).promise();};

Testing: From Nightmare to Joy

Before: Mock Hell

typescript
// Before: Testing required mocking everythingdescribe('OrderService', () => {  let orderService: OrderService;  let mockInventory: jest.Mocked<IInventoryService>;  let mockPayment: jest.Mocked<IPaymentService>;  let mockShipping: jest.Mocked<IShippingService>;  let mockNotification: jest.Mocked<INotificationService>;
  beforeEach(() => {    mockInventory = createMock<IInventoryService>();    mockPayment = createMock<IPaymentService>();    mockShipping = createMock<IShippingService>();    mockNotification = createMock<INotificationService>();
    orderService = new OrderService(      mockInventory,      mockPayment,      mockShipping,      mockNotification    );  });
  it('should process order', async () => {    // 40+ lines of mock setup    mockInventory.checkAvailability.mockResolvedValue({ available: true });    mockPayment.processPayment.mockResolvedValue({ success: true });    // ... 15 more mock setups
    const result = await orderService.processOrder(orderData);
    expect(result.success).toBe(true);    expect(mockInventory.checkAvailability).toHaveBeenCalledWith(orderData.items);    // ... 12 more assertions  });});

After: Pure Function Paradise

typescript
// After: Testing pure functions is trivialdescribe('Order calculations', () => {  it('calculates order total correctly', () => {    const items = [      { price: 10, quantity: 2 },      { price: 5, quantity: 1 }    ];
    expect(calculateOrderTotal(items)).toBe(25);  });
  it('applies percentage discount', () => {    const discounts = [{ type: 'percentage', value: 10 }];
    expect(applyDiscounts(100, discounts)).toBe(90);  });});
// Integration tests for event handlersdescribe('Order created handler', () => {  it('saves valid order and publishes event', async () => {    const mockDb = createMockDB();    const mockEvents = createMockEventBridge();
    await handler(createOrderEvent(validOrderData));
    expect(mockDb.put).toHaveBeenCalledWith(validOrderData);    expect(mockEvents.publish).toHaveBeenCalledWith('order.validated', validOrderData);  });});

Testing improvements: Fewer test cases needed, less mock setup required.

The Monitoring Revolution

With pure functions and events, monitoring became almost trivial:

typescript
// Automatic tracing for every functionimport { captureAWS } from 'aws-xray-sdk';
// Every function is automatically tracedexport const handler = async (event) => {  // X-Ray automatically tracks:  // - Function execution time  // - Database calls  // - Event publishing  // - Error rates
  const result = await processBusinessLogic(event.detail);  await publishEvent('process.completed', result);};
// Business metrics through eventsconst publishBusinessMetric = (metric: string, value: number, tags: Record<string, string>) => {  publishEvent('metric.recorded', { metric, value, tags, timestamp: Date.now() });};
// Usageawait publishBusinessMetric('order.processed', 1, {  paymentMethod: order.paymentMethod,  customerSegment: order.customerSegment});

Observability improvements:

  • Debugging time: Significantly reduced average debugging sessions
  • Mean Time to Detection: Faster incident detection
  • Root cause identification: Improved tracing capabilities
  • Performance monitoring: Built-in with X-Ray

When NOT to Use This Pattern

This functional, event-driven approach isn't always the answer. Here's when to stick with classes:

1. Stateful Operations

typescript
// When you need to maintain state between operationsclass ConnectionManager {  private connections = new Map<string, Connection>();
  async getConnection(id: string): Promise<Connection> {    if (!this.connections.has(id)) {      this.connections.set(id, await createConnection(id));    }    return this.connections.get(id);  }}

2. Complex Lifecycle Management

typescript
// When resources need careful lifecycle managementclass DatabaseMigrator {  constructor(private db: Database) {}
  async migrate(): Promise<void> {    await this.db.startTransaction();    try {      await this.runMigrations();      await this.db.commit();    } catch (error) {      await this.db.rollback();      throw error;    }  }}

3. Framework Integration

typescript
// When working with frameworks that expect classes@Controller('/users')class UserController {  @Get('/:id')  async getUser(@Param('id') id: string): Promise<User> {    return getUserById(id);  }}

The 18-Month Results

After adopting functional, event-driven architecture:

Team Productivity

  • New developer onboarding: Reduced from weeks to days
  • Feature delivery time: Meaningful improvement in development cycles
  • Deployment frequency: More frequent, safer deployments
  • Code review time: Noticeable reduction in review complexity

System Reliability

  • Production incidents: Significant reduction in monthly incidents
  • Bug fix time: Faster resolution of issues
  • System uptime: Improved overall reliability
  • Performance: Better response times across the board

Business Impact

The business impact was meaningful:

  • Development efficiency: Teams could deliver features more quickly
  • Infrastructure costs: Serverless functions reduced operational overhead
  • Quality improvements: Fewer production issues and faster resolution
  • Developer satisfaction: Simpler codebase improved team morale

The Key Insight: Simplicity Scales

The most important lesson from this journey wasn't technical - it was philosophical. Complexity is not sophistication.

The patterns we learned in Java and C# made sense in those contexts, but Node.js shines when you embrace its functional nature:

  1. Functions over classes for stateless operations
  2. Events over method calls for service communication
  3. Configuration over injection for dependencies
  4. Pure functions over complex abstractions for business logic

What's Next: The Future of Node.js Architecture

The serverless revolution has taught us that most enterprise patterns are overengineering. The future of Node.js applications is:

  • Function-first: Every operation as a small, focused function
  • Event-driven: Asynchronous communication by default
  • Stateless: No shared mutable state between operations
  • Observable: Built-in tracing and monitoring

We've proven that you can build sophisticated, scalable systems without factories, dependency injection, or complex class hierarchies. Sometimes the best architecture is the one that gets out of your way.

Next time you find yourself creating a ServiceFactory or writing an interface with a single implementation, ask yourself: "Do I need this complexity, or am I just recreating Java in JavaScript?"

The answer might surprise you.

Related Posts