👨‍💻 Un Tech Lead fullstack pour accélérer votre projet ?
Je suis dispo pour des missions React / Node / Cloud.

Catégorie : FR

  • Implémentation d’une architecture hexagonale avec React et Redux

    Photo de Jonas Svidras sur Unsplash

    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

    Vous pouvez consulter ma proposition d’archi hexagonal en Golang