After years of building large-scale React applications, I’ve seen the same pattern repeat itself: a project starts clean, then features accumulate, and before long the codebase is a tangle of API calls inside components, business logic scattered across hooks, and UI concerns bleeding into data-fetching code. The application works, but it’s fragile — a refactor anywhere risks breaking something everywhere.
Hexagonal Architecture (also called Ports & Adapters) combined with Domain-Driven Design (DDD) is the approach I now reach for when a project needs to survive its own growth. This guide is the practical version — not theory, but actual code you can use today.
What problem are we solving?
Before the solution, the problem. A typical React codebase might look like this:
// UserProfile.tsx — doing too much
function UserProfile({ userId }: { userId: string }) {
const [user, setUser] = useState<User | null>(null);
useEffect(() => {
fetch(`/api/users/${userId}`)
.then(res => res.json())
.then(data => {
// Business logic mixed with UI
if (data.role === 'admin' && data.plan === 'free') {
data.permissions = ['read'];
}
setUser(data);
});
}, [userId]);
return <div>{user?.name}</div>;
}
This component knows about HTTP, JSON parsing, permission rules, and rendering — four distinct concerns in one place. Testing any single concern requires mocking all the others.
The core idea: separate concerns into layers
Hexagonal Architecture defines three rings:
- Domain — pure business logic, no framework dependencies
- Application — use cases that orchestrate the domain
- Infrastructure — the outside world: HTTP, localStorage, APIs, React itself
The rule is simple: dependencies only point inward. Infrastructure depends on Application. Application depends on Domain. Domain depends on nothing.
Setting up the folder structure
src/
├── domain/
│ ├── user/
│ │ ├── User.ts ← entity
│ │ ├── UserRepository.ts ← port (interface)
│ │ └── Permission.ts ← value object
├── application/
│ └── user/
│ └── GetUserProfile.ts ← use case
├── infrastructure/
│ ├── http/
│ │ └── HttpUserRepository.ts ← adapter
│ └── storage/
│ └── LocalStorageUserRepository.ts ← adapter
└── ui/
└── UserProfile.tsx ← React component
Step 1: Define the Domain
The domain contains your business rules and nothing else. No React, no fetch, no axios.
// domain/user/User.ts
export interface User {
id: string;
name: string;
email: string;
role: 'admin' | 'editor' | 'viewer';
plan: 'free' | 'pro' | 'enterprise';
}
// domain/user/Permission.ts
export type Permission = 'read' | 'write' | 'delete' | 'admin';
export function resolvePermissions(user: User): Permission[] {
if (user.role === 'admin' && user.plan === 'enterprise') {
return ['read', 'write', 'delete', 'admin'];
}
if (user.role === 'admin') {
return ['read', 'write', 'delete'];
}
if (user.role === 'editor') {
return ['read', 'write'];
}
return ['read'];
}
This permission logic is now pure TypeScript. You can test it with a simple function call — no mocks needed.
// Permission.test.ts
import { resolvePermissions } from './Permission';
test('free admin gets limited permissions', () => {
const user: User = { id: '1', name: 'Test', email: '', role: 'admin', plan: 'free' };
expect(resolvePermissions(user)).toEqual(['read', 'write', 'delete']);
});
Step 2: Define the Port
A port is an interface that describes what the application needs from the outside world. It lives in the domain or application layer.
// domain/user/UserRepository.ts
import type { User } from './User';
export interface UserRepository {
findById(id: string): Promise<User | null>;
save(user: User): Promise<void>;
}
The application layer doesn’t know whether data comes from a REST API, GraphQL, or a local JSON file. It just calls the interface.
Step 3: Write the Use Case
A use case orchestrates the domain to satisfy one specific need. It receives the repository as a constructor argument (dependency injection).
// application/user/GetUserProfile.ts
import type { UserRepository } from '../../domain/user/UserRepository';
import type { User } from '../../domain/user/User';
import type { Permission } from '../../domain/user/Permission';
import { resolvePermissions } from '../../domain/user/Permission';
export interface UserProfileResult {
user: User;
permissions: Permission[];
}
export class GetUserProfile {
constructor(private readonly userRepository: UserRepository) {}
async execute(userId: string): Promise<UserProfileResult | null> {
const user = await this.userRepository.findById(userId);
if (!user) return null;
const permissions = resolvePermissions(user);
return { user, permissions };
}
}
The use case is also trivially testable. You pass in a fake repository:
// GetUserProfile.test.ts
import { GetUserProfile } from './GetUserProfile';
const fakeRepo = {
findById: async (id: string) => ({
id, name: 'Esteban', email: 'e@test.com', role: 'admin' as const, plan: 'free' as const
}),
save: async () => {},
};
test('returns user with resolved permissions', async () => {
const useCase = new GetUserProfile(fakeRepo);
const result = await useCase.execute('1');
expect(result?.user.name).toBe('Esteban');
expect(result?.permissions).toContain('write');
expect(result?.permissions).not.toContain('admin');
});
Step 4: Implement the Adapter
The adapter is the concrete implementation of a port. This is where fetch, axios, or any external API lives.
// infrastructure/http/HttpUserRepository.ts
import type { UserRepository } from '../../domain/user/UserRepository';
import type { User } from '../../domain/user/User';
export class HttpUserRepository implements UserRepository {
constructor(private readonly baseUrl: string) {}
async findById(id: string): Promise<User | null> {
const response = await fetch(`${this.baseUrl}/users/${id}`);
if (!response.ok) return null;
return response.json() as Promise<User>;
}
async save(user: User): Promise<void> {
await fetch(`${this.baseUrl}/users/${user.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(user),
});
}
}
For tests or demos, you might use a local adapter:
// infrastructure/storage/InMemoryUserRepository.ts
import type { UserRepository } from '../../domain/user/UserRepository';
import type { User } from '../../domain/user/User';
export class InMemoryUserRepository implements UserRepository {
private store = new Map<string, User>();
async findById(id: string): Promise<User | null> {
return this.store.get(id) ?? null;
}
async save(user: User): Promise<void> {
this.store.set(user.id, user);
}
}
Swapping implementations is one line change — the rest of the application never knows.
Step 5: Wire it together with a React hook
The UI layer is an adapter too. A custom hook connects React’s lifecycle to your use case.
// ui/hooks/useUserProfile.ts
import { useState, useEffect } from 'react';
import { GetUserProfile } from '../../application/user/GetUserProfile';
import { HttpUserRepository } from '../../infrastructure/http/HttpUserRepository';
import type { UserProfileResult } from '../../application/user/GetUserProfile';
const userRepository = new HttpUserRepository('https://api.esaraviam.dev');
const getUserProfile = new GetUserProfile(userRepository);
export function useUserProfile(userId: string) {
const [result, setResult] = useState<UserProfileResult | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
setLoading(true);
getUserProfile.execute(userId)
.then(setResult)
.catch(setError)
.finally(() => setLoading(false));
}, [userId]);
return { result, loading, error };
}
The component becomes purely presentational:
// ui/UserProfile.tsx
import { useUserProfile } from './hooks/useUserProfile';
export function UserProfile({ userId }: { userId: string }) {
const { result, loading, error } = useUserProfile(userId);
if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
if (!result) return <div>User not found</div>;
const { user, permissions } = result;
return (
<div>
<h2>{user.name}</h2>
<p>{user.email}</p>
<ul>
{permissions.map(p => <li key={p}>{p}</li>)}
</ul>
</div>
);
}
Composition Root: where everything meets
In a real application, you create your dependencies once — typically at the app root or with a DI container.
// infrastructure/container.ts
import { HttpUserRepository } from './http/HttpUserRepository';
import { GetUserProfile } from '../application/user/GetUserProfile';
const API_BASE = import.meta.env.VITE_API_BASE ?? 'https://api.esaraviam.dev';
export const userRepository = new HttpUserRepository(API_BASE);
export const getUserProfile = new GetUserProfile(userRepository);
For testing, you swap the container:
// infrastructure/container.test.ts
import { InMemoryUserRepository } from './storage/InMemoryUserRepository';
import { GetUserProfile } from '../application/user/GetUserProfile';
export const userRepository = new InMemoryUserRepository();
export const getUserProfile = new GetUserProfile(userRepository);
When to use this pattern
Hexagonal Architecture adds upfront structure. It’s worth it when:
- The project will live longer than 6 months
- Multiple developers will work on it
- You need to swap data sources (e.g., REST today, GraphQL later)
- Business rules are complex enough to deserve isolated testing
- You plan to add mobile or CLI interfaces later
For a landing page or a simple CRUD with two routes, the overhead isn’t justified.
Key takeaways
- Domain = pure TypeScript. No React, no fetch. Just functions and types.
- Ports = interfaces. They describe what you need, not how it’s done.
- Adapters = implementations. HTTP, localStorage, in-memory — they all implement the same port.
- Use cases = orchestration. They coordinate domain logic to solve one business need.
- React = the outermost adapter. Components and hooks consume use cases, never repositories directly.
This structure is the one I describe in my most-read article on dev.to. After years of applying it in production — from banking systems at Scotiabank CCAU to mining projects for BHP — it’s the pattern I trust most when the codebase needs to scale with the team.
The full example is available on GitHub @esaraviam. Questions? Reach out on LinkedIn or DEV Community.