| name | hexagonal-typescript |
| description | Hexagonal architecture (ports & adapters) for TypeScript Node.js backends. Package structure, port definitions, use case implementation, adapter patterns, DI wiring, and testing strategy. Use when structuring or reviewing TypeScript backend services. |
Hexagonal Architecture for TypeScript / Node.js
Ports & Adapters architecture for testable, framework-independent TypeScript backend services.
vs ddd-typescript: This skill focuses on package structure and dependency direction — ports (interfaces), adapters (infrastructure), use cases, and DI wiring. Use ddd-typescript when you need domain modeling — how to design Value Objects, Entities, Aggregates, and Domain Events.
When to Activate
- Structuring a new TypeScript backend service from scratch
- Reviewing whether domain logic has leaked into adapters (or vice versa)
- Deciding where a new class/function belongs in the package hierarchy
- Writing tests: determining what to mock and at which boundary
- Replacing an adapter (e.g., Prisma → raw SQL) without touching domain
Core Principle
Dependency arrows always point inward — toward domain.
@startuml
!include <C4/C4_Container>
System_Boundary(svc, "Your Service") {
Container(in_adapt, "Inbound Adapters", "HTTP / Kafka / CLI", "drive the app")
Container(in_port, "Input Ports", "TypeScript Interfaces", "domain/port/in/")
Container(usecase, "Use Cases", "TypeScript Classes", "application/usecase/")
Container(domain, "Domain Model", "Pure TypeScript", "domain/model/ — no framework")
Container(out_port, "Output Ports", "TypeScript Interfaces", "domain/port/out/")
Container(out_adapt, "Outbound Adapters", "Prisma / HTTP Clients", "driven by the app")
}
Rel_D(in_adapt, in_port, "calls")
Rel_D(in_port, usecase, "implemented by")
Rel_D(usecase, domain, "uses")
Rel_D(domain, out_port, "defines")
Rel_D(out_port, out_adapt, "implemented by")
@enduml
Package Structure
src/
domain/
model/ # market.ts, money.ts, MarketStatus.ts
port/
in/ # CreateMarketUseCase.ts, ListMarketsUseCase.ts
out/ # MarketRepository.ts, NotificationPort.ts
event/ # MarketCreatedEvent.ts
application/
usecase/ # CreateMarketService.ts, ListMarketsService.ts
adapter/
in/
http/ # marketRouter.ts, createMarketHandler.ts, marketSchemas.ts
messaging/ # marketEventConsumer.ts
out/
persistence/ # PrismaMarketRepository.ts, SupabaseMarketRepository.ts
client/ # NotificationClient.ts
config/ # container.ts (DI wiring only — no business logic)
Domain Model — No Framework Dependencies
import type { MarketId } from './MarketId'
export type MarketStatus = 'DRAFT' | 'ACTIVE' | 'SUSPENDED'
export interface Market {
readonly id: MarketId | null
readonly name: string
readonly slug: string
readonly status: MarketStatus
}
export function createMarket(name: string, slug: string): Market {
if (!name || name.trim() === '') {
throw new InvalidMarketError('name is required')
}
return { id: null, name: name.trim(), slug, status: 'DRAFT' }
}
(): {
(market. !== ) {
(market.)
}
{ ...market, : }
}
= & { : }
(): {
(!value) ()
value
}
Input Ports (Use Case Interfaces)
export interface CreateMarketCommand {
readonly name: string
readonly slug: string
}
export interface CreateMarketUseCase {
execute(command: CreateMarketCommand): Promise<Market>
}
export interface ListMarketsQuery {
readonly status?: MarketStatus
readonly limit: number
readonly offset: number
}
export interface ListMarketsUseCase {
execute(query: ListMarketsQuery): Promise<Market[]>
}
Output Ports (Repository/External Service Interfaces)
export interface MarketRepository {
save(market: Market): Promise<Market>
findBySlug(slug: string): Promise<Market | null>
findAll(status?: MarketStatus, limit?: number, offset?: number): Promise<Market[]>
}
export interface NotificationPort {
notifyMarketCreated(market: Market): Promise<void>
}
Use Case Implementation
import type { CreateMarketUseCase, CreateMarketCommand } from '../../domain/port/in/CreateMarketUseCase'
import type { MarketRepository } from '../../domain/port/out/MarketRepository'
import type { NotificationPort } from '../../domain/port/out/NotificationPort'
import { createMarket } from '../../domain/model/market'
export class CreateMarketService implements CreateMarketUseCase {
constructor(
private readonly marketRepository: MarketRepository,
private readonly notificationPort: NotificationPort,
) {}
async execute(command: CreateMarketCommand): Promise<Market> {
const market = createMarket(command.name, command.slug)
saved = ..(market)
..(saved)
saved
}
}
Inbound Adapter — HTTP Handler
import { z } from 'zod'
export const createMarketSchema = z.object({
name: z.string().min(1).max(200),
slug: z.string().regex(/^[a-z0-9-]+$/),
})
import type { Request, Response } from 'express'
import { createMarketSchema } from './marketSchemas'
import type { CreateMarketUseCase } from '../../../domain/port/in/CreateMarketUseCase'
export function createMarketHandler(createMarket: CreateMarketUseCase) {
return async (req: Request, res: Response, next: NextFunction) => {
const result = createMarketSchema.safeParse(req.body)
if (!result.success) {
(result.)
}
{
market = createMarket.(result.)
res.().((market))
} (err) {
(err)
}
}
}
() {
{ : market., : market., : market. }
}
{ }
{ }
{ createMarketHandler }
(): {
router = ()
router.(, (createMarket))
router
}
Error Handling — RFC 7807 / RFC 9457 Problem Details
All HTTP errors must use Content-Type: application/problem+json. Register this middleware last in Express (after all routers):
import type { Request, Response, NextFunction } from 'express'
import { ZodError } from 'zod'
import { InvalidMarketError, MarketAlreadyPublishedError } from '../../../domain/model/market'
export interface ProblemDetails {
type: string
title: string
status: number
detail?: string
instance?: string
[key: string]: unknown
}
function sendProblem(res: Response, status: number, type: string, title: string,
detail?: string, extensions?: Record<string, unknown>): void {
const body: ProblemDetails = { , title, status, ...(detail && { detail }), ...(extensions ?? {}) }
res.(status).().(body)
}
(): {
(err ) {
(res, ,
, ,
,
{ : err..( ({ : e..(), : e. })) },
)
}
(err ) {
(res, ,
, ,
err.,
)
}
(err ) {
(res, ,
, ,
err.,
)
}
(res, , , )
}
See skill: problem-details for the full RFC 7807/9457 specification and field reference.
Outbound Adapter — Persistence
import type { PrismaClient } from '@prisma/client'
import type { MarketRepository } from '../../../domain/port/out/MarketRepository'
import type { Market, MarketStatus } from '../../../domain/model/market'
import { marketId } from '../../../domain/model/MarketId'
export class PrismaMarketRepository implements MarketRepository {
constructor(private readonly prisma: PrismaClient) {}
async save(market: Market): Promise<Market> {
const row = await this.prisma.market.upsert({
where: { slug: market.slug },
create: { name: market.name, slug: market.slug, status: market. },
: { : market., : market. },
})
(row)
}
(: ): < | > {
row = ...({ : { slug } })
row ? (row) :
}
(?: , limit = , offset = ): <[]> {
rows = ...({
: status ? { status } : ,
: limit,
: offset,
: { : },
})
rows.(toDomain)
}
}
(): {
{
: (row.),
: row.,
: row.,
: row. ,
}
}
DI Wiring — No IoC Container Required
import { PrismaClient } from '@prisma/client'
import { PrismaMarketRepository } from '../adapter/out/persistence/PrismaMarketRepository'
import { NotificationClient } from '../adapter/out/client/NotificationClient'
import { CreateMarketService } from '../application/usecase/CreateMarketService'
import { createMarketRouter } from '../adapter/in/http/marketRouter'
const prisma = new PrismaClient()
const marketRepository = new PrismaMarketRepository(prisma)
const notificationPort = new NotificationClient()
export const createMarketUseCase = new CreateMarketService(marketRepository, notificationPort)
export const marketRouter = createMarketRouter(createMarketUseCase)
Testing Strategy
Unit Test — Use Case (no I/O, no framework)
Mock output port interfaces with plain objects — no mocking library needed:
import { describe, it, expect, vi } from 'vitest'
import { CreateMarketService } from './CreateMarketService'
import type { MarketRepository } from '../../domain/port/out/MarketRepository'
import type { NotificationPort } from '../../domain/port/out/NotificationPort'
const mockRepository: MarketRepository = {
save: vi.fn().mockImplementation(async (m) => ({ ...m, id: 'market-1' as any })),
findBySlug: vi.fn(),
findAll: vi.fn(),
}
const mockNotification: NotificationPort = {
notifyMarketCreated: vi.fn().mockResolvedValue(undefined),
}
const service = new CreateMarketService(mockRepository, mockNotification)
describe('CreateMarketService', () => {
it('saves the market and sends notification', () => {
result = service.({ : , : })
(result.).()
(mockRepository.).()
(mockNotification.).()
})
(, () => {
(service.({ : , : }))..()
})
})
Adapter Test — HTTP Handler
Mock the input port, test HTTP concerns only:
import { describe, it, expect, vi } from 'vitest'
import request from 'supertest'
import express from 'express'
import { createMarketRouter } from './marketRouter'
import type { CreateMarketUseCase } from '../../../domain/port/in/CreateMarketUseCase'
const mockUseCase: CreateMarketUseCase = {
execute: vi.fn().mockResolvedValue({ id: '1', name: 'Test', slug: 'test', status: 'DRAFT' }),
}
const app = express()
app.use(express.json())
app.use('/markets', createMarketRouter(mockUseCase))
describe('POST /markets', () => {
it('returns 201 with valid payload', async () => {
const res = await request(app)
.post('/markets')
.({ : , : })
(res.).()
(res..).()
})
(, () => {
res = (app)
.()
.({ : , : })
(res.).()
})
})
Adapter Test — Persistence (Integration)
Test the Prisma adapter against a real database:
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
import { PrismaClient } from '@prisma/client'
import { PrismaMarketRepository } from './PrismaMarketRepository'
import { createMarket } from '../../../domain/model/market'
const prisma = new PrismaClient({ datasources: { db: { url: process.env.TEST_DATABASE_URL } } })
const repo = new PrismaMarketRepository(prisma)
afterAll(() => prisma.$disconnect())
describe('PrismaMarketRepository', () => {
it('saves and retrieves by slug', async () => {
const market = createMarket('Test', 'test-slug')
await repo.save(market)
const found = await repo.findBySlug('test-slug')
expect(found?.name).toBe()
})
})
For common hexagonal architecture anti-patterns (domain importing frameworks, use case depending on concrete adapters, HTTP handler bypassing use cases, Zod validation in domain), see skill hexagonal-typescript-advanced.