Skip to main content

Introducing Hexagonal React: Architecture Enforced by ESLint

· 7 min read
Fabio Fognani
random nerd

I'm excited to announce @dxbox/hexagonal-react, a monorepo template that brings hexagonal architecture (also known as ports and adapters pattern) to React applications with a game-changing twist: architectural rules are enforced at compile-time via ESLint.

The core principle is simple yet powerful: "If it compiles, it's architecturally correct."

The Problem: Architectural Decay in Large React Applications​

As React applications grow, maintaining clean architecture becomes increasingly challenging. We've all seen codebases where:

  • Business logic leaks into components - Complex domain rules scattered across hooks and components
  • Framework coupling - Domain logic deeply entangled with React, making it hard to test or migrate
  • Dependency violations - UI components importing database adapters, persistence logic mixed with presentation
  • Code review burden - Architectural violations discovered only during PR reviews, not at compile-time
  • Inconsistent patterns - Each developer interprets "clean architecture" differently

Traditional solutions like MobX, Redux, or custom state management libraries add complexity but don't fundamentally solve the architectural problem. They still allow developers to violate separation of concerns, and violations are only caught through manual code review—if at all.

The Solution: Hexagonal Architecture with ESLint Enforcement​

Hexagonal React takes a different approach: it makes architectural violations impossible to compile. The template enforces hexagonal architecture through five distinct layers, with strict import rules validated by ESLint:

Architecture Layers​

1. Domain (@repo/domain) Business entities, commands, and events with zero external dependencies. This is your pure business logic—no React, no frameworks, no databases. Just plain TypeScript classes representing your domain model.

// packages/domain/src/task-manager/entities/Task.ts
export class Task {
constructor(
public readonly id: string,
public title: string,
public completed: boolean = false
) {}

toggle(): void {
this.completed = !this.completed;
}
}

2. Ports (@repo/ports) Interfaces defining external dependencies using pure TypeScript contracts. Ports depend only on Domain, never on concrete implementations.

// 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[]>;
}

3. Use-cases (@repo/use-cases) Application handlers orchestrating Domain and Ports. Use-cases contain your application logic—the "what" happens when a user performs an action.

// 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;
}
}

4. Adapters (@repo/adapter-*) Concrete implementations of ports—databases, APIs, ViewModels. Adapters can only be imported in the composition root (DI container), never directly in use-cases or UI.

// packages/adapter-in-memory/src/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());
}
}

5. UI (@repo/ui) Pure React components receiving ViewModels via props. Components are dumb and declarative—they render what they receive, nothing more.

// packages/ui/src/TaskList.tsx

export interface TaskListItemProps {
id: string;
completed: boolean;
title: ReactNode;
}

export interface TaskListProps {
tasks: TaskListItemProps[];
onToggle: (id: string) => void;
}

export function TaskList({ tasks, onToggle }: TaskListProps) {
return (
<ul>
{tasks.map(task => (
<li key={task.id}>
<input
type="checkbox"
checked={task.completed}
onChange={() => onToggle(task.id)}
/>
{task.title}
</li>
))}
</ul>
);
}

Dependency Rules Enforced by ESLint​

The magic happens in the config-eslint package, where strict import rules are defined and validated at compile-time:

  • Domain cannot import anything
  • Ports depend only on Domain
  • Use-cases depend on Domain and Ports
  • Adapters cannot cross-import or touch UI
  • Only apps can import adapters (composition roots) and viewmodels (anywhere)

If you try to violate these rules — say, importing a database adapter directly in a use-case — ESLint will fail your build. No manual code review needed; the foundations of architecture are enforced by the compiler.

Of course you can customize these rules, for example providing alternative locations for the DI containers:

// file: app.config.mjs
// DI Container (Composition Root) - can import any adapters
{
// you can change the matched files pattern here
files: ["**/src/di/**/*.{ts,tsx}", "**/di/**/*.{ts,tsx}"],
rules: {
"no-restricted-imports": "off",
},
},

It's pre-configured, so you don't have to do it. But it's all in your hands, so you can change it if you know what you're doing.

The Composition Root: Where Everything Connects​

In hexagonal architecture, the composition root (also called DI container) is where you wire concrete adapters to abstract ports.

// apps/nextjs/src/di/container.ts
import { InMemoryTaskRepository } from '@repo/adapter-in-memory';
import { CreateTaskHandler } from '@repo/use-cases';

// Wire concrete implementations to use-cases
const taskRepository = new InMemoryTaskRepository();
const createTaskHandler = new CreateTaskHandler(taskRepository);

export const commandBus = new HybridCommandBus({});

// register handlers
commandBus.registerLocalHandler(CreateTaskCommand.prototype.type, createTaskHandler);

Multi-Framework Support​

Because business logic is completely decoupled from React, you can mix different frameworks while sharing the same core logic. The template includes a Next.js and a Vite application, both using the same domain, ports, use-cases, and adapters:

pnpm dev
# Next.js runs on http://localhost:3001
# Vite runs on http://localhost:3002

Want to migrate from Next.js to Remix? Just create a new app in apps/, wire the DI container, and you're done. Your business logic doesn't change.

Why This Matters​

Testability​

Test your domain and use-cases without installing React Testing Library or worrying about hooks. Just instantiate classes and test pure logic:

// CreateTaskHandler.test.ts
import { CreateTaskHandler } from '@repo/use-cases';
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);
});

Swappable Implementations​

Need to swap from in-memory storage to Prisma? Just create a new adapter and update the DI container. Zero changes to domain or use-cases.

// New adapter
import { PrismaTaskRepository } from '@repo/adapter-prisma';

// Update DI container
const taskRepository = new PrismaTaskRepository();

Safe Architecture​

Architectural violations are caught by ESLint, not code review. Even if an AI or junior developer tries to import a database adapter in a component, the build will fail.

Reduced Cognitive Load​

Developers always know where to put code:

  • Business rules? → Domain
  • External interfaces? → Ports
  • Application logic? → Use-cases
  • Concrete implementations? → Adapters
  • React components? → UI

No more debates about "where does this go?"

Getting Started​

Installation requires Node.js 18+ and pnpm 8+.

You can start a new project via @dxbox/create-hexagonal-react

npx @dxbox/create-hexagonal-react your-app-name
cd your-app-name
pnpm dev

The template includes a demo Task Manager module to illustrate the pattern. Once you understand the architecture, just remove it:

pnpm remove:demo

Code Generators​

The repository contains code generators for automatically creating domain entities, ports, handlers, repositories, adapters etc.

Just run

pnpm gen

and enjoy automatic code generation, without having to remember what to write and where.

Isn't this good DevX?

The Benefits​

✅ Compile-Time Safety - Architectural violations caught by ESLint, not code review ✅ Framework Independence - Business logic decoupled from React, easy to migrate or mix frameworks ✅ Testability - Test domain and use-cases without React or mocking frameworks ✅ Swappable Implementations - Change databases, APIs, or ViewModels without touching business logic ✅ Clear Boundaries - Developers always know where code belongs ✅ Scalability - Organize by modules and bounded contexts, not file types

Learn More​


With hexagonal-react, architecture is no longer a suggestion enforced through documentation and code review—it's a compile-time guarantee. If it builds, it's architecturally sound.

Happy architecting!