
L’architecture hexagonale, également connue sous le nom de « Ports et Adaptateurs », est une approche de conception logicielle qui vise à créer des applications flexibles, maintenables et testables. Elle sépare clairement les préoccupations entre le domaine métier, l’infrastructure et l’interface utilisateur.
Dans ce tutoriel, nous allons explorer comment structurer une application React avec Redux en utilisant une architecture hexagonale. Nous prendrons l’exemple d’une gestion d’utilisateurs pour illustrer les concepts, en suivant la structure de projet détaillée ci-dessous.
Votre stack vous ralentit ?
Je vous propose un audit technique gratuit pour identifier les freins à la performance ou à la maintenabilité.
Structure du projet
Voici la structure du projet que nous allons utiliser :
app/ # Application : spécifique à React et Redux
components/
features/
user/
userSlice.ts
userSelectors.ts
userThunks.ts
userHooks.ts
pages/
UserList.tsx
UserDetail.tsx
UserForm.tsx
layout/
FullPage.tsx
Dashboard.tsx
routes/
userRoutes.ts
store/
index.ts
domain/ # Métier : types et interfaces métiers
entities/
User.ts
ports/
UserRepository.ts
services/
UserService.ts
infrastructure/ # Adapteurs : API, stockage, etc.
api/
userApi.ts
storage/
userStorage.ts
styles/
utils/
Domaine (domain/
)
Le domaine contient la logique métier pure de votre application. Il est indépendant de l’interface utilisateur et de l’infrastructure.
1. Entités (entities/
)
domain/entities/User.ts
export interface User {
id: string;
name: string;
email: string;
role: string;
}
2. Ports (ports/
)
Les ports sont des interfaces qui définissent comment le domaine interagit avec l’extérieur.
domain/ports/UserRepository.ts
import { User } from '../entities/User';
export interface UserRepository {
getUsers(): Promise<User[]>;
getUserById(id: string): Promise<User | null>;
createUser(user: User): Promise<User>;
updateUser(user: User): Promise<User>;
deleteUser(id: string): Promise<void>;
}
3. Services (services/
)
Les services contiennent la logique métier et utilisent les ports pour accéder aux données.
domain/services/UserService.ts
import { User } from '../entities/User';
import { UserRepository } from '../ports/UserRepository';
export class UserService {
constructor(private userRepository: UserRepository) {}
async getAllUsers(): Promise<User[]> {
// Logique métier supplémentaire si nécessaire
return await this.userRepository.getUsers();
}
async getUserById(id: string): Promise<User | null> {
// Logique métier supplémentaire si nécessaire
return await this.userRepository.getUserById(id);
}
async createUser(user: User): Promise<User> {
// Exemple de validation métier
if (!user.email.includes('@')) {
throw new Error('Email invalide');
}
return await this.userRepository.createUser(user);
}
async updateUser(user: User): Promise<User> {
// Logique métier pour la mise à jour
return await this.userRepository.updateUser(user);
}
async deleteUser(id: string): Promise<void> {
// Logique métier pour la suppression
return await this.userRepository.deleteUser(id);
}
}
Infrastructure (infrastructure/
)
L’infrastructure contient les implémentations concrètes des ports définis dans le domaine.
1. API (api/
)
infrastructure/api/userApi.ts
import axios from 'axios';
import { User } from '../../domain/entities/User';
import { UserRepository } from '../../domain/ports/UserRepository';
export class UserApi implements UserRepository {
private apiUrl = '/api/users';
async getUsers(): Promise<User[]> {
const response = await axios.get<User[]>(this.apiUrl);
return response.data;
}
async getUserById(id: string): Promise<User | null> {
const response = await axios.get<User>(`${this.apiUrl}/${id}`);
return response.data;
}
async createUser(user: User): Promise<User> {
const response = await axios.post<User>(this.apiUrl, user);
return response.data;
}
async updateUser(user: User): Promise<User> {
const response = await axios.put<User>(`${this.apiUrl}/${user.id}`, user);
return response.data;
}
async deleteUser(id: string): Promise<void> {
await axios.delete(`${this.apiUrl}/${id}`);
}
}
2. Stockage (storage/
)
Optionnellement, vous pouvez implémenter un stockage local.
infrastructure/storage/userStorage.ts
import { User } from '../../domain/entities/User';
import { UserRepository } from '../../domain/ports/UserRepository';
export class UserStorage implements UserRepository {
private storageKey = 'users';
async getUsers(): Promise<User[]> {
const data = localStorage.getItem(this.storageKey);
return data ? JSON.parse(data) : [];
}
async getUserById(id: string): Promise<User | null> {
const users = await this.getUsers();
return users.find(user => user.id === id) || null;
}
async createUser(user: User): Promise<User> {
const users = await this.getUsers();
users.push(user);
localStorage.setItem(this.storageKey, JSON.stringify(users));
return user;
}
async updateUser(user: User): Promise<User> {
let users = await this.getUsers();
users = users.map(u => (u.id === user.id ? user : u));
localStorage.setItem(this.storageKey, JSON.stringify(users));
return user;
}
async deleteUser(id: string): Promise<void> {
let users = await this.getUsers();
users = users.filter(u => u.id !== id);
localStorage.setItem(this.storageKey, JSON.stringify(users));
}
}
Application (app/
)
L’application gère l’interface utilisateur avec React et la gestion d’état avec Redux.
1. Store Redux (store/
)
app/store/index.ts
import { configureStore } from '@reduxjs/toolkit';
import userReducer from '../features/user/userSlice';
export const store = configureStore({
reducer: {
user: userReducer,
// Autres reducers...
},
});
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
app/store/hooks.ts
import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux';
import type { RootState, AppDispatch } from './index';
// Utilisez ces hooks personnalisés dans votre application
export const useAppDispatch = () => useDispatch<AppDispatch>();
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;
2. Features (features/
)
Nous allons maintenant détailler la partie features/user/
.
a. Slice utilisateur (userSlice.ts
)
app/features/user/userSlice.ts
import { createSlice } from '@reduxjs/toolkit';
import { User } from '../../domain/entities/User';
import { fetchUsers, fetchUserById, createUser, updateUser, deleteUser } from './userThunks';
interface UserState {
users: User[];
selectedUser: User | null;
loading: boolean;
error: string | null;
}
const initialState: UserState = {
users: [],
selectedUser: null,
loading: false,
error: null,
};
const userSlice = createSlice({
name: 'user',
initialState,
reducers: {
clearSelectedUser(state) {
state.selectedUser = null;
},
},
extraReducers: builder => {
builder
// fetchUsers
.addCase(fetchUsers.pending, state => {
state.loading = true;
state.error = null;
})
.addCase(fetchUsers.fulfilled, (state, action) => {
state.loading = false;
state.users = action.payload;
})
.addCase(fetchUsers.rejected, (state, action) => {
state.loading = false;
state.error = action.error.message || 'Erreur lors du chargement des utilisateurs';
})
// fetchUserById
.addCase(fetchUserById.pending, state => {
state.loading = true;
state.error = null;
})
.addCase(fetchUserById.fulfilled, (state, action) => {
state.loading = false;
state.selectedUser = action.payload;
})
.addCase(fetchUserById.rejected, (state, action) => {
state.loading = false;
state.error = action.error.message || "Erreur lors du chargement de l'utilisateur";
})
// createUser
.addCase(createUser.fulfilled, (state, action) => {
state.users.push(action.payload);
})
// updateUser
.addCase(updateUser.fulfilled, (state, action) => {
const index = state.users.findIndex(u => u.id === action.payload.id);
if (index !== -1) {
state.users[index] = action.payload;
}
})
// deleteUser
.addCase(deleteUser.fulfilled, (state, action) => {
state.users = state.users.filter(u => u.id !== action.payload);
});
},
});
export const { clearSelectedUser } = userSlice.actions;
export default userSlice.reducer;
b. Thunks (userThunks.ts
)
Modification importante : Les thunks utilisent maintenant les services du domaine au lieu d’accéder directement à l’API.
app/features/user/userThunks.ts
import { createAsyncThunk } from '@reduxjs/toolkit';
import { User } from '../../domain/entities/User';
import { UserService } from '../../domain/services/UserService';
import { UserApi } from '../../infrastructure/api/userApi';
// Instanciez le service avec l'implémentation du repository
const userRepository = new UserApi();
const userService = new UserService(userRepository);
export const fetchUsers = createAsyncThunk('user/fetchUsers', async () => {
return await userService.getAllUsers();
});
export const fetchUserById = createAsyncThunk('user/fetchUserById', async (id: string) => {
return await userService.getUserById(id);
});
export const createUser = createAsyncThunk('user/createUser', async (user: User) => {
return await userService.createUser(user);
});
export const updateUser = createAsyncThunk('user/updateUser', async (user: User) => {
return await userService.updateUser(user);
});
export const deleteUser = createAsyncThunk('user/deleteUser', async (id: string) => {
await userService.deleteUser(id);
return id;
});
c. Sélecteurs (userSelectors.ts
)
Les sélecteurs permettent d’accéder facilement aux données du store.
app/features/user/userSelectors.ts
import { RootState } from '../../store';
export const selectUsers = (state: RootState) => state.user.users;
export const selectSelectedUser = (state: RootState) => state.user.selectedUser;
export const selectUserLoading = (state: RootState) => state.user.loading;
export const selectUserError = (state: RootState) => state.user.error;
d. Hooks (userHooks.ts
)
Les hooks personnalisés facilitent la réutilisation de la logique dans les composants.
app/features/user/userHooks.ts
import { useAppDispatch, useAppSelector } from '../../store/hooks';
import { useEffect } from 'react';
import {
fetchUsers,
fetchUserById,
createUser,
updateUser,
deleteUser,
} from './userThunks';
import {
selectUsers,
selectSelectedUser,
selectUserLoading,
selectUserError,
} from './userSelectors';
export const useUsers = () => {
const dispatch = useAppDispatch();
const users = useAppSelector(selectUsers);
const loading = useAppSelector(selectUserLoading);
const error = useAppSelector(selectUserError);
useEffect(() => {
dispatch(fetchUsers());
}, [dispatch]);
return { users, loading, error };
};
export const useUser = (id: string) => {
const dispatch = useAppDispatch();
const user = useAppSelector(selectSelectedUser);
const loading = useAppSelector(selectUserLoading);
const error = useAppSelector(selectUserError);
useEffect(() => {
dispatch(fetchUserById(id));
}, [dispatch, id]);
return { user, loading, error };
};
export const useCreateUser = () => {
const dispatch = useAppDispatch();
return (user: User) => dispatch(createUser(user));
};
export const useUpdateUser = () => {
const dispatch = useAppDispatch();
return (user: User) => dispatch(updateUser(user));
};
export const useDeleteUser = () => {
const dispatch = useAppDispatch();
return (id: string) => dispatch(deleteUser(id));
};
e. Pages (pages/
)
UserList.tsx
app/features/user/pages/UserList.tsx
import React from 'react';
import { useUsers, useDeleteUser } from '../userHooks';
import { Link } from 'react-router-dom';
const UserList: React.FC = () => {
const { users, loading, error } = useUsers();
const deleteUser = useDeleteUser();
if (loading) return <p>Chargement...</p>;
if (error) return <p>Erreur : {error}</p>;
return (
<div>
<h1>Liste des utilisateurs</h1>
<Link to="/users/new">Créer un nouvel utilisateur</Link>
<ul>
{users.map(user => (
<li key={user.id}>
<Link to={`/users/${user.id}`}>{user.name}</Link> ({user.email}) - {user.role}
<button onClick={() => deleteUser(user.id)}>Supprimer</button>
</li>
))}
</ul>
</div>
);
};
export default UserList;
UserDetail.tsx
app/features/user/pages/UserDetail.tsx
import React from 'react';
import { useParams, Link } from 'react-router-dom';
import { useUser } from '../userHooks';
const UserDetail: React.FC = () => {
const { id } = useParams<{ id: string }>();
const { user, loading, error } = useUser(id!);
if (loading) return <p>Chargement...</p>;
if (error) return <p>Erreur : {error}</p>;
if (!user) return <p>Utilisateur non trouvé</p>;
return (
<div>
<h1>Détails de l'utilisateur</h1>
<p>Nom : {user.name}</p>
<p>Email : {user.email}</p>
<p>Rôle : {user.role}</p>
<Link to={`/users/${user.id}/edit`}>Modifier</Link>
</div>
);
};
export default UserDetail;
UserForm.tsx
app/features/user/pages/UserForm.tsx
import React, { useEffect } from 'react';
import { useForm } from 'react-hook-form';
import { User } from '../../../domain/entities/User';
import { useCreateUser, useUpdateUser, useUser } from '../userHooks';
import { useNavigate, useParams } from 'react-router-dom';
const UserForm: React.FC = () => {
const { id } = useParams<{ id: string }>();
const isEditMode = Boolean(id);
const { register, handleSubmit, reset } = useForm<User>();
const createUser = useCreateUser();
const updateUser = useUpdateUser();
const navigate = useNavigate();
const { user } = useUser(id!);
useEffect(() => {
if (isEditMode && user) {
reset(user);
}
}, [isEditMode, user, reset]);
const onSubmit = async (data: User) => {
if (isEditMode) {
await updateUser({ ...data, id: id! });
} else {
await createUser(data);
}
navigate('/users');
};
return (
<div>
<h1>{isEditMode ? 'Modifier' : 'Créer'} un utilisateur</h1>
<form onSubmit={handleSubmit(onSubmit)}>
<div>
<label>Nom</label>
<input {...register('name', { required: true })} />
</div>
<div>
<label>Email</label>
<input {...register('email', { required: true })} />
</div>
<div>
<label>Rôle</label>
<input {...register('role', { required: true })} />
</div>
<button type="submit">{isEditMode ? 'Mettre à jour' : 'Créer'}</button>
</form>
</div>
);
};
export default UserForm;
3. Routes (routes/
)
app/routes/userRoutes.ts
import React from 'react';
import { Route, Routes } from 'react-router-dom';
import UserList from '../features/user/pages/UserList';
import UserDetail from '../features/user/pages/UserDetail';
import UserForm from '../features/user/pages/UserForm';
const UserRoutes: React.FC = () => (
<Routes>
<Route path="/users" element={<UserList />} />
<Route path="/users/new" element={<UserForm />} />
<Route path="/users/:id/edit" element={<UserForm />} />
<Route path="/users/:id" element={<UserDetail />} />
</Routes>
);
export default UserRoutes;
4. Layout (layout/
)
app/layout/FullPage.tsx
import React from 'react';
const FullPageLayout: React.FC = ({ children }) => {
return (
<div className="full-page-layout">
{children}
</div>
);
};
export default FullPageLayout;
app/layout/Dashboard.tsx
import React from 'react';
import { Link, Outlet } from 'react-router-dom';
const DashboardLayout: React.FC = () => {
return (
<div className="dashboard-layout">
<nav>
<ul>
<li><Link to="/users">Utilisateurs</Link></li>
{/* Autres liens de navigation */}
</ul>
</nav>
<main>
<Outlet />
</main>
</div>
);
};
export default DashboardLayout;
5. Point d’entrée principal
app/App.tsx
import React from 'react';
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
import DashboardLayout from './layout/Dashboard';
import FullPageLayout from './layout/FullPage';
import UserRoutes from './routes/userRoutes';
const App: React.FC = () => {
return (
<Router>
<Routes>
<Route path="/" element={<DashboardLayout />}>
{/* Routes principales */}
<Route path="users/*" element={<UserRoutes />} />
</Route>
{/* Routes avec un autre layout */}
<Route path="/login" element={<FullPageLayout>{/* Composant de connexion */}</FullPageLayout>} />
</Routes>
</Router>
);
};
export default App;
Styles (styles/
)
Le dossier styles/
contient vos fichiers CSS ou SCSS pour styliser votre application.
Utils (utils/
)
Le dossier utils/
contient des fonctions utilitaires réutilisables dans votre application.
Conclusion
En adoptant une architecture hexagonale dans votre application React avec Redux, vous bénéficiez d’une séparation claire des responsabilités :
- Domaine : Contient la logique métier pure et est indépendant des frameworks et bibliothèques.
- Infrastructure : Gère les détails techniques comme les appels API et le stockage.
- Application : Gère l’interface utilisateur et l’état de l’application.
En modifiant les thunks pour qu’ils utilisent les services du domaine plutôt que d’accéder directement à l’infrastructure, vous respectez pleinement les principes de l’architecture hexagonale. Cela permet de garder votre couche d’application indépendante des détails techniques de l’infrastructure.
Cette architecture facilite la maintenance, les tests et l’évolutivité de votre application.
Ressources supplémentaires
- Documentation officielle de Redux Toolkit
- React Router Documentation
- TypeScript Handbook
- Architecture Hexagonale par Alistair Cockburn
Vous pouvez consulter ma proposition d’archi hexagonal en Golang