Architecture as a Force Multiplier
Any engineer can write Node.js code that works. The discipline of software architecture is writing code that continues to work — and continues to be changeable — as requirements evolve, the team grows, and the application scales. Design patterns are battle-tested solutions to recurring problems that the industry has refined over decades.
The Repository Pattern: Isolating Your Database Logic
The Repository Pattern creates a dedicated abstraction layer between your business logic and your database. Instead of calling Mongoose or pg-promise directly inside your route handlers, you delegate all data access to repository classes that speak only the language of your domain objects.
class UserRepository {
async findById(id) {
return await User.findById(id).select('-password').lean();
}
async findByEmail(email) {
return await User.findOne({ email: email.toLowerCase() }).lean();
}
async create(userData) {
const user = new User(userData);
return await user.save();
}
async update(id, updates) {
return await User.findByIdAndUpdate(id, updates, { new: true, runValidators: true }).lean();
}
}
module.exports = new UserRepository();
This pattern provides enormous benefits: your route handlers and services become database-agnostic, testing becomes trivial (mock the repository and your business logic tests never touch a database), and your data access patterns are centralized rather than scattered across hundreds of files.
The Service Layer: Where Business Logic Lives
Controllers should be the thinnest layer in your application. Their job is: receive the HTTP request, validate input, call the appropriate service function, and send the response. All business logic — validation rules, workflow orchestration, side effects like sending emails or triggering webhooks — belongs in service functions.
This separation means your business logic can be invoked from multiple entry points — an HTTP controller, a cron job, a CLI command — without duplication. The service does not know or care how it was called.
Factory Functions: Dependency Injection Without Ceremony
In modern Node.js, factory functions often produce cleaner, more testable code than classes. A factory function accepts its dependencies as arguments (explicit dependency injection) and returns an object with methods. This eliminates the ambiguity of this binding and makes dependencies explicit at the call site:
const createEmailService = ({ sendgridClient, fromAddress }) => ({
async sendWelcomeEmail(to, name) {
return sendgridClient.send({ from: fromAddress, to, subject: 'Welcome!', html: welcomeTemplate(name) });
},
});
// In tests, inject a mock client
const emailService = createEmailService({ sendgridClient: mockSendgridClient, fromAddress: 'test@example.com' });
The Middleware Pipeline: Express Architecture
Every piece of cross-cutting concern — logging, authentication, rate limiting, input parsing, error handling — belongs in middleware, not in route handlers. The correct middleware order is: security headers (Helmet), rate limiting, CORS, request parsing, request logging, authentication, then routing. Error handling middleware always goes last.
Conclusion
These patterns — Repository, Service Layer, Factory Functions, Middleware Pipeline, Centralized Errors — are the architecture vocabulary of professional Node.js development. Apply them systematically from the beginning of a project, and you produce codebases that new team members can navigate, that can be tested without databases or external services, and that can be extended without fear of cascading breakage.
