Hexagonal Architecture for React: Ports and Adapters
Hexagonal architecture, also called ports-and-adapters, places your domain logic at the center and defines explicit interfaces (ports) for all external dependencies. Instead of use cases depending directly on specific HTTP clients or databases, they depend on ports (interfaces). Adapters implement these ports, translating between domain concepts and external systems. This architecture excels in projects with many external systems or frequent changes to how data flows in and out of the app. It is more flexible than layered architecture but requires more upfront design.
The "hexagon" concept is that the domain sits in the center, surrounded by ports. Anything outside the hexagon (React components, HTTP clients, databases) is an adapter. The domain never touches adapters; communication flows through ports. I've used this pattern successfully in applications with complex integrations: multiple APIs, offline support, real-time updates, and analytics—each implemented as a swappable adapter.
Ports and Adapters Visualized
A typical React hexagonal architecture looks like:
┌─────────────────────────────┐
│ React Components (Adapters) │
└──────────────┬────────────────┘
│
┌──────────────PORT──────────────┐
│ UserRepositoryPort │
│ (Interface for user data) │
└──────────────┬──────────────────┘
│
┌──────────────────────DOMAIN─────────────────────┐
│ │
│ RegisterUserUseCase (depends on port, not │
│ specific implementation) │
│ │
└──────────────────────┬─────────────────────────┘
│
┌──────────────PORT──────────────┐
│ AnalyticsPort │
│ (Interface for event tracking) │
└──────────────┬──────────────────┘
│
┌─────────────────────────────┐
│ Analytics Adapters: │
│ - GoogleAnalytics │
│ - Amplitude │
│ - Custom Webhook │
└─────────────────────────────┘
Adapters outside the hexagon implement ports. The domain (inside) knows nothing of their specifics.
The Core Pattern: Ports as Interfaces
A port is a TypeScript interface or JavaScript class that defines a contract:
// PORT: Contract for persisting users
export interface UserRepositoryPort {
save(user: User): Promise<User>;
findById(id: string): Promise<User | null>;
findByEmail(email: string): Promise<User | null>;
}
// PORT: Contract for tracking events
export interface AnalyticsPort {
track(event: string, data: Record<string, unknown>): void;
}
Use cases depend on ports, not concrete implementations:
export class RegisterUserUseCase {
constructor(
userRepository, // implements UserRepositoryPort
analytics // implements AnalyticsPort
) {
this.userRepository = userRepository;
this.analytics = analytics;
}
async execute(email, name) {
const user = new User(null, email, name);
const saved = await this.userRepository.save(user);
this.analytics.track('user_registered', { userId: saved.id });
return saved;
}
}
The use case does not know whether the repository is HTTP, a mock, or local storage. It only knows the port interface.
Concrete Adapters: HTTP, Storage, Mock
Now implement the port in multiple ways:
HTTP Adapter (for production):
export class UserHttpAdapter {
constructor(baseURL = '/api') {
this.baseURL = baseURL;
}
async save(user) {
const response = await fetch(`${this.baseURL}/users`, {
method: 'POST',
body: JSON.stringify(user),
});
if (!response.ok) throw new Error('Failed to save user');
return response.json();
}
async findById(id) {
const response = await fetch(`${this.baseURL}/users/${id}`);
if (response.status === 404) return null;
return response.json();
}
async findByEmail(email) {
const response = await fetch(`${this.baseURL}/users/by-email/${email}`);
if (response.status === 404) return null;
return response.json();
}
}
Mock Adapter (for testing):
export class MockUserAdapter {
constructor() {
this.users = new Map();
}
async save(user) {
const id = String(this.users.size + 1);
const saved = { ...user, id };
this.users.set(id, saved);
return saved;
}
async findById(id) {
return this.users.get(id) || null;
}
async findByEmail(email) {
return Array.from(this.users.values()).find((u) => u.email === email) || null;
}
}
IndexedDB Adapter (for offline support):
export class UserIndexedDBAdapter {
constructor(dbName = 'AppDB') {
this.dbName = dbName;
this.storeName = 'users';
}
async save(user) {
return new Promise((resolve, reject) => {
const request = indexedDB.open(this.dbName, 1);
request.onsuccess = () => {
const db = request.result;
const tx = db.transaction(this.storeName, 'readwrite');
const store = tx.objectStore(this.storeName);
store.put(user);
tx.oncomplete = () => resolve(user);
};
request.onerror = () => reject(request.error);
});
}
async findById(id) {
return new Promise((resolve) => {
const request = indexedDB.open(this.dbName, 1);
request.onsuccess = () => {
const db = request.result;
const tx = db.transaction(this.storeName);
const store = tx.objectStore(this.storeName);
const getRequest = store.get(id);
getRequest.onsuccess = () => resolve(getRequest.result || null);
};
});
}
}
All three implement the same port. The use case works identically with any adapter.
Testing with Hexagonal Architecture
Hexagonal architecture is a testing dream: inject mock adapters, test the use case logic in isolation:
describe('RegisterUserUseCase', () => {
it('saves user and tracks event', async () => {
const mockUserRepo = new MockUserAdapter();
const mockAnalytics = {
track: jest.fn(),
};
const useCase = new RegisterUserUseCase(mockUserRepo, mockAnalytics);
const user = await useCase.execute('[email protected]', 'John');
expect(user.id).toBeDefined();
expect(user.email).toBe('[email protected]');
expect(mockAnalytics.track).toHaveBeenCalledWith('user_registered', {
userId: user.id,
});
});
it('rejects duplicate email', async () => {
const mockUserRepo = new MockUserAdapter();
await mockUserRepo.save(new User(null, '[email protected]', 'John'));
const mockAnalytics = { track: jest.fn() };
const useCase = new RegisterUserUseCase(mockUserRepo, mockAnalytics);
await expect(
useCase.execute('[email protected]', 'Jane')
).rejects.toThrow('Email already registered');
});
});
No mocking framework needed for the HTTP client; the mock adapter is the entire HTTP layer.
Driving Adapter: React Components
React components are adapters too—they adapt user interaction to domain use cases:
import { RegisterUserUseCase } from '../usecases/RegisterUserUseCase';
export function RegisterForm({ userRepository, analytics }) {
const [email, setEmail] = useState('');
const [name, setName] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const handleSubmit = async (e) => {
e.preventDefault();
setLoading(true);
try {
const useCase = new RegisterUserUseCase(userRepository, analytics);
const user = await useCase.execute(email, name);
setEmail('');
setName('');
// Success feedback
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
return (
<form onSubmit={handleSubmit}>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="Email"
/>
<input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Name"
/>
{error && <p style={{color: 'red'}}>{error}</p>}
<button disabled={loading}>{loading ? 'Registering...' : 'Register'}</button>
</form>
);
}
The component receives adapters as props or from Context (injects them). It is a thin wrapper around the use case.
Comparing Layered and Hexagonal
| Aspect | Layered | Hexagonal |
|---|---|---|
| Structure | Horizontal layers (presentation, application, domain, data) | Domain at center, ports surrounding, adapters outside |
| Dependencies | Flow downward (presentation → domain) | Inward to ports, outward from adapters |
| External Changes | Add new adapters in data layer | Implement new port + adapters outside hexagon |
| Complexity | Simpler, easier to understand | More flexible, requires upfront design |
| Best For | Most CRUD apps, teams new to clean architecture | Complex integrations, many external systems |
Key Takeaways
- Ports are interfaces defining contracts; adapters implement them.
- Domain logic depends on ports, not concrete implementations.
- Swap adapters (HTTP, storage, mock) without touching use cases.
- React components are driving adapters; they translate user actions to use cases.
- Testing is straightforward: inject mock adapters, test use cases directly.
Frequently Asked Questions
Should I use hexagonal or layered architecture?
Start with layered if the application is CRUD-heavy and has few external systems. Use hexagonal if you have many external dependencies (multiple APIs, databases, real-time services, offline support) or anticipate swapping implementations.
What if I have a port that only one adapter implements?
That is fine. Ports document contracts and enable testing with mocks. Even a port with one real implementation is valuable because you can test the use case with a mock adapter.
How do I handle adapters with different async models (callbacks, promises, observables)?
Normalize them in the adapter. The port defines the contract (promises in JavaScript); the adapter translates the underlying library to that contract. For example, if you use a callback-based library internally, the adapter wraps it in a promise.
Can I use hexagonal architecture with Redux or Zustand?
Yes. Redux actions and reducers become use cases; selectors become adapters that return domain objects. The principle is the same: domain logic is pure and independent.