Skip to main content

Hexagonal Architecture

Hexagonal architecture (ports and adapters) isolates business logic from external concerns. The domain doesn't know about databases, APIs, or UI frameworks — it only knows about ports (interfaces) that adapters implement.

The Core Idea

        ┌─────────────────────────────────────┐
│ ADAPTERS (outer) │
│ ┌───────────────────────────────┐ │
│ │ PORTS (interfaces) │ │
│ │ ┌─────────────────────────┐ │ │
│ │ │ DOMAIN (core) │ │ │
│ │ │ Business logic only │ │ │
│ │ └─────────────────────────┘ │ │
│ └───────────────────────────────┘ │
└─────────────────────────────────────┘

Dependency rule: Code flows inward. Domain knows nothing about adapters. Adapters depend on ports. Ports define what the domain needs.

Nx Library Structure

Map hexagonal layers to Nx libraries:

libs/
domain/ → Entities, business rules, use cases
entities/ → Core business objects
use-cases/ → Application-specific business rules

ports/ → Interfaces the domain needs
repositories/ → Data access contracts
services/ → External service contracts

adapters/
primary/ → Driving adapters (UI, API routes)
ui/ → React components, pages
api/ → Next.js API routes, server actions
secondary/ → Driven adapters (implementations)
db/ → Drizzle/Prisma repositories
external/ → Third-party API clients

apps/
web/ → Next.js app (thin shell)

Layer Responsibilities

LayerContainsDepends On
DomainEntities, use cases, business rulesNothing external
PortsRepository interfaces, service contractsDomain types only
Primary AdaptersUI components, API routes, controllersPorts + Domain
Secondary AdaptersDB implementations, API clientsPorts + Domain

Next.js Integration

Server Actions as Primary Adapters

// apps/web/app/actions/contact.ts (Primary Adapter)
'use server'
import { createContact } from '@myorg/domain/use-cases'
import { contactRepository } from '@myorg/adapters/db'

export async function createContactAction(formData: FormData) {
const input = parseContactInput(formData)
return createContact(input, { contactRepository })
}

Use Cases in Domain

// libs/domain/use-cases/createContact.ts
import type { ContactRepository } from '@myorg/ports/repositories'
import { Contact } from '@myorg/domain/entities'

export async function createContact(
input: CreateContactInput,
deps: { contactRepository: ContactRepository }
) {
const contact = Contact.create(input)
return deps.contactRepository.save(contact)
}

Ports as Interfaces

// libs/ports/repositories/ContactRepository.ts
import type { Contact } from '@myorg/domain/entities'

export interface ContactRepository {
save(contact: Contact): Promise<Contact>
findById(id: string): Promise<Contact | null>
findAll(): Promise<Contact[]>
}

Secondary Adapters Implement Ports

// libs/adapters/db/DrizzleContactRepository.ts
import type { ContactRepository } from '@myorg/ports/repositories'
import { db, contacts } from './schema'

export const drizzleContactRepository: ContactRepository = {
async save(contact) {
const [result] = await db.insert(contacts).values(contact).returning()
return result
},
// ... other methods
}

Nx Boundary Enforcement

Configure @nx/enforce-module-boundaries in .eslintrc.json:

{
"rules": {
"@nx/enforce-module-boundaries": [
"error",
{
"depConstraints": [
{ "sourceTag": "layer:domain", "onlyDependOnLibsWithTags": [] },
{ "sourceTag": "layer:ports", "onlyDependOnLibsWithTags": ["layer:domain"] },
{ "sourceTag": "layer:adapters", "onlyDependOnLibsWithTags": ["layer:ports", "layer:domain"] },
{ "sourceTag": "layer:app", "onlyDependOnLibsWithTags": ["layer:adapters", "layer:ports", "layer:domain"] }
]
}
]
}
}

Now architecture violations fail the build.

Why This Matters

BenefitHow Hex + Nx Delivers
TestabilityMock ports, test domain in isolation
SwappabilityChange DB or API without touching domain
AI-assisted devClear boundaries help AI understand structure
Team scalingTeams own layers, not features
Build speedNx only rebuilds affected libraries

Context

Resources