Practical guide to hexagonal architecture with Node.js & TypeScript
Master Node.js hexagonal architecture with TypeScript. A practical guide for B2B software leaders to build scalable, maintainable applications.
Practical Guide to Hexagonal Architecture with Node.js & TypeScript
In the fast-paced world of B2B software development, especially for agencies and startups, the ability to build applications that are not only functional but also adaptable and maintainable is paramount. As your product evolves and market demands shift, your codebase needs to be resilient. This is where Hexagonal Architecture, often referred to as Ports and Adapters, shines. This guide will walk you through implementing Hexagonal Architecture with Node.js and TypeScript, empowering your technology teams to build more robust and scalable solutions.
The Core Problem: Tight Coupling and its Consequences
Traditional monolithic architectures, while simple to start with, often lead to tightly coupled systems. This means different components are heavily dependent on each other. When you need to change one part, you risk breaking others. For product leaders and CTOs, this translates into:
- Slowed Development Cycles: Adding new features or integrating third-party services becomes a complex and time-consuming endeavor.
- Increased Technical Debt: The codebase becomes harder to understand and modify, leading to more bugs and higher maintenance costs.
- Testing Challenges: Unit and integration testing become significantly more difficult, impacting quality assurance.
- Vendor Lock-in: Dependence on specific frameworks or databases can make future migrations prohibitively expensive.
Consider a scenario where your Node.js application directly interacts with a specific database driver. If you later decide to switch to a different database system, you’ll need to refactor a significant portion of your application’s core logic. This is a prime example of tight coupling.
Understanding Hexagonal Architecture: Ports and Adapters Explained
Hexagonal Architecture, conceptualized by Alistair Cockburn, aims to solve the tight coupling problem by separating the application’s core business logic from its external concerns. The core idea is to treat the application as a “hexagon” with “ports” on each side. These ports represent the interfaces through which the application interacts with the outside world. “Adapters” then plug into these ports to handle specific technologies or protocols.
Think of it this way:
- The Hexagon (Core Domain): This is the heart of your application. It contains all the business rules, use cases, and domain entities. Crucially, it has no knowledge of external technologies like databases, UI frameworks, or message queues.
- Ports: These are interfaces defined within the core domain. They specify what the application can do (e.g.,
UserServicePortwith methods likecreateUser,getUserById) and what information it needs from the outside (e.g.,UserRepositoryPortwith methods likesaveUser,findUserById). - Adapters: These are external components that implement or consume the ports. They translate between the external world and the core domain.
- Driving Adapters (Left Side): These initiate actions in the core. Examples include:
- REST API Adapters: Handling incoming HTTP requests.
- CLI Adapters: Responding to command-line commands.
- Event Listeners: Triggering actions based on external events.
- Driven Adapters (Right Side): These are driven by the core domain to perform actions. Examples include:
- Database Adapters: Interacting with a specific database (e.g., PostgreSQL, MongoDB).
- Email Service Adapters: Sending emails.
- External API Client Adapters: Communicating with third-party services.
- Driving Adapters (Left Side): These initiate actions in the core. Examples include:
The beauty of this approach is that the core domain remains technology-agnostic. You can swap out adapters without affecting the core logic, drastically improving maintainability and testability.
Implementing Hexagonal Architecture in Node.js with TypeScript
Let’s dive into a practical implementation using Node.js and TypeScript. We’ll focus on a simple user management system.
1. Defining the Core Domain and Ports
First, we establish the core business logic and the interfaces (ports) that define how it interacts with the outside.
src/domain/user/user.entity.ts
export class User {
constructor(
public readonly id: string,
public readonly name: string,
public readonly email: string
) {}
}
src/domain/user/user.repository.port.ts
This is an inbound port (driven by the domain) that defines how the domain expects to interact with data storage.
import { User } from './user.entity';
export interface UserRepositoryPort {
save(user: User): Promise<void>;
findById(id: string): Promise<User | null>;
findByEmail(email: string): Promise<User | null>;
}
src/domain/user/user.service.port.ts
This is an outbound port (driving the domain) that defines the use cases the application exposes.
import { User } from './user.entity';
export interface UserServicePort {
createUser(name: string, email: string): Promise<User>;
getUserById(id: string): Promise<User | null>;
getUserByEmail(email: string): Promise<User | null>;
}
2. Implementing the Core Domain Logic (Application Layer)
This layer orchestrates the use cases, depending only on the ports defined in the domain.
src/application/user/user.service.ts
This class implements the UserServicePort and uses the UserRepositoryPort.
import { User } from '../../domain/user/user.entity';
import { UserRepositoryPort } from '../../domain/user/user.repository.port';
import { UserServicePort } from '../../domain/user/user.service.port';
import { v4 as uuidv4 } from 'uuid'; // Example for ID generation
export class UserService implements UserServicePort {
constructor(private readonly userRepository: UserRepositoryPort) {}
async createUser(name: string, email: string): Promise<User> {
const existingUser = await this.userRepository.findByEmail(email);
if (existingUser) {
throw new Error('User with this email already exists.');
}
const newUser = new User(uuidv4(), name, email);
await this.userRepository.save(newUser);
return newUser;
}
async getUserById(id: string): Promise<User | null> {
return this.userRepository.findById(id);
}
async getUserByEmail(email: string): Promise<User | null> {
return this.userRepository.findByEmail(email);
}
}
Notice how UserService depends on an interface (UserRepositoryPort), not a concrete implementation. This is key.
3. Developing Adapters
Now we create the adapters that connect our core logic to the outside world.
3.1. Driven Adapter: Database Implementation
Let’s create an adapter for a hypothetical InMemoryUserRepository for testing purposes, and then a PostgresUserRepository.
src/infrastructure/persistence/in-memory/in-memory.user.repository.ts
import { User } from '../../../domain/user/user.entity';
import { UserRepositoryPort } from '../../../domain/user/user.repository.port';
export class InMemoryUserRepository implements UserRepositoryPort {
private users: Map<string, User> = new Map();
async save(user: User): Promise<void> {
this.users.set(user.id, user);
}
async findById(id: string): Promise<User | null> {
return this.users.get(id) || null;
}
async findByEmail(email: string): Promise<User | null> {
for (const user of this.users.values()) {
if (user.email === email) {
return user;
}
}
return null;
}
}
src/infrastructure/persistence/postgres/postgres.user.repository.ts
(This is a simplified example. A real implementation would use a proper ORM like TypeORM or Prisma.)
import { User } from '../../../domain/user/user.entity';
import { UserRepositoryPort } from '../../../domain/user/user.repository.port';
// Assume 'dbClient' is an instance of your database client (e.g., Pool from 'pg')
export class PostgresUserRepository implements UserRepositoryPort {
// constructor(private readonly dbClient: any) {} // Example dependency
async save(user: User): Promise<void> {
console.log(`Saving user to PostgreSQL: ${user.email}`);
// Example: await this.dbClient.query('INSERT INTO users (id, name, email) VALUES ($1, $2, $3)', [user.id, user.name, user.email]);
}
async findById(id: string): Promise<User | null> {
console.log(`Finding user by ID in PostgreSQL: ${id}`);
// Example: const result = await this.dbClient.query('SELECT * FROM users WHERE id = $1', [id]);
// if (result.rows.length > 0) return new User(result.rows[0].id, result.rows[0].name, result.rows[0].email);
return null; // Placeholder
}
async findByEmail(email: string): Promise<User | null> {
console.log(`Finding user by email in PostgreSQL: ${email}`);
// Example: const result = await this.dbClient.query('SELECT * FROM users WHERE email = $1', [email]);
// if (result.rows.length > 0) return new User(result.rows[0].id, result.rows[0].name, result.rows[0].email);
return null; // Placeholder
}
}
3.2. Driving Adapter: REST API Implementation
This adapter handles incoming HTTP requests and uses the UserServicePort.
src/interfaces/http/user.controller.ts
import { Request, Response } from 'express';
import { UserServicePort } from '../../domain/user/user.service.port';
export class UserController {
constructor(private readonly userService: UserServicePort) {}
async createUser(req: Request, res: Response): Promise<void> {
const { name, email } = req.body;
try {
const newUser = await this.userService.createUser(name, email);
res.status(201).json(newUser);
} catch (error: any) {
res.status(400).json({ message: error.message });
}
}
async getUserById(req: Request, res: Response): Promise<void> {
const { id } = req.params;
try {
const user = await this.userService.getUserById(id);
if (user) {
res.json(user);
} else {
res.status(404).json({ message: 'User not found' });
}
} catch (error: any) {
res.status(500).json({ message: error.message });
}
}
}
4. Dependency Injection and Wiring
The final step is to wire everything together. Dependency Injection (DI) is crucial here. We instantiate concrete implementations of ports and pass them to the services and controllers that depend on them.
src/main.ts (or your application entry point)
import express from 'express';
import bodyParser from 'body-parser';
import { UserService } from './application/user/user.service';
import { UserController } from './interfaces/http/user.controller';
import { InMemoryUserRepository } from './infrastructure/persistence/in-memory/in-memory.user.repository';
// import { PostgresUserRepository } from './infrastructure/persistence/postgres/postgres.user.repository';
const app = express();
app.use(bodyParser.json());
// --- Dependency Wiring ---
// Choose your repository implementation
const userRepository = new InMemoryUserRepository();
// const userRepository = new PostgresUserRepository(/* pass db client here */);
// Instantiate the application service
const userService = new UserService(userRepository);
// Instantiate the controller
const userController = new UserController(userService);
// --- Define Routes ---
app.post('/users', (req, res) => userController.createUser(req, res));
app.get('/users/:id', (req, res) => userController.getUserById(req, res));
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
Benefits of Hexagonal Architecture for B2B Software
Adopting Hexagonal Architecture offers significant advantages for product leaders, CTOs, and their teams:
-
Enhanced Maintainability:
- Reduced Complexity: The separation of concerns makes the codebase easier to understand and navigate. Developers can focus on business logic without getting bogged down in infrastructure details.
- Easier Bug Fixing: Isolating bugs becomes simpler as issues are often confined to specific adapters or the core domain.
- Improved Code Readability: Clear interfaces and well-defined responsibilities lead to more readable and maintainable code.
-
Increased Testability:
- Independent Testing: The core domain logic can be tested in isolation, without needing a database or external services. This leads to faster and more reliable unit tests.
- Mocking Simplicity: Mocking dependencies (like repositories) for testing becomes straightforward by simply creating mock implementations of the ports.
- Higher Test Coverage: Achieving high test coverage for critical business logic becomes more feasible.
-
Greater Flexibility and Adaptability:
- Technology Agnosticism: Easily swap out database technologies, UI frameworks, or external service integrations without rewriting core business logic. This is invaluable for long-term product strategy and avoiding vendor lock-in.
- Faster Feature Development: New features can be implemented by adding new use cases in the domain and corresponding adapters, minimizing impact on existing functionality.
- Easier Integration: Integrating with new systems or partners becomes simpler due to the well-defined port interfaces.
-
Improved Scalability:
- Decoupled Components: Allows individual components or adapters to be scaled independently based on demand.
- Microservices Readiness: The principles of Hexagonal Architecture align well with microservices, making it easier to break down a monolith into smaller, independently deployable services if needed.
-
Reduced Technical Debt:
- By proactively addressing dependencies and promoting a clean architecture, you significantly reduce the accumulation of technical debt, leading to lower long-term development costs and risks.
Measuring the Impact: KPIs and Metrics
When implementing Hexagonal Architecture, you can track several key performance indicators (KPIs) to measure its effectiveness:
- Development Velocity: Monitor the average time to deliver new features. A well-architected system should see an increase in this metric over time.
- Bug Count: Track the number of critical and major bugs reported per release. Reduced bug counts indicate improved code quality and stability.
- Mean Time To Recovery (MTTR): Measure how quickly your team can resolve production issues. Decoupled systems are generally easier to debug and fix.
- Test Execution Time: Observe the speed of your test suites. Faster tests encourage more frequent testing.
- Cost of Change: Estimate the effort required to implement common changes (e.g., switching database, adding a new integration). Hexagonal architecture should demonstrably reduce this cost.
- Code Complexity Metrics: Tools like SonarQube can measure cyclomatic complexity and code duplication. A well-architected system will often show lower complexity scores in core modules.
Checklist for Implementing Hexagonal Architecture
For teams looking to adopt this pattern, here’s a practical checklist:
- Identify Core Domain: Clearly define the business rules and entities that form the heart of your application.
- Define Ports: Design clear interfaces for all interactions between the core domain and the outside world (both inbound and outbound).
- Keep Domain Pure: Ensure the core domain has zero dependencies on external technologies or frameworks.
- Implement Application Services: Create services that orchestrate use cases, depending only on domain ports.
- Develop Adapters: Build concrete implementations for each port, handling specific technologies (e.g., database drivers, API clients, UI frameworks).
- Establish Dependency Injection: Use a DI container or manual wiring to inject concrete implementations into services and controllers.
- Prioritize Testing: Write comprehensive tests for the core domain and application services, using mocks for external dependencies.
- Iterate and Refactor: Start with a few key areas and gradually apply the principles across your application. Be prepared to refactor as your understanding evolves.
Conclusion: Building Resilient and Future-Proof Software
Hexagonal Architecture, when implemented with Node.js and TypeScript, provides a powerful framework for building B2B software that is resilient, maintainable, and adaptable. By separating your core business logic from external concerns, you empower your teams to develop faster, reduce technical debt, and create applications that can evolve with your business needs. This architectural pattern is not just a technical choice; it’s a strategic investment in the long-term success and agility of your product.
At Alken, we specialize in helping product leaders and CTOs navigate complex architectural decisions and implement best practices that drive business value. If you’re looking to build scalable, maintainable, and future-proof Node.js applications, we can help you leverage patterns like Hexagonal Architecture effectively.
Ready to transform your software architecture? Contact us at [email protected] to discuss your project and explore how Alken can empower your technology teams.