React Clean Architecture: A Domain-Driven Design Approach

I've been working with React, and one thing I've learned is that architecture matters—especially as your app grows. Clean Architecture with Domain-Driven Design (DDD) has been a game-changer for me. It splits your application into distinct layers, each with a clear purpose. The result? Code that's easier to test, maintain, and scale. Here's how I think about the layers: - **View**: What users see and interact with - **Repository**: Where data lives and how we fetch it - **Adapter**: The bridge to external APIs - **Service**: Where business rules and logic live - **UseCase**: The conductor that brings everything together I'll walk through building a Product List feature to show you how these layers work together. We'll use React Query for data management and Axios for HTTP requests—tools I reach for on most projects. ## 📂 Folder Structure Before diving into code, let's talk about organization. I've found this folder structure works really well for keeping things clean: ``` src/ ├── features/ │ └── products/ │ ├── api/ │ │ └── products.api.js // Adapter layer │ ├── components/ │ │ ├── ProductCard.js // View layer │ │ └── ProductList.js // View layer │ ├── hooks/ │ │ ├── useProducts.js // UseCase layer │ │ └── useProductsRepository.js // Repository layer │ ├── services/ │ │ └── products.service.js // Service layer │ └── types/ │ └── product.types.js // Data types/models └── lib/ ├── axios.js // Axios instance └── react-query.js // React Query client ``` ## 💻 Code Example: The Product List Feature ### 1. The Adapter Layer (products.api.js) The Adapter layer is your connection to the outside world. It's just a thin wrapper around Axios—no business logic, no transformations. Just fetch the data and pass it along. ```javascript // src/features/products/api/products.api.js import axiosInstance from '../../../lib/axios'; export const getProducts = async () => { const response = await axiosInstance.get('/products'); return response.data; }; ``` ### 2. The Service Layer (products.service.js) Here's where your business logic lives. In this example, we're just formatting prices and checking stock availability. But in real-world apps, this is where you'd put calculations, validations, and any domain-specific rules. ```javascript // src/features/products/services/products.service.js export const formatProducts = (products) => { // Example of business logic: // Format product prices, check for active status, etc. return products.map(product => ({ ...product, price: `$${product.price.toFixed(2)}`, isAvailable: product.stock > 0 })); }; ``` ### 3. The Repository Layer (useProductsRepository.js) The Repository is where React Query shines. This custom hook handles all the messy details of data fetching—caching, loading states, errors. Your components don't need to worry about any of that. ```javascript // src/features/products/hooks/useProductsRepository.js import { useQuery } from 'react-query'; import { getProducts } from '../api/products.api'; export const useProductsRepository = () => { return useQuery('products', getProducts, { staleTime: 5 * 60 * 1000, // 5 minutes }); }; ``` ### 4. The UseCase Layer (useProducts.js) Think of the UseCase as the conductor of an orchestra. It pulls data from the Repository, runs it through the Service layer for any business logic, and hands it to your components in exactly the format they need. ```javascript // src/features/products/hooks/useProducts.js import { useProductsRepository } from './useProductsRepository'; import { formatProducts } from '../services/products.service'; export const useProducts = () => { const { data, isLoading, error } = useProductsRepository(); // The use case orchestrates the data flow // and applies business logic from the Service layer const products = data ? formatProducts(data) : []; return { products, isLoading, error }; }; ``` ### 5. The View Layer (ProductList.js) Finally, the View. This is pure UI—no business logic, no data fetching. Just grab what you need from the UseCase hook and render it. Simple. ```javascript // src/features/products/components/ProductList.js import React from 'react'; import { useProducts } from '../hooks/useProducts'; import ProductCard from './ProductCard'; const ProductList = () => { const { products, isLoading, error } = useProducts(); if (isLoading) { return
Loading products...
; } if (error) { return
An error occurred: {error.message}
; } return (
{products.map(product => ( ))}
); }; export default ProductList; ``` ## Why This Works The beauty of this architecture is in the separation. Need to switch from Axios to Fetch? Just update the Adapter. Business rules changed? Touch only the Service layer. Want to swap React Query for something else? The Repository is the only place you need to look. Your View components stay blissfully unaware of all these details. They just consume data and render UI. That's the kind of separation that makes refactoring a breeze instead of a nightmare. I've used this pattern on projects big and small, and it's never let me down. Give it a shot on your next feature—you might be surprised how much cleaner your code feels.