So you've been building React apps for a while. You've got your components organized, maybe you're using hooks, maybe you've even dipped your toes into state management. But something feels off. Your components are doing too much. Your business logic is scattered everywhere. You're passing props through seven layers of components just to update a single field.
Sound familiar? That's where Domain-Driven Design comes in.
Now, before you roll your eyes and think "here comes another architecture pattern that'll make my code more complicated," hear me out. DDD isn't about adding layers for the sake of layers. It's about organizing your code so that the important stuff your business rules, your domain logic doesn't get lost in the noise of UI concerns.
What Even Is Domain-Driven Design?
Domain-Driven Design is a way of organizing code around the business domain you're actually building for. The core idea is simple: your business logic shouldn't care about React, or databases, or APIs. It should just care about the problem you're solving.
Think about it like this. If you're building an e-commerce app, your domain is about products, orders, customers, and payments. Those concepts exist whether you're using React, Vue, or building a CLI tool. The domain is the heart of your application, and everything else the UI, the API calls, the database is just infrastructure that supports it.
The traditional DDD approach talks about things like entities, value objects, aggregates, and repositories. But here's the thing: you don't need to implement all of that to get value from DDD. You just need to understand the mindset.
The React Problem
Here's what usually happens in React apps. You start with a component that needs to display some data. So you fetch it, maybe with a useEffect and fetch, or maybe with React Query. Then you need to update that data, so you add a function that calls an API. Then you need to validate something, so you add validation logic. Before you know it, your component is 500 lines long and doing everything.
The problem? Your business logic is now tightly coupled to React. Want to test that logic? Good luck. Want to reuse it somewhere else? Nope. Want to understand what your app actually does? You'll need to dig through a bunch of JSX and hooks.
The DDD Approach
Instead of putting everything in components, DDD suggests you separate your code into layers:
Domain Layer: This is where your business logic lives. Pure functions, no React, no API calls, no side effects. Just the rules of your domain.
Application Layer: This is where you orchestrate things. It might use React hooks, but it's coordinating between the domain and the infrastructure.
Infrastructure Layer: API calls, database access, external services. The stuff that talks to the outside world.
Presentation Layer: Your React components. They're dumb. They display data and call functions. That's it.
A Practical Example
Let's say you're building a todo app. I know, super original, but it's simple enough that we can see the pattern without getting lost.
The Wrong Way
function TodoList() {
const [todos, setTodos] = useState([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
setLoading(true);
fetch('/api/todos')
.then(res => res.json())
.then(data => {
// Business logic mixed with API calls
const sortedTodos = data.sort((a, b) => {
if (a.completed && !b.completed) return 1;
if (!a.completed && b.completed) return -1;
return new Date(b.createdAt) - new Date(a.createdAt);
});
setTodos(sortedTodos);
setLoading(false);
});
}, []);
const handleComplete = async (id) => {
// More business logic in the component
const todo = todos.find(t => t.id === id);
if (todo.completed) {
throw new Error('Todo is already completed');
}
await fetch(`/api/todos/${id}`, {
method: 'PATCH',
body: JSON.stringify({ completed: true })
});
setTodos(todos.map(t => t.id === id ? { ...t, completed: true } : t));
};
// ... more component code
}This works, but it's a mess. The sorting logic, the validation, the API calls it's all mixed together. Good luck testing any of this in isolation.
The DDD Way
First, let's define our domain. What is a todo? What can you do with it?
// domain/todo.ts
export interface Todo {
id: string;
title: string;
completed: boolean;
createdAt: Date;
}
export class TodoEntity {
constructor(private data: Todo) {}
get id() { return this.data.id; }
get title() { return this.data.title; }
get completed() { return this.data.completed; }
get createdAt() { return this.data.createdAt; }
complete(): Todo {
if (this.data.completed) {
throw new Error('Todo is already completed');
}
return { ...this.data, completed: true };
}
isOverdue(): boolean {
// Business rule: a todo is overdue if it's incomplete and older than 7 days
const daysSinceCreation = (Date.now() - this.data.createdAt.getTime()) / (1000 * 60 * 60 * 24);
return !this.data.completed && daysSinceCreation > 7;
}
}
// Domain service - pure business logic
export function sortTodos(todos: Todo[]): Todo[] {
return [...todos].sort((a, b) => {
if (a.completed && !b.completed) return 1;
if (!a.completed && b.completed) return -1;
return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
});
}Now let's create a repository interface. This is the contract for how we'll fetch and save todos, but we don't care about the implementation yet.
// domain/todoRepository.ts
import { Todo } from './todo';
export interface TodoRepository {
findAll(): Promise<Todo[]>;
findById(id: string): Promise<Todo | null>;
save(todo: Todo): Promise<Todo>;
update(id: string, updates: Partial<Todo>): Promise<Todo>;
}The application layer uses this repository and coordinates the domain logic:
// application/todoService.ts
import { TodoEntity, sortTodos, Todo } from '../domain/todo';
import { TodoRepository } from '../domain/todoRepository';
export class TodoService {
constructor(private repository: TodoRepository) {}
async getSortedTodos(): Promise<Todo[]> {
const todos = await this.repository.findAll();
return sortTodos(todos);
}
async completeTodo(id: string): Promise<Todo> {
const todoData = await this.repository.findById(id);
if (!todoData) {
throw new Error('Todo not found');
}
const todo = new TodoEntity(todoData);
const updated = todo.complete();
return this.repository.update(id, updated);
}
}The infrastructure layer implements the repository with actual API calls:
// infrastructure/apiTodoRepository.ts
import { TodoRepository, Todo } from '../domain/todoRepository';
export class ApiTodoRepository implements TodoRepository {
async findAll(): Promise<Todo[]> {
const response = await fetch('/api/todos');
const data = await response.json();
return data.map((todo: any) => ({
...todo,
createdAt: new Date(todo.createdAt)
}));
}
async findById(id: string): Promise<Todo | null> {
const response = await fetch(`/api/todos/${id}`);
if (!response.ok) return null;
const data = await response.json();
return {
...data,
createdAt: new Date(data.createdAt)
};
}
async save(todo: Todo): Promise<Todo> {
const response = await fetch('/api/todos', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(todo)
});
return response.json();
}
async update(id: string, updates: Partial<Todo>): Promise<Todo> {
const response = await fetch(`/api/todos/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updates)
});
return response.json();
}
}Finally, your React component becomes beautifully simple:
// presentation/TodoList.tsx
import { useEffect, useState } from 'react';
import { TodoService } from '../application/todoService';
import { ApiTodoRepository } from '../infrastructure/apiTodoRepository';
import { Todo } from '../domain/todo';
function TodoList() {
const [todos, setTodos] = useState<Todo[]>([]);
const [loading, setLoading] = useState(false);
// Dependency injection - we'll make this better in a sec
const todoService = new TodoService(new ApiTodoRepository());
useEffect(() => {
setLoading(true);
todoService.getSortedTodos()
.then(setTodos)
.finally(() => setLoading(false));
}, []);
const handleComplete = async (id: string) => {
try {
const updated = await todoService.completeTodo(id);
setTodos(todos.map(t => t.id === id ? updated : t));
} catch (error) {
// Handle error
console.error(error);
}
};
if (loading) return <div>Loading...</div>;
return (
<div>
{todos.map(todo => (
<div key={todo.id}>
<input
type="checkbox"
checked={todo.completed}
onChange={() => handleComplete(todo.id)}
/>
<span>{todo.title}</span>
</div>
))}
</div>
);
}Look at that component. It's tiny. It's focused. It doesn't know anything about sorting algorithms or validation rules. It just displays data and calls functions.
But Wait, This Seems Like Overkill
I know what you're thinking. "Dude, I just wanted to build a todo app, not architect the next Facebook." And you're right. For a simple todo app, this is probably overkill.
But here's the thing: most apps aren't simple todo apps. They grow. They get complicated. And when they do, having your business logic separated from your UI makes everything easier.
The key is to start simple and add structure as you need it. You don't need to create entities and repositories for every single feature. But when you find yourself writing the same business logic in multiple places, or when your components are getting too complex, that's when DDD patterns start to make sense.
Making It Work With React
The example above works, but creating services in components isn't ideal. React has its own patterns for this stuff. Let's use hooks and context to make it cleaner.
// application/useTodoService.ts
import { useContext, createContext } from 'react';
import { TodoService } from './todoService';
import { ApiTodoRepository } from '../infrastructure/apiTodoRepository';
const TodoServiceContext = createContext<TodoService | null>(null);
export function TodoServiceProvider({ children }: { children: React.ReactNode }) {
const service = new TodoService(new ApiTodoRepository());
return (
<TodoServiceContext.Provider value={service}>
{children}
</TodoServiceContext.Provider>
);
}
export function useTodoService() {
const service = useContext(TodoServiceContext);
if (!service) {
throw new Error('useTodoService must be used within TodoServiceProvider');
}
return service;
}Now your component is even cleaner:
function TodoList() {
const todoService = useTodoService();
const [todos, setTodos] = useState<Todo[]>([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
setLoading(true);
todoService.getSortedTodos()
.then(setTodos)
.finally(() => setLoading(false));
}, [todoService]);
// ... rest of component
}Or, if you want to go full React Query (which honestly plays really nicely with DDD):
// application/useTodos.ts
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTodoService } from './useTodoService';
export function useTodos() {
const todoService = useTodoService();
return useQuery({
queryKey: ['todos'],
queryFn: () => todoService.getSortedTodos()
});
}
export function useCompleteTodo() {
const todoService = useTodoService();
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => todoService.completeTodo(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['todos'] });
}
});
}Now your component is basically just wiring things together:
function TodoList() {
const { data: todos = [], isLoading } = useTodos();
const completeTodo = useCompleteTodo();
if (isLoading) return <div>Loading...</div>;
return (
<div>
{todos.map(todo => (
<div key={todo.id}>
<input
type="checkbox"
checked={todo.completed}
onChange={() => completeTodo.mutate(todo.id)}
/>
<span>{todo.title}</span>
</div>
))}
</div>
);
}Beautiful, right? Your component is now just presentation. All the business logic is in the domain layer, all the coordination is in the application layer, and all the API stuff is in the infrastructure layer.
The Benefits
So why go through all this trouble? A few reasons:
Testability: Your business logic is pure functions. You can test them without mocking React, without setting up a test environment, without any of that nonsense. Just call the function with some data and check the result.
Reusability: That sortTodos function? You can use it in a React component, a Node.js script, a test, anywhere. It doesn't care.
Clarity: When you look at the domain layer, you immediately understand what your app does. The business rules are right there, not hidden in component logic.
Maintainability: Need to change how todos are sorted? Change it in one place. Need to add a new business rule? Add it to the domain layer. Your components don't need to know or care.
Flexibility: Want to switch from REST to GraphQL? Just swap out the repository implementation. The domain and application layers don't change. Want to add a CLI tool that uses the same business logic? Go for it.
When Not to Use DDD
Look, DDD isn't a silver bullet. If you're building a simple landing page, or a portfolio site, or a basic CRUD app that'll never grow, you probably don't need this. Over-engineering is a real thing, and it can slow you down.
But if you're building something that:
- Has complex business rules
- Needs to be maintained long-term
- Might need to support multiple interfaces (web, mobile, CLI, etc.)
- Has logic that needs to be tested thoroughly
Then DDD patterns can really help. The trick is knowing when to apply them and when to keep things simple.
The Mental Model
The most valuable thing about DDD isn't the specific patterns it's the mental model. It's thinking about your code in terms of layers. It's asking yourself: "Does this belong in the domain, or is it infrastructure? Is this business logic, or is it just presentation?"
Once you start thinking that way, your code naturally gets better organized. You don't need to implement every DDD pattern perfectly. You just need to keep the domain separate from everything else.
Wrapping Up
Domain-Driven Design in React isn't about following a strict set of rules. It's about organizing your code so that the important stuff your business logic doesn't get lost in the noise. It's about making your components simple and your domain logic testable.
Start small. Extract a bit of business logic into a pure function. Create a service to coordinate things. See how it feels. If it makes your code cleaner and easier to work with, keep going. If it feels like unnecessary complexity, maybe it is, and that's okay too.
The goal isn't perfect architecture. The goal is code that's easy to understand, easy to test, and easy to change. DDD is just one way to get there.
