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:

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 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:

Measuring the Impact: KPIs and Metrics

When implementing Hexagonal Architecture, you can track several key performance indicators (KPIs) to measure its effectiveness:

Checklist for Implementing Hexagonal Architecture

For teams looking to adopt this pattern, here’s a practical checklist:

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.