Skip to main content

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:

  1. Domain - Business entities and commands (zero dependencies)
  2. Ports - Interfaces for external dependencies
  3. Use-cases - Application handlers
  4. Adapters - Concrete implementations
  5. 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-react documentation]](/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 using useReactiveInstance

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

  1. 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 } });
}
}
  1. 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:

  1. Define Domain - Business entities with zero dependencies
  2. Define Ports - Interfaces for external interactions
  3. Create Use-cases - Application logic orchestrating domain and ports
  4. Implement Adapters - Concrete implementations (databases, APIs, ViewModels)
  5. Build UI - Pure React components receiving ViewModels
  6. Wire in DI Container - Connect concrete adapters to ports
  7. Use in Components - Consume use-cases via context

Remember: If it compiles, it's architecturally correct. ESLint enforces the rules for you.