DEV Community

Cover image for Implementing Hexagonal Architecture in React: A Complete Practical Guide
Carlos MartΓ­nez
Carlos MartΓ­nez

Posted on

Implementing Hexagonal Architecture in React: A Complete Practical Guide

🎯 Introduction

In the world of frontend development, especially with React, it's common to see projects that start with a simple structure but, over time, become difficult to maintain, test, and scale. Business logic gets mixed with UI components, services are directly coupled to APIs, and tests become complex to write.

Sound familiar? If you've experienced these challenges, Hexagonal Architecture (also known as Ports and Adapters Architecture) combined with Vertical Slicing might be the solution you're looking for.

In this article, I'll show you how to implement this powerful architectural combination in React with TypeScript, using a real Todo management project as an example. We won't just talk about theory; we'll see real code, design decisions, and practical advantages that you can apply from day one.


πŸ“š What is Hexagonal Architecture?

Hexagonal Architecture was proposed by Alistair Cockburn in 2005. Its main goal is to decouple business logic from technical implementation details.

Fundamental principles:

  1. Domain is the center: Business logic doesn't depend on anything external
  2. Dependency inversion: Outer layers depend on inner ones, never the reverse
  3. Ports and Adapters: Abstract interfaces (ports) and concrete implementations (adapters)
  4. Testable by design: Business logic can be tested without UI, without database, without APIs

Visual representation:

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚            INFRASTRUCTURE LAYER                 β”‚
β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”   β”‚
β”‚  β”‚         APPLICATION LAYER                β”‚   β”‚
β”‚  β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”    β”‚   β”‚
β”‚  β”‚  β”‚        DOMAIN LAYER              β”‚    β”‚   β”‚
β”‚  β”‚  β”‚  β€’ Entities                      β”‚    β”‚   β”‚
β”‚  β”‚  β”‚  β€’ Business Rules                β”‚    β”‚   β”‚
β”‚  β”‚  β”‚  β€’ Domain Exceptions             β”‚    β”‚   β”‚
β”‚  β”‚  β”‚  β€’ Ports (Interfaces)            β”‚    β”‚   β”‚
β”‚  β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜    β”‚   β”‚
β”‚  β”‚  β€’ Use Cases / Services                  β”‚   β”‚
β”‚  β”‚  β€’ Application Logic                     β”‚   β”‚
β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜   β”‚
β”‚  β€’ Adapters (Repositories, API clients)         β”‚
β”‚  β€’ UI Components (React)                        β”‚
β”‚  β€’ State Management (Zustand)                   β”‚
β”‚  β€’ Frameworks & Libraries                       β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
Enter fullscreen mode Exit fullscreen mode

πŸ—οΈ Project Structure

Our application follows this folder structure:

src/
└── features/
    └── todos/
        β”œβ”€β”€ domain/                    # πŸ”΅ Domain Layer
        β”‚   β”œβ”€β”€ entities/
        β”‚   β”‚   └── todo.entity.ts
        β”‚   β”œβ”€β”€ enums/
        β”‚   β”‚   └── todo-status.enum.ts
        β”‚   β”œβ”€β”€ exceptions/
        β”‚   β”‚   β”œβ”€β”€ domain.exception.ts
        β”‚   β”‚   β”œβ”€β”€ invalid-todo.exception.ts
        β”‚   β”‚   β”œβ”€β”€ invalid-todo-status-transition.exception.ts
        β”‚   β”‚   └── todo-not-found.exception.ts
        β”‚   └── ports/
        β”‚       └── todo.repository.ts
        β”‚
        β”œβ”€β”€ application/               # 🟒 Application Layer
        β”‚   └── services/
        β”‚       β”œβ”€β”€ todo.service.ts
        β”‚       └── dtos/
        β”‚           └── create-todo.dto.ts
        β”‚
        └── infrastructure/            # 🟑 Infrastructure Layer
            β”œβ”€β”€ adapters/
            β”‚   └── in-memory-todo.repository.ts
            β”œβ”€β”€ factories/
            β”‚   └── todo-service.factory.ts
            β”œβ”€β”€ stores/
            β”‚   └── todo.store.ts
            └── ui/
                β”œβ”€β”€ components/
                β”‚   β”œβ”€β”€ TodoItem.tsx
                β”‚   β”œβ”€β”€ TodosList.tsx
                β”‚   └── TodoStatus.tsx
                └── pages/
                    β”œβ”€β”€ CreateTodoPage.tsx
                    β”œβ”€β”€ TodoDetailPage.tsx
                    └── TodosPage.tsx
Enter fullscreen mode Exit fullscreen mode

This structure clearly separates responsibilities and facilitates long-term maintenance.


🧩 Vertical Slicing: Organization by Features

Besides Hexagonal Architecture, this project implements Vertical Slicing, an organizational pattern that structures code by complete features instead of technical layers.

What is Vertical Slicing?

Instead of organizing code by technical type (all components together, all services together), we organize it by business capability. Each feature contains everything needed to function independently:

❌ Horizontal (Traditional):          βœ… Vertical (By Feature):

src/                                  src/
β”œβ”€β”€ components/                       └── features/
β”‚   β”œβ”€β”€ TodoItem.tsx                      β”œβ”€β”€ todos/
β”‚   β”œβ”€β”€ UserProfile.tsx                   β”‚   β”œβ”€β”€ domain/
β”‚   └── ProductCard.tsx                   β”‚   β”œβ”€β”€ application/
β”œβ”€β”€ services/                             β”‚   └── infrastructure/
β”‚   β”œβ”€β”€ todoService.ts                    β”œβ”€β”€ users/
β”‚   β”œβ”€β”€ userService.ts                    β”‚   β”œβ”€β”€ domain/
β”‚   └── productService.ts                 β”‚   β”œβ”€β”€ application/
└── types/                                β”‚   └── infrastructure/
    β”œβ”€β”€ Todo.ts                           └── products/
    β”œβ”€β”€ User.ts                               β”œβ”€β”€ domain/
    └── Product.ts                            β”œβ”€β”€ application/
                                              └── infrastructure/
Enter fullscreen mode Exit fullscreen mode

Advantages of Vertical Slicing

1. High Cohesion, Low Coupling

Everything related to "Todos" is in one place. You don't have to jump between 10 different folders to understand a functionality.

2. Efficient Parallel Development

Two developers can work on todos and users simultaneously without merge conflicts, since they're in completely separate folders.

3. Easy to Remove or Extract

No longer need the todos feature? Delete the features/todos/ folder and nothing else breaks. Want to turn it into a micro-frontend? Extract it as an independent package.

4. Simplified Onboarding

A new developer can understand the complete feature by navigating a single folder, without needing to know the entire application.

5. Real Scalability

With 50+ features, it's much more manageable to have 50 feature folders than 50 files in each global technical folder.

6. Technical Decisions per Feature

Each feature can have its own implementation decisions. For example:

  • todos uses in-memory repository
  • users could use REST API
  • analytics could use WebSockets

Vertical Slicing + Hexagonal = πŸ’ͺ

The combination of Vertical Slicing (organization by features) with Hexagonal Architecture (organization by layers within each feature) gives you the best of both worlds:

  • Feature isolation (Vertical)
  • Separation of concerns (Hexagonal)
  • Predictable and consistent code

πŸ”΅ Layer 1: Domain (The Heart of the System)

The domain contains pure business logic, without external dependencies. It's the most important code and should be as stable as possible.

1.1 Enums: Todo States

// domain/enums/todo-status.enum.ts
export enum TodoStatus {
  PENDING = "PENDING",
  IN_PROGRESS = "IN_PROGRESS",
  COMPLETED = "COMPLETED",
}
Enter fullscreen mode Exit fullscreen mode

⚠️ Design decision: Using enums instead of magic strings prevents errors and makes code more maintainable.

1.2 Entity: Todo

The Todo entity is a behavior-rich class, not just a data container:

// domain/entities/todo.entity.ts
export interface TodoProps {
  id: number;
  title: string;
  description: string;
  status: TodoStatus;
  createdAt: Date;
  updatedAt: Date;
}

export default class Todo {
  private readonly id: number;
  private title: string;
  private description: string;
  private status: TodoStatus;
  private readonly createdAt: Date;
  private updatedAt: Date;

  constructor(props: TodoProps) {
    this.validateProps(props);
    this.id = props.id;
    this.title = props.title;
    this.description = props.description;
    this.status = props.status;
    this.createdAt = props.createdAt;
    this.updatedAt = props.updatedAt;
  }

  private validateProps(props: TodoProps): void {
    if (!props.title.trim()) {
      throw new InvalidTodoException("title cannot be empty.");
    }
    if (!props.description.trim()) {
      throw new InvalidTodoException("description cannot be empty.");
    }
  }

  // βœ… Getters to maintain immutability
  getId(): number { return this.id; }
  getTitle(): string { return this.title; }
  getStatus(): TodoStatus { return this.status; }

  // βœ… Setters with validation
  setTitle(title: string): void {
    if (!title.trim()) {
      throw new InvalidTodoException("title cannot be empty.");
    }
    this.title = title;
    this.touchUpdatedAt();
  }

  // βœ… Business logic: state transitions
  start(): void {
    if (this.status === TodoStatus.IN_PROGRESS) {
      throw new InvalidTodoStatusTransitionException(
        this.status, 
        TodoStatus.IN_PROGRESS
      );
    }
    if (this.status === TodoStatus.COMPLETED) {
      throw new InvalidTodoStatusTransitionException(
        this.status, 
        TodoStatus.IN_PROGRESS
      );
    }
    this.status = TodoStatus.IN_PROGRESS;
    this.touchUpdatedAt();
  }

  complete(): void {
    if (this.status === TodoStatus.PENDING) {
      throw new InvalidTodoStatusTransitionException(
        this.status, 
        TodoStatus.COMPLETED
      );
    }
    if (this.status === TodoStatus.COMPLETED) {
      throw new InvalidTodoStatusTransitionException(
        this.status, 
        TodoStatus.COMPLETED
      );
    }
    this.status = TodoStatus.COMPLETED;
    this.touchUpdatedAt();
  }

  // βœ… Cloning to avoid mutations
  clone(): Todo {
    return new Todo({
      id: this.id,
      title: this.title,
      description: this.description,
      status: this.status,
      createdAt: this.createdAt,
      updatedAt: this.updatedAt,
    });
  }

  private touchUpdatedAt(): void {
    this.updatedAt = new Date();
  }
}
Enter fullscreen mode Exit fullscreen mode

🎨 Advantages of this implementation:

  1. Centralized validation: Business rules are in one place
  2. Immutability: readonly properties and clone() method
  3. Encapsulation: Private fields, controlled access through getters/setters
  4. Safe state transitions: You can't go from PENDING to COMPLETED directly
  5. Self-documenting: The code clearly explains which operations are valid

1.3 Domain Exceptions

Exceptions are typed and descriptive:

// domain/exceptions/domain.exception.ts
export class DomainException extends Error {
  constructor(message: string) {
    super(message);
    this.name = this.constructor.name;
  }
}

// domain/exceptions/invalid-todo-status-transition.exception.ts
export class InvalidTodoStatusTransitionException extends DomainException {
  constructor(from: string, to: string) {
    super(`Cannot transition from "${from}" to "${to}".`);
  }
}
Enter fullscreen mode Exit fullscreen mode

βœ… Benefit: Errors are clear and can be specifically handled in upper layers.

1.4 Ports: Interfaces

Ports are contracts defined by the domain but implemented by infrastructure:

// domain/ports/todo.repository.ts
import type Todo from "../entities/todo.entity";

export default interface TodoRepository {
  create(todo: Todo): Promise<void>;
  update(todo: Todo): Promise<void>;
  delete(id: number): Promise<void>;
  list(): Promise<Todo[]>;
  findById(id: number): Promise<Todo | null>;
}
Enter fullscreen mode Exit fullscreen mode

πŸ”‘ Key: The domain defines WHAT it needs, not HOW it's implemented. This is dependency inversion in action.


🟒 Layer 2: Application (Use Cases)

The application layer orchestrates domain logic. This is where services that implement use cases live.

2.1 DTOs (Data Transfer Objects)

DTOs define the data structure for communication between layers:

// application/services/dtos/create-todo.dto.ts
export interface CreateTodoDto {
  title: string;
  description: string;
}
Enter fullscreen mode Exit fullscreen mode

2.2 Application Service

// application/services/todo.service.ts
import Todo from "../../domain/entities/todo.entity";
import { TodoStatus } from "../../domain/enums/todo-status.enum";
import { TodoNotFoundException } from "../../domain/exceptions/todo-not-found.exception";
import type TodoRepository from "../../domain/ports/todo.repository";
import type { CreateTodoDto } from "./dtos/create-todo.dto";

export default class TodoService {
  constructor(private readonly todoRepository: TodoRepository) {}

  async list(): Promise<Todo[]> {
    return this.todoRepository.list();
  }

  async findById(id: number): Promise<Todo | null> {
    return this.todoRepository.findById(id);
  }

  async create(dto: CreateTodoDto): Promise<Todo> {
    const now: Date = new Date();
    const todo: Todo = new Todo({
      id: now.getTime(),
      title: dto.title,
      description: dto.description,
      status: TodoStatus.PENDING,
      createdAt: now,
      updatedAt: now,
    });

    await this.todoRepository.create(todo);
    return todo;
  }

  async delete(id: number): Promise<void> {
    const todo: Todo | null = await this.todoRepository.findById(id);
    if (!todo) throw new TodoNotFoundException(id);

    await this.todoRepository.delete(id);
  }

  async start(id: number): Promise<Todo> {
    const todo: Todo | null = await this.todoRepository.findById(id);
    if (!todo) throw new TodoNotFoundException(id);

    const cloned: Todo = todo.clone(); // πŸ”‘ Avoid mutations
    cloned.start();
    await this.todoRepository.update(cloned);
    return cloned;
  }

  async complete(id: number): Promise<Todo> {
    const todo: Todo | null = await this.todoRepository.findById(id);
    if (!todo) throw new TodoNotFoundException(id);

    const cloned: Todo = todo.clone(); // πŸ”‘ Avoid mutations
    cloned.complete();
    await this.todoRepository.update(cloned);
    return cloned;
  }
}
Enter fullscreen mode Exit fullscreen mode

πŸ’‘ Important observations:

  1. Dependency injection: Repository is injected through the constructor
  2. Using clones: We never directly mutate the entity obtained from the repository
  3. Existence validations: We throw exceptions if the todo doesn't exist
  4. Thin layer: Only orchestrates, doesn't contain complex business logic

🟑 Layer 3: Infrastructure (Technical Details)

This layer contains concrete implementations of ports and all integration with the outside world.

3.1 Adapter: In-Memory Repository

// infrastructure/adapters/in-memory-todo.repository.ts
import Todo from "../../domain/entities/todo.entity";
import { TodoStatus } from "../../domain/enums/todo-status.enum";
import type TodoRepository from "../../domain/ports/todo.repository";

export default class InMemoryTodoRepository implements TodoRepository {
  private todos: Todo[] = [
    new Todo({
      id: 1,
      title: "Learn TypeScript",
      description: "Study the basics of TypeScript and its features",
      status: TodoStatus.PENDING,
      createdAt: new Date(),
      updatedAt: new Date(),
    }),
    // ... more example todos
  ];

  async create(todo: Todo): Promise<void> {
    this.todos = [...this.todos, todo];
  }

  async update(updatedTodo: Todo): Promise<void> {
    this.todos = this.todos.map((todo: Todo) =>
      todo.getId() === updatedTodo.getId() ? updatedTodo : todo,
    );
  }

  async delete(id: number): Promise<void> {
    this.todos = this.todos.filter((todo: Todo) => todo.getId() !== id);
  }

  async list(): Promise<Todo[]> {
    return this.todos;
  }

  async findById(id: number): Promise<Todo | null> {
    const todo: Todo | undefined = this.todos.find(
      (todo: Todo) => todo.getId() === id,
    );
    return todo ?? null;
  }
}
Enter fullscreen mode Exit fullscreen mode

πŸ”„ Key advantage: Tomorrow you can create an ApiTodoRepository that makes HTTP calls and only change the factory configuration. Your domain and application won't change a single line.

3.2 Factory Pattern: Dependency Injection

// infrastructure/factories/todo-service.factory.ts
import TodoService from "../../application/services/todo.service";
import type TodoRepository from "../../domain/ports/todo.repository";
import InMemoryTodoRepository from "../adapters/in-memory-todo.repository";

export default class TodoServiceFactory {
  private static instance: TodoService | null = null;

  static getInstance(): TodoService {
    if (!this.instance) {
      const repository: TodoRepository = new InMemoryTodoRepository();
      this.instance = new TodoService(repository);
    }

    return this.instance;
  }
}
Enter fullscreen mode Exit fullscreen mode

πŸ’‘ Pattern applied: Singleton + Factory to manage service creation and configuration.

3.3 State Management with Zustand

// infrastructure/stores/todo.store.ts
import { create } from "zustand";
import type Todo from "../../domain/entities/todo.entity";
import type { CreateTodoDto } from "../../application/services/dtos/create-todo.dto";
import TodoServiceFactory from "../factories/todo-service.factory";

interface TodoStore {
  todos: Todo[];
  loadingTodos: boolean;
  error: string | null;

  listTodos: () => Promise<void>;
  findTodoById: (id: number) => Promise<Todo | null>;
  createTodo: (dto: CreateTodoDto) => Promise<void>;
  deleteTodo: (id: number) => Promise<void>;
  startTodo: (id: number) => Promise<void>;
  completeTodo: (id: number) => Promise<void>;
}

const useTodoStore = create<TodoStore>()((set, get) => ({
  todos: [],
  loadingTodos: false,
  error: null,

  listTodos: async () => {
    set({ loadingTodos: true, error: null });

    try {
      const todos: Todo[] = await TodoServiceFactory.getInstance().list();
      set({ todos });
    } catch (error) {
      set({ error: (error as Error).message });
    } finally {
      set({ loadingTodos: false });
    }
  },

  createTodo: async (dto: CreateTodoDto) => {
    const todo: Todo = await TodoServiceFactory.getInstance().create(dto);
    set({ todos: [...get().todos, todo] });
  },

  startTodo: async (id: number) => {
    const updated: Todo = await TodoServiceFactory.getInstance().start(id);
    set({
      todos: get().todos.map((todo: Todo) =>
        todo.getId() === id ? updated : todo,
      ),
    });
  },

  completeTodo: async (id: number) => {
    const updated: Todo = await TodoServiceFactory.getInstance().complete(id);
    set({
      todos: get().todos.map((todo: Todo) =>
        todo.getId() === id ? updated : todo,
      ),
    });
  },

  deleteTodo: async (id: number) => {
    await TodoServiceFactory.getInstance().delete(id);
    set({ todos: get().todos.filter((todo: Todo) => todo.getId() !== id) });
  },
}));

export default useTodoStore;
Enter fullscreen mode Exit fullscreen mode

βœ… Zustand acts as an adapter between React and our application. Components don't know the internal architecture, only the store.

3.4 React Components

UI components are dumb and reusable:

// infrastructure/ui/components/TodoItem.tsx
export default function TodoItem({ todo }: TodoItemProps): JSX.Element {
  const { startTodo, completeTodo, deleteTodo } = useTodoStore();

  return (
    <div className="group p-5 rounded-xl bg-white border border-slate-200">
      <div className="flex items-start justify-between gap-3">
        <div className="min-w-0 flex-1">
          <Link
            to={`/todos/${todo.getId()}`}
            className="text-lg font-semibold text-slate-800"
          >
            {todo.getTitle()}
          </Link>
          <p className="text-sm text-slate-500 mt-1">
            {todo.getDescription()}
          </p>
        </div>
        <TodoStatus status={todo.getStatus()} />
      </div>

      <div className="flex items-center gap-2 mt-4">
        {todo.getStatus() === TodoStatus.PENDING && (
          <button onClick={() => startTodo(todo.getId())}>
            Start
          </button>
        )}

        {todo.getStatus() === TodoStatus.IN_PROGRESS && (
          <button onClick={() => completeTodo(todo.getId())}>
            Complete
          </button>
        )}

        <button onClick={() => deleteTodo(todo.getId())}>
          Delete
        </button>
      </div>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

🎯 Components as presenters: They only display data and delegate actions to the store.


πŸ§ͺ Testing: The Great Benefit

Hexagonal Architecture shines especially in testing. Each layer can be tested in isolation.

Entity Test (Domain)

// tests/domain/todo.entity.test.ts
describe("Todo Entity", () => {
  it("should transition from PENDING to IN_PROGRESS", () => {
    const todo = new Todo({
      id: 1,
      title: "Test",
      description: "Test description",
      status: TodoStatus.PENDING,
      createdAt: new Date(),
      updatedAt: new Date(),
    });

    todo.start();

    expect(todo.getStatus()).toBe(TodoStatus.IN_PROGRESS);
  });

  it("should throw error when transitioning from PENDING to COMPLETED", () => {
    const todo = createTodo({ status: TodoStatus.PENDING });

    expect(() => todo.complete()).toThrow(
      InvalidTodoStatusTransitionException
    );
  });
});
Enter fullscreen mode Exit fullscreen mode

βœ… No mocks, no complex setup: Pure business logic testing.

Service Test (Application)

// tests/application/todo.service.test.ts
describe("TodoService", () => {
  let repository: TodoRepository;
  let service: TodoService;

  beforeEach(() => {
    repository = new InMemoryTodoRepository();
    service = new TodoService(repository);
  });

  it("should create a new todo with PENDING status", async () => {
    await service.create({ 
      title: "New Todo", 
      description: "New description" 
    });

    const todos: Todo[] = await service.list();

    expect(todos.length).toBe(4);
    expect(todos[3].getStatus()).toBe(TodoStatus.PENDING);
  });

  it("should return a cloned instance when starting a todo", async () => {
    const original: Todo | null = await service.findById(1);
    const updated: Todo = await service.start(1);

    expect(updated).not.toBe(original); // Different instances
    expect(updated.getStatus()).toBe(TodoStatus.IN_PROGRESS);
  });
});
Enter fullscreen mode Exit fullscreen mode

βœ… Lightweight integration test: We use the real (in-memory) repository without needing complex mocks.

UI Test (Infrastructure)

// tests/ui/todo-item.test.tsx
import { render, screen } from "@testing-library/react";
import { BrowserRouter } from "react-router";
import TodoItem from "../../features/todos/infrastructure/ui/components/TodoItem";

describe("TodoItem", () => {
  it("should render todo with title and description", () => {
    const todo = new Todo({
      id: 1,
      title: "Test Todo",
      description: "Test Description",
      status: TodoStatus.PENDING,
      createdAt: new Date(),
      updatedAt: new Date(),
    });

    render(
      <BrowserRouter>
        <TodoItem todo={todo} />
      </BrowserRouter>
    );

    expect(screen.getByText("Test Todo")).toBeInTheDocument();
    expect(screen.getByText("Test Description")).toBeInTheDocument();
  });
});
Enter fullscreen mode Exit fullscreen mode

πŸš€ Advantages of This Architecture

1. Maintainability

  • Each layer has a clear responsibility
  • Code is easy to understand and modify
  • Changes are contained within their layer

2. Superior Testability

  • Business logic without external dependencies
  • Fast and reliable tests
  • You don't need to mount the entire app to test a business rule

3. Implementation Flexibility

  • Change from in-memory to REST API: Only change the adapter
  • Migrate from Zustand to Redux: Only affects infrastructure
  • Replace React with Vue: Domain and application intact

4. Scalability

  • Adding new features follows the same pattern
  • Multiple developers can work in parallel
  • Clear structure for large projects

5. Framework Independence

  • React is just an implementation detail
  • Business logic isn't coupled to any framework
  • You can reuse domain and application in other contexts (mobile, backend, etc.)

6. Improves Collaboration

  • Natural Domain-Driven Design
  • Ubiquitous language between business and development
  • Implicit documentation in the structure

7. Vertical Slicing: Natural Organization

  • Code organized by business capabilities, not by technical type
  • Self-contained features that are easy to navigate
  • Parallel development without conflicts
  • Decoupled features that can be removed or extracted without breaking anything
  • Faster and more effective developer onboarding

πŸ“Š Comparison: Before vs. After

πŸ”΄ Traditional React Architecture

src/
β”œβ”€β”€ components/
β”‚   └── TodoItem.tsx  # 500 lines mixing UI, logic, validations
β”œβ”€β”€ pages/
β”‚   └── TodosPage.tsx # Calls fetch() directly, inline validations
β”œβ”€β”€ utils/
β”‚   └── api.ts        # Loose functions
└── types/
    └── Todo.ts       # Simple interface without behavior
Enter fullscreen mode Exit fullscreen mode

Problems:

  • ❌ Business logic scattered across components
  • ❌ Difficult to test (need to mount React)
  • ❌ Direct coupling to APIs
  • ❌ Duplicated validations
  • ❌ Backend changes require touching many files
  • ❌ Mixed features: to understand "todos" you must review multiple folders
  • ❌ Constant merge conflicts in the same files

🟒 With Hexagonal Architecture

src/features/todos/
β”œβ”€β”€ domain/           # The truth of your business
β”œβ”€β”€ application/      # Use case orchestration
└── infrastructure/   # Replaceable technical details
Enter fullscreen mode Exit fullscreen mode

Benefits:

  • βœ… Centralized and testable logic
  • βœ… Simple and reusable components
  • βœ… Contained technological changes
  • βœ… Consistent validations
  • βœ… Fast and reliable tests
  • βœ… Self-contained features in features/todos/
  • βœ… Parallel development without collisions
  • βœ… Easy to remove or extract complete features

🎯 When to Use Hexagonal Architecture + Vertical Slicing

βœ… Ideal for:

  • Enterprise applications with complex business logic
  • Projects that will scale over time
  • Medium to large teams (Vertical Slicing facilitates parallel work)
  • Applications with multiple integrations (APIs, localStorage, WebSockets)
  • Projects requiring high test coverage
  • When business logic must be shared (web, mobile, desktop)
  • Applications with multiple independent features that can grow over time

⚠️ Consider alternatives if:

  • It's a quick MVP that will likely be discarded
  • Very simple application (landing page, static blog)
  • Very small team with very tight deadlines
  • Initial overhead is greater than expected benefits

πŸ› οΈ Project Technology Stack

  • React 19: UI components
  • TypeScript 5.9: Type safety and better DX
  • Vite 7: Modern and fast build tool
  • Vitest 4: Testing framework compatible with Vite
  • React Router 7: Client-side routing
  • Zustand 5: Minimalist state management
  • TailwindCSS 4: Utility-first CSS (without classes, pure CSS)

πŸ“ Conclusions

The combination of Hexagonal Architecture + Vertical Slicing in React is not just a passing fad; it's an investment in the long-term sustainability of your application. Although it requires more initial structure, the benefits in maintainability, testability, and flexibility are invaluable.

Key Takeaways:

  1. Domain First: Start by modeling your domain, not the UI
  2. Dependency Inversion: Outer layers depend on inner ones
  3. Ports and Adapters: Define interfaces, implement later
  4. Vertical Slicing: Organize by features, not by technical type
  5. Test, Test, Test: The architecture makes testing natural
  6. Pragmatism: Adapt the architecture to your context, not the other way around

Next Steps:

If you want to dive deeper, consider exploring:

  • Value Objects to model more complex domain concepts
  • CQRS (Command Query Responsibility Segregation) to separate reads and writes
  • Event Sourcing if you need complete auditing
  • Domain Events for communication between aggregates
  • Specification Pattern for complex queries

πŸ™ Acknowledgments

This article and the example project are the result of experience applying Clean Architecture and Domain-Driven Design in real React projects.

Special thanks to:

  • Alistair Cockburn for conceiving Hexagonal Architecture
  • Robert C. Martin (Uncle Bob) for popularizing Clean Architecture
  • Eric Evans for Domain-Driven Design, which perfectly complements this architecture
  • The React community for creating an incredible ecosystem that allows implementing these patterns
  • The TypeScript developers for making JavaScript predictable and safe
  • All developers who share knowledge and help raise the level of the industry
  • And most importantly, to my beautiful family who are my driving force. Mazikeen, Brian, and FΓ‘tima, I love you ❀️

About the Author

Carlos Martinez is a Computer Systems Engineer and Full Stack Web Developer with 3 years of experience in the industry. He has worked implementing clean and scalable architectures in production projects, with a special focus on React and TypeScript.

This project was developed as educational material to demonstrate the practical implementation of Hexagonal Architecture in modern React applications, based on real experience applying these patterns in professional environments. The complete code is available on GitHub under MIT license https://github.com/Carlosgmdev/react-hexagonal.

Questions or Feedback?

If you implemented this architecture in your project or have questions, I'd love to hear about your experience. The best way to learn is by sharing knowledge.

Happy coding! πŸš€πŸ‘¨β€πŸ’»


πŸ“š References and Additional Resources


Last updated: February 2026

#React #TypeScript #CleanArchitecture #HexagonalArchitecture #VerticalSlicing #SoftwareEngineering #Frontend #DomainDrivenDesign

Top comments (0)