Node.js makes it easy to build an API, connect a database, and ship a working backend. The real challenge begins when that application grows, business rules become complicated, multiple developers contribute, and a small change unexpectedly breaks another feature.
Advanced Node.js architecture is about managing that complexity without making development unnecessarily difficult. By separating business logic from infrastructure, defining clear module boundaries, and designing reliable communication between components, we can build applications that remain maintainable as requirements evolve.
In this article, we'll explore a practical modular architecture using dependency injection, repositories, services, centralized error handling, and asynchronous workflows. We'll implement a runnable JavaScript example using only Node.js built-in modules to understand the underlying design before introducing frameworks or external libraries.
Designing a Modular Node.js Architecture
A scalable Node.js application should separate responsibilities rather than placing routing, validation, database queries, and business rules inside a single file. A useful starting point is a layered architecture consisting of a presentation layer, an application layer, a domain layer, and an infrastructure layer. Each layer has a clear purpose, making it easier to test individual components and replace implementation details without rewriting the entire application.
The presentation layer handles incoming HTTP requests and converts them into application commands. The application layer coordinates use cases, the domain layer protects business rules, and the infrastructure layer implements external concerns such as database access, caching, messaging, and logging. Dependency injection connects these components while keeping high-level business logic independent of specific infrastructure implementations.
For larger systems, modular monoliths are often a sensible starting point. Each business module, such as users, orders, or payments, owns its application logic and exposes a small public interface. This approach provides strong boundaries without immediately introducing the operational complexity of microservices, distributed transactions, service discovery, and network communication.
Consider an order-processing feature. The HTTP handler receives a request, an order service validates the requested operation, and a repository persists the order. An event publisher then announces that the order was created, allowing other components to react without tightly coupling the order service to email notifications or analytics. In production, this event publication should be made reliable with an outbox pattern when database changes and message delivery must remain consistent.
The following example demonstrates these architectural principles with an in-memory repository, an injected event publisher, structured logging, domain-specific errors, and a service responsible for business rules. It also demonstrates how dependencies can be replaced during testing, making the design easier to extend and maintain.
const { randomUUID } = require('node:crypto');
// 1. Define a domain-specific error for expected business failures.
class DomainError extends Error {
constructor(message, code, statusCode = 400) {
super(message);
this.name = 'DomainError';
this.code = code;
this.statusCode = statusCode;
}
}
// 2. Add structured logging for easier debugging and observability.
const logger = {
info(message, metadata = {}) {
console.log(JSON.stringify({ level: 'info', message, ...metadata }));
},
error(message, metadata = {}) {
console.error(JSON.stringify({ level: 'error', message, ...metadata }));
}
};
// 3. Implement an in-memory repository behind a simple interface.
// Replace this implementation with PostgreSQL or MongoDB in production.
class InMemoryOrderRepository {
constructor() {
this.orders = new Map();
}
async save(order) {
this.orders.set(order.id, { ...order });
return { ...order };
}
async findById(id) {
const order = this.orders.get(id);
return order ? { ...order } : null;
}
async count() {
return this.orders.size;
}
}
// 4. Keep event publishing independent from the order service.
// A real implementation could publish messages to a durable broker.
class ConsoleEventPublisher {
async publish(eventName, payload) {
logger.info('Event published', { eventName, payload });
}
}
// 5. Implement business rules in the application service.
class OrderService {
constructor({ orderRepository, eventPublisher }) {
if (!orderRepository || !eventPublisher) {
throw new Error('OrderService dependencies are required');
}
this.orderRepository = orderRepository;
this.eventPublisher = eventPublisher;
}
async createOrder(input) {
logger.info('Starting order creation');
// Validate input before performing persistence operations.
if (!input || typeof input.customerId !== 'string') {
throw new DomainError('A valid customerId is required', 'INVALID_CUSTOMER');
}
if (!Array.isArray(input.items) || input.items.length === 0) {
throw new DomainError('An order must contain at least one item', 'EMPTY_ORDER');
}
// Calculate totals on the server instead of trusting client totals.
const total = input.items.reduce((sum, item) => {
if (!Number.isSafeInteger(item.quantity) || item.quantity <= 0) {
throw new DomainError('Quantity must be a positive integer', 'INVALID_QUANTITY');
}
if (!Number.isSafeInteger(item.priceInCents) || item.priceInCents < 0) {
throw new DomainError('Price must be non-negative integer cents', 'INVALID_PRICE');
}
return sum + item.quantity * item.priceInCents;
}, 0);
if (!Number.isSafeInteger(total)) {
throw new DomainError('Order total exceeds supported limits', 'INVALID_TOTAL');
}
// Construct a domain object with server-generated identity and status.
const order = {
id: randomUUID(),
customerId: input.customerId,
items: input.items.map(item => ({
productId: item.productId,
quantity: item.quantity,
priceInCents: item.priceInCents
})),
totalInCents: total,
status: 'created',
createdAt: new Date().toISOString()
};
// Persist the order before notifying downstream consumers.
const savedOrder = await this.orderRepository.save(order);
// Production systems should use an outbox or another durable strategy.
await this.eventPublisher.publish('order.created', {
orderId: savedOrder.id,
customerId: savedOrder.customerId,
totalInCents: savedOrder.totalInCents
});
logger.info('Order created successfully', { orderId: savedOrder.id });
return savedOrder;
}
async getOrder(orderId) {
const order = await this.orderRepository.findById(orderId);
if (!order) {
throw new DomainError('Order not found', 'ORDER_NOT_FOUND', 404);
}
return order;
}
}
// 6. Compose dependencies in one place: the composition root.
function createApplication(overrides = {}) {
const orderRepository = overrides.orderRepository || new InMemoryOrderRepository();
const eventPublisher = overrides.eventPublisher || new ConsoleEventPublisher();
const orderService = new OrderService({ orderRepository, eventPublisher });
return { orderService, orderRepository };
}
// 7. Run a complete example with step-by-step output.
async function main() {
logger.info('Step 1: Initialize application dependencies');
const { orderService, orderRepository } = createApplication();
logger.info('Step 2: Prepare a sample order');
const input = {
customerId: 'customer-123',
items: [
{ productId: 'keyboard', quantity: 1, priceInCents: 7500 },
{ productId: 'mouse', quantity: 2, priceInCents: 2500 }
]
};
try {
logger.info('Step 3: Create the order');
const order = await orderService.createOrder(input);
logger.info('Step 4: Read the persisted order', { orderId: order.id });
const retrievedOrder = await orderService.getOrder(order.id);
console.log('Retrieved order:', JSON.stringify(retrievedOrder, null, 2));
logger.info('Step 5: Check repository statistics', {
orderCount: await orderRepository.count()
});
// Invalid input demonstrates predictable domain error handling.
logger.info('Step 6: Demonstrate validation failure');
try {
await orderService.createOrder({ customerId: 'customer-123', items: [] });
} catch (error) {
if (error instanceof DomainError) {
logger.info('Expected business error', {
code: error.code,
message: error.message
});
} else {
throw error;
}
}
} catch (error) {
logger.error('Application operation failed', {
name: error.name,
message: error.message,
code: error.code || 'INTERNAL_ERROR'
});
process.exitCode = 1;
}
}
// 8. Handle unexpected failures at the process entry point.
main().catch(error => {
logger.error('Unexpected application failure', { message: error.message });
process.exitCode = 1;
});
Conclusion
Advanced Node.js architecture is less about adopting a particular folder structure and more about establishing clear responsibilities and reliable boundaries. Separating business rules from persistence, injecting dependencies, validating inputs, and using consistent error handling makes backend systems easier to test and change as they grow.
Start with a modular monolith and introduce additional infrastructure only when the requirements justify it. For production systems, consider request validation, authentication and authorization, database transactions, connection pooling, structured observability, graceful shutdown, health checks, and integration tests. When asynchronous events become business-critical, use durable messaging and patterns such as the transactional outbox rather than relying on an in-memory publisher.
The goal is not to build the most complicated architecture possible. It is to build the simplest architecture that protects your business rules today while leaving room for tomorrow's requirements. Which architectural pattern has made the biggest difference in your Node.js projects?
Top comments (0)