← All writings ES

Hexagonal Architecture in TypeScript + React: A practical guide

How to apply Domain-Driven Design and Hexagonal Architecture in a real React TypeScript project — ports, adapters, use cases and clean boundaries.

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:

  1. Domain — pure business logic, no framework dependencies
  2. Application — use cases that orchestrate the domain
  3. 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.