Workflow Guide
This guide walks you through the process of building a feature using hexagonal architecture. We'll use a Task Manager as an example, showing how to structure code across the five architecture layers.
The Five Layers
Hexagonal architecture organizes code into five distinct layers, each with a specific responsibility:
- Domain - Business entities and commands (zero dependencies)
- Ports - Interfaces for external dependencies
- Use-cases - Application handlers
- Adapters - Concrete implementations
- UI - Pure React components
Let's see how to build a feature step-by-step.
Step 1: Define Your Domain
The Domain layer contains your business entities—pure TypeScript classes with business logic and no external dependencies.
Example: A Task entity
// packages/domain/src/task-manager/entities/Task.ts
export class Task {
constructor(
public readonly id: string,
public title: string,
public completed: boolean = false,
public createdAt: Date = new Date()
) {}
toggle(): void {
this.completed = !this.completed;
}
updateTitle(newTitle: string): void {
if (newTitle.trim().length === 0) {
throw new Error('Task title cannot be empty');
}
this.title = newTitle;
}
}
Key points:
- No imports from external libraries (except TypeScript types)
- Business rules live here (e.g., "title cannot be empty")
- Pure methods that can be tested without any framework
Step 2: Define Ports (Interfaces)
Ports define interfaces for external interactions. They depend only on Domain, never on concrete implementations.
Example: A repository port
// packages/ports/src/task-manager/TaskRepository.ts
import type { Task } from '@repo/domain';
export interface TaskRepository {
save(task: Task): Promise<void>;
findById(id: string): Promise<Task | null>;
findAll(): Promise<Task[]>;
delete(id: string): Promise<void>;
}
Key points:
- Pure TypeScript interfaces
- Depend only on Domain entities
- No implementation details
Step 3: Create Use-Cases
Use-cases contain application logic—the "what happens" when a user performs an action. They orchestrate Domain and Ports.
Example: Create a task
// packages/use-cases/src/task-manager/CreateTaskHandler.ts
import type { TaskRepository } from '@repo/ports';
import { Task } from '@repo/domain';
export class CreateTaskHandler {
constructor(private repository: TaskRepository) {}
async execute(title: string): Promise<Task> {
const task = new Task(crypto.randomUUID(), title);
await this.repository.save(task);
return task;
}
}
Example: Toggle a task
// packages/use-cases/src/task-manager/ToggleTaskHandler.ts
import type { TaskRepository } from '@repo/ports';
export class ToggleTaskHandler {
constructor(private repository: TaskRepository) {}
async execute(taskId: string): Promise<void> {
const task = await this.repository.findById(taskId);
if (!task) {
throw new Error(`Task with id ${taskId} not found`);
}
task.toggle();
await this.repository.save(task);
}
}
Key points:
- Depend on Ports (interfaces), not concrete implementations
- Orchestrate domain logic
- Can be tested by mocking the port
Step 4: Implement Adapters
Adapters provide concrete implementations of ports. They can be databases, APIs, ViewModels, etc.
Example: In-memory repository
// packages/adapter-in-memory/src/task-manager/InMemoryTaskRepository.ts
import type { Task } from '@repo/domain';
import type { TaskRepository } from '@repo/ports';
export class InMemoryTaskRepository implements TaskRepository {
private tasks = new Map<string, Task>();
async save(task: Task): Promise<void> {
this.tasks.set(task.id, task);
}
async findById(id: string): Promise<Task | null> {
return this.tasks.get(id) ?? null;
}
async findAll(): Promise<Task[]> {
return Array.from(this.tasks.values());
}
async delete(id: string): Promise<void> {
this.tasks.delete(id);
}
}
Key points:
- Adapters implement ports
- Can be swapped without changing use-cases
- Cannot be imported directly in use-cases or UI (only in DI container)
- ViewModels are a special case of Adapters, please refer to [
@dxbox/use-less-reactdocumentation]](/docs/use-less-react/intro) to understand how to connect to the Command Bus, Event Bus and QueryBus, and how to connect a View to a ViewModel usinguseReactiveInstance
Step 5: Build UI Components
UI components are pure React components that receive bare props. They don't know about domain logic, use-cases, ViewModels or other adapters.
Example: Task list component
// packages/ui/src/task-manager/TaskList.tsx
export interface TaskItemProps {
id: string;
title: ReactNode;
completed: boolean;
}
export interface TaskListProps {
tasks: TaskItemProps[];
onToggle: (id: string) => void;
onDelete: (id: string) => void;
}
export function TaskList({ tasks, onToggle, onDelete }: Props) {
return (
<ul>
{tasks.map(task => (
<li key={task.id}>
<input
type="checkbox"
checked={task.completed}
onChange={() => onToggle(task.id)}
/>
<span style={{ textDecoration: task.completed ? 'line-through' : 'none' }}>
{task.title}
</span>
<button onClick={() => onDelete(task.id)}>Delete</button>
</li>
))}
</ul>
);
}
Key points:
- Receive bare props
- No business logic — just rendering and connecting callbacks to user events
- Easy to display in Storybook
Step 6: Wire Everything in the DI Container
The composition root (DI container) is where you wire concrete adapters to abstract ports. This happens exclusively in apps/*/src/di/.
Example: DI container
/**
* Composition Root - Dependency Injection Container
*
* ESLint will prevent imports of @repo/adapter-* anywhere else in the app
* except for @repo/adapter-viewmodels
*/
import { HybridCommandBus, QueryBus, HybridEventBus } from "@dxbox/use-less-react/classes";
// Adapters (can ONLY be imported here)
import { InMemoryTaskRepository } from "@repo/adapter-demo";
// Domain (commands, queries, event payloads)
import { CreateTaskCommand, GetTaskQuery } from "@repo/domain";
// Use Cases (handlers)
import { CreateTaskHandler, GetTaskHandler } from "@repo/use-cases";
/**
* Buses (CQRS + Event Sourcing)
*/
export const eventBus = new HybridEventBus({
remotePublisher: {
sendRemote: async () => void 0,
},
});
export const commandBus = new HybridCommandBus({});
export const queryBus = new QueryBus({});
/**
* Repositories
*/
const taskRepository = new InMemoryTaskRepository();
/**
* Register Command Handlers
*/
commandBus.registerLocalHandler(CreateTaskCommand.prototype.type,
new CreateTaskHandler(taskRepository, eventBus)
);
/**
* Register Query Handlers
*/
queryBus.registerHandler(GetTaskQuery.prototype.type,
new GetTaskHandler(taskRepository)
);
Step 7: Use in React Components
Consume use-cases from the DI container in your React components:
// apps/app-next/src/components/tasks-list-container.tsx
export function TasksListContainer() {
// Subscribe to ViewModel changes and extract state
const {
state: { error, isLoading, tasks },
instance: taskListViewModel,
} = useReactiveInstance(
() => new TaskListViewModel(commandBus, queryBus),
(vm) => ({
tasks: vm.tasks.map((t) => ({
id: t.id,
completed: t.completed,
title: t.title,
})),
isLoading: vm.isLoading,
error: vm.error,
}),
["tasks", "isLoading", "error"]
);
// Load tasks when component mounts
useEffect(() => {
taskListViewModel.loadTasks();
}, [taskListViewModel]);
return (
<div>
<h1>Tasks</h1>
<TaskList
tasks={tasks}
onToggle={taskListViewModel.onToggle}
onDelete={taskListViewModel.onDelete}
/>
</div>
);
}
Swapping Implementations
One of the biggest benefits of hexagonal architecture is the ability to swap implementations without changing business logic.
Example: Swap in-memory storage for Prisma
- Create a new adapter:
// packages/adapter-prisma/src/task-manager/PrismaTaskRepository.ts
import type { Task } from '@repo/domain';
import type { TaskRepository } from '@repo/ports';
import { PrismaClient } from '@prisma/client';
export class PrismaTaskRepository implements TaskRepository {
constructor(private prisma: PrismaClient) {}
async save(task: Task): Promise<void> {
await this.prisma.task.upsert({
where: { id: task.id },
create: { id: task.id, title: task.title, completed: task.completed },
update: { title: task.title, completed: task.completed },
});
}
async findById(id: string): Promise<Task | null> {
const record = await this.prisma.task.findUnique({ where: { id } });
if (!record) return null;
return new Task(record.id, record.title, record.completed);
}
async findAll(): Promise<Task[]> {
const records = await this.prisma.task.findMany();
return records.map(r => new Task(r.id, r.title, r.completed));
}
async delete(id: string): Promise<void> {
await this.prisma.task.delete({ where: { id } });
}
}
- Update the DI container:
// apps/app-next/src/di/container.ts
import { PrismaTaskRepository } from '@repo/adapter-prisma'; // ✅ Changed
import { CreateTaskHandler } from '@repo/use-cases';
const prisma = new PrismaClient();
const taskRepository = new PrismaTaskRepository(prisma); // ✅ Changed
That's it! Your use-cases, domain, and UI remain completely unchanged. You swapped the database without touching business logic.
Testing
Testing is straightforward because domain and use-cases are pure TypeScript classes:
Example: Test a use-case
// packages/use-cases/src/task-manager/CreateTaskHandler.test.ts
import { CreateTaskHandler } from './CreateTaskHandler';
import { InMemoryTaskRepository } from '@repo/adapter-in-memory';
test('creates a task', async () => {
const repository = new InMemoryTaskRepository();
const handler = new CreateTaskHandler(repository);
const task = await handler.execute('Buy milk');
expect(task.title).toBe('Buy milk');
expect(task.completed).toBe(false);
const saved = await repository.findById(task.id);
expect(saved).toEqual(task);
});
No React Testing Library, no mocking frameworks — just plain Jest/Vitest.
Summary
The workflow for building features with hexagonal architecture:
- Define Domain - Business entities with zero dependencies
- Define Ports - Interfaces for external interactions
- Create Use-cases - Application logic orchestrating domain and ports
- Implement Adapters - Concrete implementations (databases, APIs, ViewModels)
- Build UI - Pure React components receiving ViewModels
- Wire in DI Container - Connect concrete adapters to ports
- Use in Components - Consume use-cases via context
Remember: If it compiles, it's architecturally correct. ESLint enforces the rules for you.