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

- Name
- Sanjeev Sharma
- @webcoderspeed1
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.