Published on

Hexagonal Architecture in Node.js — Ports, Adapters, and a Codebase You Can Actually Test

Authors

Introduction

Hexagonal architecture (ports and adapters) inverts the dependency graph. Your domain sits at the center, completely isolated from frameworks. Everything else—Express, database drivers, third-party APIs—becomes a replaceable adapter implementing a port (interface). This makes testing effortless and framework swaps trivial.

Domain at the Center: No Framework Imports

The core rule: domain code never imports from Express, Prisma, or any framework. It only knows about interfaces it defines.

// src/domain/entities/user.ts
// NO imports from any framework - pure TypeScript

export interface UserProfile {
  id: string
  email: string
  name: string
  createdAt: Date
}

export class User {
  constructor(
    public id: string,
    public email: string,
    public name: string,
    public createdAt: Date = new Date()
  ) {
    this.validate()
  }

  private validate() {
    if (!this.email.includes('@')) {
      throw new Error('Invalid email format')
    }
    if (this.name.length < 2) {
      throw new Error('Name must be at least 2 characters')
    }
  }

  updateProfile(name: string, email: string): void {
    this.name = name
    this.email = email
    this.validate()
  }

  getPublicProfile(): Omit<UserProfile, 'email'> {
    return {
      id: this.id,
      name: this.name,
      createdAt: this.createdAt
    }
  }
}

export class UserNotFound extends Error {
  constructor(userId: string) {
    super(`User ${userId} not found`)
    this.name = 'UserNotFound'
  }
}

Ports: Interfaces Defining Contracts

Ports are interfaces your domain depends on. They're defined in the domain layer but implemented in the outer layer.

// src/domain/ports/user-repository.ts
// This port defines what persistence looks like to the domain

import type { User } from '../entities/user'

export interface UserRepository {
  save(user: User): Promise<void>
  findById(id: string): Promise<User | null>
  findByEmail(email: string): Promise<User | null>
  delete(id: string): Promise<void>
  list(limit: number, offset: number): Promise<User[]>
}

// src/domain/ports/email-sender.ts
// This port defines what sending email looks like to the domain

export interface EmailSender {
  send(to: string, subject: string, body: string): Promise<void>
}

export interface PasswordResetSender extends EmailSender {
  sendPasswordReset(email: string, resetToken: string): Promise<void>
}

// src/domain/ports/token-issuer.ts
export interface TokenIssuer {
  issue(payload: { userId: string; email: string }): Promise<string>
  verify(token: string): Promise<{ userId: string; email: string } | null>
}

Use Cases: Application Layer

Use cases orchestrate domain objects and call ports. They're still framework-agnostic:

// src/application/use-cases/register-user.ts
import type { UserRepository } from '../../domain/ports/user-repository'
import type { EmailSender } from '../../domain/ports/email-sender'
import type { TokenIssuer } from '../../domain/ports/token-issuer'
import { User, UserNotFound } from '../../domain/entities/user'

export interface RegisterUserRequest {
  email: string
  name: string
  password: string
}

export class RegisterUserUseCase {
  constructor(
    private userRepository: UserRepository,
    private emailSender: EmailSender,
    private tokenIssuer: TokenIssuer
  ) {}

  async execute(request: RegisterUserRequest): Promise<string> {
    // Check if user already exists
    const existing = await this.userRepository.findByEmail(request.email)
    if (existing) {
      throw new Error('Email already registered')
    }

    // Create new user
    const user = new User(crypto.randomUUID(), request.email, request.name)
    await this.userRepository.save(user)

    // Send welcome email
    await this.emailSender.send(
      user.email,
      'Welcome!',
      `Hello ${user.name}, welcome to our service`
    )

    // Issue token
    const token = await this.tokenIssuer.issue({
      userId: user.id,
      email: user.email
    })

    return token
  }
}

// src/application/use-cases/update-user-profile.ts
export interface UpdateProfileRequest {
  userId: string
  name: string
  email: string
}

export class UpdateUserProfileUseCase {
  constructor(
    private userRepository: UserRepository,
    private emailSender: EmailSender
  ) {}

  async execute(request: UpdateProfileRequest): Promise<User> {
    const user = await this.userRepository.findById(request.userId)
    if (!user) throw new UserNotFound(request.userId)

    const oldEmail = user.email
    user.updateProfile(request.name, request.email)
    await this.userRepository.save(user)

    if (oldEmail !== request.email) {
      await this.emailSender.send(
        user.email,
        'Email Updated',
        `Your email has been changed to ${user.email}`
      )
    }

    return user
  }
}

Adapters: Framework-Specific Implementations

Adapters live in the outer layer and implement ports. Express routes are adapters. Database drivers are adapters.

// src/adapters/persistence/prisma-user-repository.ts
import type { UserRepository } from '../../domain/ports/user-repository'
import { User } from '../../domain/entities/user'
import { PrismaClient } from '@prisma/client'

export class PrismaUserRepository implements UserRepository {
  constructor(private prisma: PrismaClient) {}

  async save(user: User): Promise<void> {
    await this.prisma.user.upsert({
      where: { id: user.id },
      update: {
        email: user.email,
        name: user.name
      },
      create: {
        id: user.id,
        email: user.email,
        name: user.name,
        createdAt: user.createdAt
      }
    })
  }

  async findById(id: string): Promise<User | null> {
    const record = await this.prisma.user.findUnique({
      where: { id }
    })
    if (!record) return null
    return new User(record.id, record.email, record.name, record.createdAt)
  }

  async findByEmail(email: string): Promise<User | null> {
    const record = await this.prisma.user.findUnique({
      where: { email }
    })
    if (!record) return null
    return new User(record.id, record.email, record.name, record.createdAt)
  }

  async delete(id: string): Promise<void> {
    await this.prisma.user.delete({ where: { id } })
  }

  async list(limit: number, offset: number): Promise<User[]> {
    const records = await this.prisma.user.findMany({
      take: limit,
      skip: offset
    })
    return records.map(r => new User(r.id, r.email, r.name, r.createdAt))
  }
}

// src/adapters/email/sendgrid-email-sender.ts
import type { EmailSender } from '../../domain/ports/email-sender'
import sgMail from '@sendgrid/mail'

export class SendGridEmailSender implements EmailSender {
  constructor(private apiKey: string) {
    sgMail.setApiKey(apiKey)
  }

  async send(to: string, subject: string, body: string): Promise<void> {
    await sgMail.send({
      to,
      from: 'noreply@myapp.com',
      subject,
      html: body
    })
  }
}

// src/adapters/auth/jwt-token-issuer.ts
import type { TokenIssuer } from '../../domain/ports/token-issuer'
import jwt from 'jsonwebtoken'

export class JwtTokenIssuer implements TokenIssuer {
  constructor(private secret: string) {}

  async issue(payload: { userId: string; email: string }): Promise<string> {
    return jwt.sign(payload, this.secret, { expiresIn: '24h' })
  }

  async verify(token: string): Promise<{ userId: string; email: string } | null> {
    try {
      return jwt.verify(token, this.secret) as { userId: string; email: string }
    } catch {
      return null
    }
  }
}

// src/adapters/http/express-user-routes.ts
import type { Express, Request, Response } from 'express'
import type { UserRepository } from '../../domain/ports/user-repository'
import { RegisterUserUseCase } from '../../application/use-cases/register-user'
import { UpdateUserProfileUseCase } from '../../application/use-cases/update-user-profile'

export function setupUserRoutes(
  app: Express,
  userRepository: UserRepository,
  useCase: RegisterUserUseCase,
  updateUseCase: UpdateUserProfileUseCase
) {
  app.post('/users', async (req: Request, res: Response) => {
    try {
      const token = await useCase.execute(req.body)
      res.json({ token })
    } catch (error) {
      res.status(400).json({ error: (error as Error).message })
    }
  })

  app.get('/users/:id', async (req: Request, res: Response) => {
    try {
      const user = await userRepository.findById(req.params.id)
      if (!user) return res.status(404).json({ error: 'Not found' })
      res.json(user.getPublicProfile())
    } catch (error) {
      res.status(500).json({ error: 'Internal error' })
    }
  })

  app.put('/users/:id', async (req: Request, res: Response) => {
    try {
      const user = await updateUseCase.execute({
        userId: req.params.id,
        name: req.body.name,
        email: req.body.email
      })
      res.json(user.getPublicProfile())
    } catch (error) {
      res.status(400).json({ error: (error as Error).message })
    }
  })
}

Dependency Injection Without a Framework

Wire everything in a composition root, no DI container needed:

// src/composition-root.ts
import { PrismaClient } from '@prisma/client'
import { RegisterUserUseCase } from './application/use-cases/register-user'
import { UpdateUserProfileUseCase } from './application/use-cases/update-user-profile'
import { PrismaUserRepository } from './adapters/persistence/prisma-user-repository'
import { SendGridEmailSender } from './adapters/email/sendgrid-email-sender'
import { JwtTokenIssuer } from './adapters/auth/jwt-token-issuer'

export function createApplicationContext() {
  const prisma = new PrismaClient()
  const userRepository = new PrismaUserRepository(prisma)
  const emailSender = new SendGridEmailSender(process.env.SENDGRID_API_KEY!)
  const tokenIssuer = new JwtTokenIssuer(process.env.JWT_SECRET!)

  const registerUserUseCase = new RegisterUserUseCase(
    userRepository,
    emailSender,
    tokenIssuer
  )

  const updateUserProfileUseCase = new UpdateUserProfileUseCase(
    userRepository,
    emailSender
  )

  return {
    userRepository,
    registerUserUseCase,
    updateUserProfileUseCase,
    prisma
  }
}

// src/main.ts
import express from 'express'
import { setupUserRoutes } from './adapters/http/express-user-routes'
import { createApplicationContext } from './composition-root'

const app = express()
app.use(express.json())

const context = createApplicationContext()
setupUserRoutes(app, context.userRepository, context.registerUserUseCase, context.updateUserProfileUseCase)

app.listen(3000, () => console.log('Server running on port 3000'))

Testing Domain Logic With In-Memory Adapters

The magic: swap real adapters for test doubles. Domain logic never changes.

// src/application/use-cases/__tests__/register-user.test.ts

class InMemoryUserRepository implements UserRepository {
  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> {
    return Array.from(this.users.values()).find(u => u.email === email) || null
  }

  async delete(id: string): Promise<void> {
    this.users.delete(id)
  }

  async list(limit: number, offset: number): Promise<User[]> {
    return Array.from(this.users.values()).slice(offset, offset + limit)
  }
}

class SpyEmailSender implements EmailSender {
  calls: Array<{ to: string; subject: string; body: string }> = []

  async send(to: string, subject: string, body: string): Promise<void> {
    this.calls.push({ to, subject, body })
  }
}

class TestTokenIssuer implements TokenIssuer {
  async issue(payload: { userId: string; email: string }): Promise<string> {
    return `token_${payload.userId}`
  }

  async verify(token: string): Promise<{ userId: string; email: string } | null> {
    const match = token.match(/^token_(.+)$/)
    return match ? { userId: match[1], email: 'test@example.com' } : null
  }
}

describe('RegisterUserUseCase', () => {
  let useCase: RegisterUserUseCase
  let userRepository: InMemoryUserRepository
  let emailSender: SpyEmailSender
  let tokenIssuer: TestTokenIssuer

  beforeEach(() => {
    userRepository = new InMemoryUserRepository()
    emailSender = new SpyEmailSender()
    tokenIssuer = new TestTokenIssuer()
    useCase = new RegisterUserUseCase(userRepository, emailSender, tokenIssuer)
  })

  it('should register a new user and send welcome email', async () => {
    const token = await useCase.execute({
      email: 'alice@example.com',
      name: 'Alice',
      password: 'secret'
    })

    expect(token).toBeDefined()
    expect(emailSender.calls).toHaveLength(1)
    expect(emailSender.calls[0].to).toBe('alice@example.com')
    expect(emailSender.calls[0].subject).toBe('Welcome!')

    const saved = await userRepository.findByEmail('alice@example.com')
    expect(saved).not.toBeNull()
    expect(saved!.name).toBe('Alice')
  })

  it('should reject duplicate email', async () => {
    await useCase.execute({
      email: 'alice@example.com',
      name: 'Alice',
      password: 'secret'
    })

    expect(() =>
      useCase.execute({
        email: 'alice@example.com',
        name: 'Alice 2',
        password: 'secret2'
      })
    ).rejects.toThrow('Email already registered')
  })
})

Framework as Outer Layer

Express, Fastify, or any HTTP framework is just a thin adapter. Replace it without touching domain code.

// If you want to switch from Express to Fastify:
// src/adapters/http/fastify-user-routes.ts
import type { FastifyInstance } from 'fastify'
import type { UserRepository } from '../../domain/ports/user-repository'
import { RegisterUserUseCase } from '../../application/use-cases/register-user'

export async function setupUserRoutes(
  app: FastifyInstance,
  userRepository: UserRepository,
  useCase: RegisterUserUseCase
) {
  app.post('/users', async (req, res) => {
    try {
      const token = await useCase.execute(req.body)
      return { token }
    } catch (error) {
      throw error
    }
  })

  app.get('/users/:id', async (req: any) => {
    const user = await userRepository.findById(req.params.id)
    if (!user) throw new Error('Not found')
    return user.getPublicProfile()
  })
}

// Your use cases, domain entities, ports—unchanged
// Only the adapter layer changes

File Structure Walkthrough

src/
├── domain/                    # Core, no framework imports
│   ├── entities/
│   │   ├── user.ts
│   │   └── order.ts
│   ├── ports/                # Interfaces defining contracts
│   │   ├── user-repository.ts
│   │   ├── email-sender.ts
│   │   └── token-issuer.ts
│   └── errors/
│       └── domain-errors.ts
├── application/               # Use cases, orchestration
│   └── use-cases/
│       ├── register-user.ts
│       └── update-user-profile.ts
├── adapters/                  # Framework-specific implementations
│   ├── persistence/
│   │   ├── prisma-user-repository.ts
│   │   └── in-memory-user-repository.ts
│   ├── http/
│   │   ├── express-user-routes.ts
│   │   └── fastify-user-routes.ts
│   ├── email/
│   │   ├── sendgrid-email-sender.ts
│   │   └── mock-email-sender.ts
│   └── auth/
│       └── jwt-token-issuer.ts
├── composition-root.ts        # DI wiring
└── main.ts                    # Entry point

Checklist

  • No framework imports in domain/ or application/ layers
  • All external dependencies abstracted as ports (interfaces)
  • Adapters implement ports, not the reverse
  • Composition root wires all dependencies
  • Each test file has in-memory port implementations
  • Use case tests never touch real database or API
  • Framework can be replaced by changing adapter layer only
  • HTTP routes delegate to use cases, don't contain logic

Conclusion

Hexagonal architecture is about inverting control. Your domain owns nothing from the outside; the outside depends on your domain. This makes testing trivial, refactoring safe, and framework migrations friction-free. You're not testing whether Prisma works—that's their job. You're testing your business logic in complete isolation.