Hexagonal Architecture in Node.js — Ports, Adapters, and a Codebase You Can Actually Test
Advertisement
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
- Ports: Interfaces Defining Contracts
- Use Cases: Application Layer
- Adapters: Framework-Specific Implementations
- Dependency Injection Without a Framework
- Testing Domain Logic With In-Memory Adapters
- Framework as Outer Layer
- File Structure Walkthrough
- Checklist
- Conclusion
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.
Advertisement