Replacing Factories and DI in Node.js with Pure Functions
Factories, service layers, and DI containers rarely earn their keep in a Lambda-shaped Node.js service. What replaces them, and where classes still win.
Changing one validation rule in a Node.js payment service can eat an afternoon when the rule sits behind a factory, a dependency injection container, and a stack of interfaces with one implementation each. The cost is the distance between the change you want and the file that holds it. Xia and colleagues measured where that time goes in a field study for IEEE Transactions on Software Engineering: seven projects, 79 professional developers, 3,244 working hours. Comprehension took 57.62% of the working day, navigation another 23.96%, and editing 5.02%.
Most of that machinery is a habit carried over from Java and C#. In a Lambda-shaped service, pure functions plus events do the same work with less indirection: less boilerplate, simpler tests, faster changes. Classes still earn their keep for connection pools, lifecycle management, and framework code, and those cases are worth naming precisely.
The pattern below is the one worth retiring first: a PaymentService assembled by a factory, wired through dependency injection, and hidden behind a dozen interfaces.
// 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 rule that needs to change is a single line of validation: credit card numbers must not contain spaces.
Written as pure functions on an event-driven flow, the same functionality becomes:
// After: Simple, testable, debuggable
export 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);
};
The rule is now one function with one input and one output. The handler next to it either publishes a failure event or charges the card. Neither one needs a container to be constructed, and neither one needs a mock graph to be tested.
The Java-fication of Node.js
Teams coming from Java and C# backgrounds often bring 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, the Node.js codebase looks more like Spring Boot than idiomatic JavaScript. Every simple operation requires navigating through multiple abstraction layers:
// 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 had to mock every node in that graph
// 5. A one-line change rippled through the whole chain of files
The turning point: Adding a “send order confirmation email” feature requires changes across multiple files and services. Understanding the dependency graph becomes a significant onboarding challenge.
What Unnecessary Indirection Costs
The price of an abstraction nobody needed has been measured. Prechelt, Unger, Tichy, Brössler and Votta ran a controlled maintenance experiment with 29 professional software engineers, published in IEEE Transactions on Software Engineering in 2001. On one of the programs, engineers who did not already know the pattern needed 151% more time to change the pattern version than the simpler alternative. That was 46.6 minutes against 18.5 minutes, at p < 0.001. It is the measured version of an afternoon disappearing into a one-line rule change.
The same experiment is also the best argument against pulling patterns out on principle. Across its nine maintenance tasks, most results favored the pattern version. The authors close by advising that absent a clear reason to prefer the simpler solution, the flexibility a pattern provides is probably the better buy. Unexpected requirements keep arriving. Read whole, the finding is narrower than a blanket case against patterns: the tax is paid by whoever does not already hold the design in their head. Xia and colleagues point the same way, reporting that senior developers spend a smaller share of their time on comprehension than junior developers do. The same study cites prior work putting IT staff turnover between 20% and 35%. Someone on the team is always new to the graph.
Defect data complicates the order of removal. Vokáč tracked three years of weekly evolution history in a 500,000-line commercial C++ product for IEEE Transactions on Software Engineering. Defect rates differed sharply by pattern: code related to Factory carried 63% of the codebase’s average defect rate, while Singleton sat at 135% and Observer at 155%. Vokáč credits that low rate to Factory marking less complex, less central code rather than to the pattern itself, and concludes that using patterns is by itself no guarantee of few defects. That narrows the case against the factory in the opening snippet. It earns removal on one specific ground: it only constructs and never chooses, and a graph with no decision in it does not need a class to build it.
The dependency injection case is thinner still, in both directions. Razina and Janzen compared 20 matched pairs of SourceForge projects, one using dependency injection and one comparable project without it. They found no correlation between DI use and coupling or cohesion numbers. A trend toward lower coupling appeared only above 10% DI usage. Sun and Kim returned to the question in 2022 and reported that earlier work offers inferences but no conclusive evidence either way. Neither result shows DI harming a codebase. They show that the maintainability payoff has not been demonstrated, which is a poor basis for making the container a default.
Lambda adds a cost those studies cannot see, and AWS documents it. On-demand concurrency limits the Init phase to 10 seconds. Provisioned concurrency and SnapStart get a longer budget instead: 130 seconds or the configured function timeout, whichever is higher. AWS names initialization code as the largest contributor of latency before function execution, with package size and the amount of initialization work as the drivers. Which phase a factory lands in depends on where it is called, and the snippet above never shows the call site. Assigned to a module-scope constant, PaymentServiceFactory.create runs once per execution environment, and its object graph is Init work. Called from inside the handler, it rebuilds validators, adapters, and loggers on every invocation, warm ones included. AWS’s own remedy addresses the first placement and is the shape argued for here: rearchitect a function with many objects and connections into several smaller, specialized functions that each carry less initialization code. The bound follows from the placement. AWS puts cold starts at under 1% of invocations, lasting from under 100 ms to over 1 second. A slimmer graph built at module scope therefore buys latency on fewer than one invocation in a hundred, and the win shows up only in tail latency. Built inside the handler, the same graph is paid on every request, and the cold-start figure sets no bound on that cost.
The Event-Driven Insight
The breakthrough arrives when teams recognize that most “services” are just event handlers in disguise.
Instead of synchronous service calls:
// Synchronous coupling nightmare
await orderService.processOrder(orderData);
await inventoryService.updateStock(orderData.items);
await paymentService.chargeCard(orderData.payment);
await shippingService.scheduleDelivery(orderData.shipping);
await notificationService.sendConfirmation(orderData.customer);
The same flow can be modeled as events:
// Event-driven decoupling bliss
await 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: when every operation is an event handler, the class around it is a wrapper with nothing left to hold.
The Refactoring: From Classes to Functions
Phase 1: Identify Pure Operations
The refactoring starts by identifying operations that are:
- Stateless (no instance variables)
- Side-effect free (except for database/API calls)
- Easily testable (input → output)
// Before: Class with unnecessary state
class 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 function
const 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
Each Lambda function becomes a simple event handler:
// orders/handlers/order-created.ts
export 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.ts
export 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
Instead of injecting dependencies, reach for configuration functions and environment-based switching:
// Before: Complex dependency injection
class NotificationService {
constructor(
private emailProvider: IEmailProvider,
private smsProvider: ISMSProvider,
private pushProvider: IPushProvider
) {}
}
// After: Simple configuration functions
const 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.ts
export const handler = async (event: EventBridgeEvent<'order.processed', OrderData>) => {
await sendOrderConfirmation(event.detail);
await publishEvent('notification.sent', {
orderId: event.detail.id,
type: 'order_confirmation'
});
};
Fewer Places for Defects to Hide
Pure functions and explicit events narrow the surface where a defect can survive:
- Pure functions are predictable: Same input always produces same output
- No hidden state: No instance variables to get into inconsistent states
- Easier testing: Mock only external calls, not complex dependency graphs
- Clear data flow: Events make system behavior explicit
The caution from the defect data still applies. Shape guarantees nothing on its own; it narrows the space where a defect can survive unnoticed.
Emergent Patterns
1. Event Handler Pattern
Every Lambda function follows the same simple pattern:
// Standard event handler template
export 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
Business logic moves into pure functions:
// Pure functions for business logic
export 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 functions
export 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, dependencies resolve from environment-based configuration:
// config/database.ts
// Built once per execution environment so warm invocations reuse it
let dbClient: DocumentClient | LocalDynamoDB | undefined;
export const getDatabaseClient = () => {
dbClient ??= process.env.NODE_ENV === 'production'
? new DocumentClient()
: new LocalDynamoDB();
return dbClient;
};
// config/events.ts
let eventBus: EventBridge | LocalEventBus | undefined;
export const getEventBridge = () => {
eventBus ??= process.env.NODE_ENV === 'production'
? new EventBridge()
: new LocalEventBus();
return eventBus;
};
// Usage in handlers
const saveOrder = async (order: OrderData): Promise<void> => {
const db = getDatabaseClient();
await db.put({ TableName: 'Orders', Item: order }).promise();
};
Testing Without the Mock Graph
Before: Mocking the Whole Graph
// Before: Testing required mocking everything
describe('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: Testing Plain Functions
// After: Testing pure functions is trivial
describe('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 handlers
describe('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);
});
});
Tracing and Business Metrics
With pure functions and events, instrumentation stays at the edges of the handler:
import AWSSDK from 'aws-sdk';
import { captureAWS } from 'aws-xray-sdk';
// Wrap the SDK once per module. Active tracing must be enabled
// on the function for X-Ray to record the segments.
const AWS = captureAWS(AWSSDK);
export const handler = async (event) => {
// With clients built from the wrapped SDK, X-Ray records:
// - Function execution time
// - Database calls
// - Event publishing
// - Error rates
const result = await processBusinessLogic(event.detail);
await publishEvent('process.completed', result);
};
// Business metrics through events
const publishBusinessMetric = (metric: string, value: number, tags: Record<string, string>) => {
publishEvent('metric.recorded', { metric, value, tags, timestamp: Date.now() });
};
// Usage
await publishBusinessMetric('order.processed', 1, {
paymentMethod: order.paymentMethod,
customerSegment: order.customerSegment
});
Because every step publishes an event, a stalled order can be traced to the last event it produced. That narrows the search to one handler instead of five services’ logs read side by side.
What to Measure in Your Own Codebase
No published study isolates classes against pure functions and measures what a team produces on either side of that line. The line counts and velocity numbers that usually decorate a refactoring write-up are first-party or they are nothing, so the useful move is to collect them properly rather than borrow them.
The mechanism says where to look. Editing was the smallest slice of the working day in the field study above, so counting lines added or removed measures the cheapest part of the job. What this change actually moves is the distance between a behavior and the file that owns it. Three proxies track that distance: how many files a single behavior change touches, how long a change waits between first commit and production, and how much of a handler’s test setup has to be rebuilt when the behavior changes.
Take one named surface, record those three before the refactoring, record them again after a few weeks of ordinary work on it, and publish the method next to the result. DORA’s delivery metrics (deployment frequency, lead time for changes, time to restore service, change failure rate) give a familiar frame for a before and after. Their scope stops short of this question, though. DORA defines loosely coupled architecture around teams deploying independently of the services they depend on, which covers the move from synchronous calls to events and says nothing about whether a handler is a class or a function.
When Classes Are Still the Right Choice
The functional, event-driven shape is a default, not a rule. Three cases still call for a class:
1. Stateful Operations
// When you need to maintain state between operations
class 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
// When resources need careful lifecycle management
class 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
// When working with frameworks that expect classes
@Controller('/users')
class UserController {
@Get('/:id')
async getUser(@Param('id') id: string): Promise<User> {
return getUserById(id);
}
}
Four Defaults Worth Flipping
Patterns from Java and C# fit their own ecosystems. In Node.js, these four choices make a better starting point:
- Functions over classes for stateless operations
- Events over method calls for service communication
- Configuration over injection for dependencies
- Pure functions over complex abstractions for business logic
The default holds while a handler stays stateless and its work fits inside one event: write the function, wire it to an event, keep the business logic pure. Override it when something has to outlive a single invocation, such as a connection pool, a migration transaction, or a framework that expects a class. Before adding a ServiceFactory or an interface with a single implementation, check whether the abstraction earns its cost in this codebase, or whether it is a habit carried over from a different language.
References
- What is AWS Lambda? - AWS Lambda - Execution model and stateless function design principles that underpin the pure-function approach
- Best practices for working with AWS Lambda functions - AWS guidance on single-purpose functions, handler structure, and avoiding shared mutable state
- Building Lambda functions with Node.js - AWS Lambda - Node.js runtime configuration, handler patterns, and module initialization for Lambda
- Using Lambda with Amazon SQS - AWS Lambda - Event-driven invocation via SQS as an alternative to direct method calls between services
- What Is Amazon EventBridge? - Event bus model for decoupled, asynchronous communication between serverless functions
- Serverless Applications Lens - AWS Well-Architected Framework - Design principles: speedy, simple, singular functions and share-nothing stateless architecture
- Understanding the Lambda execution environment lifecycle - AWS Lambda Operator Guide - The 10-second Init phase limit for on-demand concurrency, the longer budget provisioned concurrency and SnapStart get instead, what drives initialization latency, and how often cold starts actually occur
- Measuring Program Comprehension: A Large-Scale Field Study with Professionals (IEEE TSE, 2018) - Seven projects, 79 professional developers, and 3,244 working hours of instrumented work, split between comprehension, navigation, and editing
- A Controlled Experiment in Maintenance Comparing Design Patterns to Simpler Solutions (IEEE TSE, 2001) - Maintenance experiment with 29 professional engineers measuring both the cost of an unneeded pattern and the tasks where the pattern still paid off
- Defect Frequency and Design Patterns: An Empirical Study of Industrial Code (IEEE TSE, 2004) - Defect rates per pattern across three years of a 500,000-line commercial product, with Factory, Singleton, and Observer measured separately
- Effects of Dependency Injection on Maintainability (IASTED SEA, 2007) - Twenty matched pairs of open-source projects compared on coupling and cohesion with and without dependency injection
- Analyzing Impact of Dependency Injection on Software Maintainability (arXiv, 2022) - Survey of what the evidence on dependency injection and maintainability does and does not establish, plus a proposed metric
- Loosely coupled architecture - DORA - How DORA scopes architectural decoupling to independent deployment, which bounds what its delivery metrics can tell you
Related posts
A practical guide to evolving Node.js monoliths into event-driven serverless functions, with migration strategies and proven architectural patterns.
A practical guide to the CloudEvents spec and TypeScript SDK: create, parse, and validate standardized events across AWS Lambda and EventBridge.
Set up a production-grade link shortener with AWS CDK, DynamoDB, and Lambda: architecture decisions, project layout, and the schema choices that hold up at scale.
Build the redirect engine, analytics collection, and API Gateway config: performance optimizations and debugging strategies for millions of daily redirects.
Hold AWS Lambda warm-path latency inside a 10 ms budget with runtime choice, connection reuse, bundle discipline, caching, and memory tuning.