UNPKG

ruch

Version:

Revolutionary React TypeScript CLI with hexagonal architecture & AI-powered development assistance. Create maintainable, scalable applications with domain-driven design and integrated AI tooling.

877 lines (705 loc) 25.8 kB
# Architecture Hexagonale Simplifiée pour React RUCH Cette documentation présente une architecture hexagonale simplifiée pour les projets React, organisée de manière intuitive respectant une structure ressemblant a une structure classique en React, en utilisant des contexts ainsi que React Query, avec les tests placés à côté des fichiers qu'ils testent. ## Table des matières 1. [Structure du projet](https://www.notion.so/Architecture-Hexagonale-Simplifi-e-pour-React-RUCH-212254dfc85680cfba7fe19855d40b7e?pvs=21) 2. [Organisation par domaines](https://www.notion.so/Architecture-Hexagonale-Simplifi-e-pour-React-RUCH-212254dfc85680cfba7fe19855d40b7e?pvs=21) 3. [Exemples d'implémentation](https://www.notion.so/Architecture-Hexagonale-Simplifi-e-pour-React-RUCH-212254dfc85680cfba7fe19855d40b7e?pvs=21) 4. [Tests colocalisés](https://www.notion.so/Architecture-Hexagonale-Simplifi-e-pour-React-RUCH-212254dfc85680cfba7fe19855d40b7e?pvs=21) 5. [Intégration avec React Query](https://www.notion.so/Architecture-Hexagonale-Simplifi-e-pour-React-RUCH-212254dfc85680cfba7fe19855d40b7e?pvs=21) 6. [Configuration MSW](https://www.notion.so/Architecture-Hexagonale-Simplifi-e-pour-React-RUCH-212254dfc85680cfba7fe19855d40b7e?pvs=21) 7. [Conclusion](https://www.notion.so/Architecture-Hexagonale-Simplifi-e-pour-React-RUCH-212254dfc85680cfba7fe19855d40b7e?pvs=21) ## Structure du projet La structure proposée conserve l'organisation classique d'un projet React tout en intégrant les principes de l'architecture hexagonale par domaines métier: ``` src/ ├── components/ # Composants React partagés │ ├── ui/ # Composants d'UI réutilisables │ │ ├── Button.tsx │ │ ├── Button.test.tsx # Test colocalisé avec son composant │ │ └── ... │ └── layouts/ # Layouts partagés │ ├── pages/ # Pages React (composants de routes) │ ├── Home.tsx │ ├── Home.test.tsx # Test colocalisé avec sa page │ └── ... │ ├── hooks/ # Hooks React partagés │ ├── useForm.ts │ ├── useForm.test.ts # Test colocalisé avec son hook │ └── ... │ ├── domain/ # Tous les domaines métier │ ├── user/ # Domaine utilisateur │ │ ├── entities/ # Modèles de données │ │ │ ├── User.ts │ │ │ │ │ ├── ports/ # Interfaces pour les adaptateurs │ │ │ └── UserPorts.ts │ │ │ │ │ ├── services/ # Services métier │ │ │ ├── UserService.ts │ │ │ └── UserService.test.ts # Tests des services │ │ │ │ │ ├── adapters/ # Implémentations concrètes │ │ │ ├── UserApiAdapter.ts │ │ │ └── UserApiAdapter.test.ts # Tests des adaptateurs │ │ │ │ │ └── hooks/ # Hooks spécifiques au domaine │ │ ├── useUser.ts │ │ └── useUser.test.ts # Tests des hooks │ │ └── ui/ # Ui spécifiques au domaine │ │ ├── UserView.ts │ │ └── userView.test.ts # Tests des vues │ │ │ └── product/ # Autre domaine │ ├── entities/ │ ├── ports/ │ ├── services/ │ ├── adapters/ │ └── hooks/ │ ├── context/ # Contextes React globaux │ ├── ServiceContext.tsx │ └── ServiceContext.test.tsx # Tests du contexte │ ├── utils/ # Utilitaires et helpers │ ├── formatters.ts │ ├── formatters.test.ts # Tests des utilitaires │ └── ... │ ├── mocks/ # Configuration globale MSW │ ├── handlers/ # Handlers organisés par domaine │ │ ├── user.ts # Handlers pour le domaine user │ │ └── product.ts # Handlers pour le domaine product │ ├── server.ts # Serveur MSW pour Node.js │ └── browser.ts # Worker MSW pour le navigateur │ ├── assets/ # Ressources statiques │ ├── images/ │ └── styles/ │ ├── App.tsx # Point d'entrée de l'application ├── App.test.tsx # Test du composant App ├── index.tsx # Fichier d'initialisation └── setupTests.ts # Configuration globale des tests ``` ## Organisation par domaines Chaque domaine est organisé selon les principes de l'architecture hexagonale, avec les tests colocalisés: ### Exemple pour le domaine "user" ``` domain/ └── user/ ├── entities/ # Modèles de données │ ├── User.ts # Interface User │ └── User.test.ts # Tests de validation des entités │ ├── ports/ # Interfaces pour les adaptateurs │ └── UserRepository.ts # Interface pour accéder aux données │ ├── services/ # Services métier │ ├── UserService.ts # Logique métier utilisateur │ └── UserService.test.ts # Tests des services │ ├── adapters/ # Implémentations concrètes │ ├── UserApiAdapter.ts # Accès à l'API REST │ └── UserApiAdapter.test.ts # Tests de l'adaptateur │ └── hooks/ # Hooks React spécifiques ├── useUser.ts # Hook combinant service et React Query └── useUser.test.ts # Tests du hook ``` ## Exemples d'implémentation ### 1. Définition des entités ```tsx // src/domain/user/entities/User.ts export interface User { id: string; name: string; email: string; role: 'admin' | 'user'; createdAt: string; } ``` ### 2. Définition des ports (interfaces) ```tsx // src/domain/user/ports/UserPorts.ts import { User } from '../entities/User'; export interface UserPorts { getUser(id: string): Promise<User>; updateUser(user: User): Promise<User>; getUserList(): Promise<User[]>; } ``` ### 3. Services métier ```tsx // src/domain/user/services/UserService.ts import { User } from '../entities/User'; import { UserPorts } from '../ports/UserPorts'; export class UserService { constructor(private userPorts: UserPorts) {} async getUser(id: string): Promise<User> { return this.userPorts.getUser(id); } async updateUser(user: User): Promise<User> { return this.userPorts.updateUser(user); } async getUserList(): Promise<User[]> { return this.userPorts.getUserList(); } // Exemple de logique métier plus complexe async promoteToAdmin(userId: string): Promise<User> { const user = await this.getUser(userId); if (user.role === 'admin') { throw new Error('User is already an admin'); } const updatedUser = { ...user, role: 'admin' as const }; return this.updateUser(updatedUser); } } ``` ### 4. Adaptateurs concrets ```tsx // src/domain/user/adapters/UserApiAdapter.ts import { User } from '../entities/User'; import { UserPorts } from '../ports/UserPorts'; export class UserApiAdapter implements UserPorts { private baseUrl: string; constructor(baseUrl: string = '/api/users') { this.baseUrl = baseUrl; } async getUser(id: string): Promise<User> { const response = await fetch(`${this.baseUrl}/${id}`); if (!response.ok) { throw new Error(`Error fetching user: ${response.statusText}`); } return await response.json(); } async updateUser(user: User): Promise<User> { const response = await fetch(`${this.baseUrl}/${user.id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(user) }); if (!response.ok) { throw new Error(`Error updating user: ${response.statusText}`); } return await response.json(); } async getUserList(): Promise<User[]> { const response = await fetch(`${this.baseUrl}/`); if (!response.ok) { throw new Error(`Error fetching users: ${response.statusText}`); } return await response.json(); } } ``` ### 5. Contexte pour l'injection de dépendances ```tsx // src/context/ServiceContext.tsx import React, { createContext, useContext } from 'react'; // Import des services et adaptateurs import { UserService } from '../domain/user/services/UserService'; import { UserApiAdapter } from '../domain/user/adapters/UserApiAdapter'; import { ProductService } from '../domain/product/services/ProductService'; import { ProductApiAdapter } from '../domain/product/adapters/ProductApiAdapter'; // Configuration de base const API_BASE_URL = process.env.REACT_APP_API_BASE_URL || '/api'; // Création des adaptateurs const userRepository = new UserApiAdapter(API_BASE_URL); const productRepository = new ProductApiAdapter(API_BASE_URL); // Création des services avec leurs dépendances const userService = new UserService(userRepository); const productService = new ProductService(productRepository); // Type pour les services interface Services { userService: UserService; productService: ProductService; } // Services par défaut const defaultServices: Services = { userService, productService }; // Création du contexte const ServiceContext = createContext<Services>(defaultServices); // Provider pour injection de dépendances export const ServiceProvider: React.FC<{ services?: Partial<Services>; children: React.ReactNode; }> = ({ services, children }) => { const value = { ...defaultServices, ...services }; return ( <ServiceContext.Provider value={value}> {children} </ServiceContext.Provider> ); }; // Hook pour utiliser les services export const useServices = () => useContext(ServiceContext); ``` ### 6. Hook spécifique au domaine utilisant React Query ```tsx // src/domain/user/hooks/useUser.ts import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { User } from '../entities/User'; import { useServices } from '../../../context/ServiceContext'; // Clés pour React Query export const USER_QUERY_KEYS = { all: ['users'] as const, lists: () => [...USER_QUERY_KEYS.all, 'list'] as const, detail: (id: string) => [...USER_QUERY_KEYS.all, 'detail', id] as const, }; export function useUser(userId: string) { const { userService } = useServices(); const queryClient = useQueryClient(); // Requête pour obtenir l'utilisateur const userQuery = useQuery({ queryKey: USER_QUERY_KEYS.detail(userId), queryFn: () => userService.getUser(userId), enabled: !!userId, }); // Mutation pour mettre à jour l'utilisateur const updateUserMutation = useMutation({ mutationFn: (updatedUser: User) => userService.updateUser(updatedUser), onSuccess: (updatedUser) => { queryClient.setQueryData(USER_QUERY_KEYS.detail(userId), updatedUser); queryClient.invalidateQueries({ queryKey: USER_QUERY_KEYS.lists() }); }, }); // Mutation pour promouvoir un utilisateur en admin const promoteToAdminMutation = useMutation({ mutationFn: (id: string) => userService.promoteToAdmin(id), onSuccess: (updatedUser) => { queryClient.setQueryData(USER_QUERY_KEYS.detail(userId), updatedUser); queryClient.invalidateQueries({ queryKey: USER_QUERY_KEYS.lists() }); }, }); return { user: userQuery.data, isLoading: userQuery.isLoading, error: userQuery.error, updateUser: updateUserMutation.mutate, isUpdating: updateUserMutation.isPending, promoteToAdmin: promoteToAdminMutation.mutate, isPromoting: promoteToAdminMutation.isPending, }; } ``` ## Tests colocalisés ### 1. Test d'un service ```tsx // src/domain/user/services/UserService.test.ts import { UserService } from './UserService'; import { UserPorts } from '../ports/UserPorts'; import { User } from '../entities/User'; // Mock du Ports const mockUser: User = { id: '1', name: 'Test User', email: 'test@example.com', role: 'user', createdAt: '2023-01-01T00:00:00Z' }; const mockUserRepository: UserRepository = { getUser: jest.fn().mockResolvedValue(mockUser), updateUser: jest.fn().mockImplementation((user) => Promise.resolve(user)), getUserList: jest.fn().mockResolvedValue([mockUser]), }; describe('UserService', () => { let userService: UserService; beforeEach(() => { jest.clearAllMocks(); userService = new UserService(mockUserRepository); }); it('should get a user by id', async () => { const user = await userService.getUser('1'); expect(user).toEqual(mockUser); expect(mockUserRepository.getUser).toHaveBeenCalledWith('1'); }); it('should update a user', async () => { const updatedUser = { ...mockUser, name: 'Updated Name' }; const result = await userService.updateUser(updatedUser); expect(result).toEqual(updatedUser); expect(mockUserRepository.updateUser).toHaveBeenCalledWith(updatedUser); }); it('should promote a user to admin', async () => { const userId = '1'; await userService.promoteToAdmin(userId); // Vérifier que getUser a été appelé expect(mockUserRepository.getUser).toHaveBeenCalledWith(userId); // Vérifier que updateUser a été appelé avec le bon rôle expect(mockUserRepository.updateUser).toHaveBeenCalledWith({ ...mockUser, role: 'admin' }); }); it('should throw error when promoting an admin', async () => { const adminUser = { ...mockUser, role: 'admin' }; mockUserRepository.getUser = jest.fn().mockResolvedValue(adminUser); await expect(userService.promoteToAdmin('1')).rejects.toThrow('User is already an admin'); }); }); ``` ### 2. Test d'un adaptateur API avec MSW ```tsx // src/domain/user/adapters/UserApiAdapter.test.ts import { UserApiAdapter } from './UserApiAdapter'; import { server } from '../../../mocks/server'; import { http, HttpResponse } from 'msw'; // Réinitialiser les handlers MSW pour ces tests spécifiques beforeEach(() => { server.resetHandlers(); }); describe('UserApiAdapter', () => { const baseUrl = '/api'; let adapter: UserApiAdapter; beforeEach(() => { adapter = new UserApiAdapter(baseUrl); }); it('should fetch a user by id', async () => { // Configurer le handler MSW pour ce test server.use( http.get(`${baseUrl}/users/1`, () => { return HttpResponse.json({ id: '1', name: 'John Doe', email: 'john@example.com', role: 'user', createdAt: '2023-01-01T00:00:00Z' }); }) ); const user = await adapter.getUser('1'); expect(user).toBeDefined(); expect(user.id).toBe('1'); expect(user.name).toBe('John Doe'); }); it('should handle error when fetching user', async () => { // Simuler une erreur server.use( http.get(`${baseUrl}/users/999`, () => { return new HttpResponse(null, { status: 404 }); }) ); await expect(adapter.getUser('999')).rejects.toThrow('Error fetching user'); }); it('should update a user', async () => { const updatedUser = { id: '1', name: 'Updated Name', email: 'john@example.com', role: 'user', createdAt: '2023-01-01T00:00:00Z' }; server.use( http.put(`${baseUrl}/users/1`, async ({ request }) => { const body = await request.json(); return HttpResponse.json({ ...body, updatedAt: '2023-02-01T00:00:00Z' }); }) ); const result = await adapter.updateUser(updatedUser); expect(result).toBeDefined(); expect(result.name).toBe('Updated Name'); expect(result.updatedAt).toBe('2023-02-01T00:00:00Z'); }); }); ``` ### 3. Test d'un hook avec React Testing Library ```tsx // src/domain/user/hooks/useUser.test.ts import { renderHook, act, waitFor } from '@testing-library/react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { useUser } from './useUser'; import { ServiceProvider } from '../../../context/ServiceContext'; import { UserService } from '../services/UserService'; import { User } from '../entities/User'; // Mock du service utilisateur const mockUser: User = { id: '1', name: 'Test User', email: 'test@example.com', role: 'user', createdAt: '2023-01-01T00:00:00Z' }; const mockUserService = { getUser: jest.fn().mockResolvedValue(mockUser), updateUser: jest.fn().mockImplementation((user: User) => Promise.resolve(user)), promoteToAdmin: jest.fn().mockImplementation(() => Promise.resolve({ ...mockUser, role: 'admin' }) ), } as unknown as UserService; // Wrapper avec providers pour les tests a mettre dans un utils const createWrapper = () => { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, }, }, }); return ({ children }) => ( <QueryClientProvider client={queryClient}> <ServiceProvider services={{ userService: mockUserService }}> {children} </ServiceProvider> </QueryClientProvider> ); }; describe('useUser hook', () => { beforeEach(() => { jest.clearAllMocks(); }); it('should fetch user data', async () => { const { result } = renderHook(() => useUser('1'), { wrapper: createWrapper(), }); // Initialement loading doit être true expect(result.current.isLoading).toBe(true); // Attendre que les données soient chargées await waitFor(() => { expect(result.current.isLoading).toBe(false); }); // Vérifier les données expect(result.current.user).toEqual(mockUser); expect(mockUserService.getUser).toHaveBeenCalledWith('1'); }); it('should update user data', async () => { const { result } = renderHook(() => useUser('1'), { wrapper: createWrapper(), }); // Attendre le chargement initial await waitFor(() => { expect(result.current.isLoading).toBe(false); }); // Mettre à jour l'utilisateur const updatedUser = { ...mockUser, name: 'Updated Name' }; act(() => { result.current.updateUser(updatedUser); }); // Vérifier que la mise à jour a été appelée expect(mockUserService.updateUser).toHaveBeenCalledWith(updatedUser); // Attendre la fin de la mise à jour await waitFor(() => { expect(result.current.isUpdating).toBe(false); }); }); it('should promote user to admin', async () => { const { result } = renderHook(() => useUser('1'), { wrapper: createWrapper(), }); // Attendre le chargement initial await waitFor(() => { expect(result.current.isLoading).toBe(false); }); // Promouvoir l'utilisateur act(() => { result.current.promoteToAdmin('1'); }); // Vérifier que la promotion a été appelée expect(mockUserService.promoteToAdmin).toHaveBeenCalledWith('1'); // Attendre la fin de la promotion await waitFor(() => { expect(result.current.isPromoting).toBe(false); }); }); }); ``` ## Intégration avec React Query ### Composant utilisant le hook de domaine ```tsx // src/pages/UserProfilePage.tsx import React from 'react'; import { useParams } from 'react-router-dom'; import { useUser } from '../domain/user/hooks/useUser'; export const UserProfilePage: React.FC = () => { const { userId } = useParams<{ userId: string }>(); const { user, isLoading, error, updateUser, isUpdating, promoteToAdmin, isPromoting } = useUser(userId || ''); if (isLoading) return <div>Chargement...</div>; if (error) return <div>Erreur: {error.message}</div>; if (!user) return <div>Utilisateur non trouvé</div>; const handleNameChange = () => { const newName = prompt('Nouveau nom:', user.name); if (newName && newName !== user.name) { updateUser({ ...user, name: newName }); } }; const handlePromoteToAdmin = () => { if (window.confirm('Promouvoir cet utilisateur en administrateur?')) { promoteToAdmin(user.id); } }; return ( <div className="user-profile"> <h1>Profil Utilisateur</h1> <div className="user-details"> <h2>{user.name}</h2> <p>Email: {user.email}</p> <p>Rôle: {user.role}</p> <p>Créé le: {new Date(user.createdAt).toLocaleDateString()}</p> </div> <div className="user-actions"> <button onClick={handleNameChange} disabled={isUpdating} > {isUpdating ? 'Modification en cours...' : 'Modifier le nom'} </button> {user.role !== 'admin' && ( <button onClick={handlePromoteToAdmin} disabled={isPromoting} > {isPromoting ? 'Promotion en cours...' : 'Promouvoir en admin'} </button> )} </div> </div> ); }; // src/pages/UserProfilePage.test.tsx - Test colocalisé import { render, screen, waitFor, fireEvent } from '@testing-library/react'; import { MemoryRouter, Routes, Route } from 'react-router-dom'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { ServiceProvider } from '../context/ServiceContext'; import { UserProfilePage } from './UserProfilePage'; import { UserService } from '../domain/user/services/UserService'; // Mocks similaires à ceux du test du hook // ... describe('UserProfilePage', () => { // Tests du composant // ... }); ``` ## Configuration MSW ### 1. Handlers par domaine ```tsx // src/mocks/handlers/user.ts import { http, HttpResponse } from 'msw'; // Base de données mockée const users = { '1': { id: '1', name: 'John Doe', email: 'john@example.com', role: 'user', createdAt: '2023-01-01T00:00:00Z' }, '2': { id: '2', name: 'Jane Smith', email: 'jane@example.com', role: 'admin', createdAt: '2023-01-02T00:00:00Z' } }; export const userHandlers = [ // GET /api/users http.get('/api/users', () => { return HttpResponse.json(Object.values(users)); }), // GET /api/users/:id http.get('/api/users/:id', ({ params }) => { const { id } = params; if (!users[id as string]) { return new HttpResponse(null, { status: 404 }); } return HttpResponse.json(users[id as string]); }), // PUT /api/users/:id http.put('/api/users/:id', async ({ params, request }) => { const { id } = params; if (!users[id as string]) { return new HttpResponse(null, { status: 404 }); } const updateData = await request.json(); users[id as string] = { ...users[id as string], ...updateData }; return HttpResponse.json(users[id as string]); }) ]; ``` ### 2. Configuration du serveur MSW ```tsx // src/mocks/server.ts import { setupServer } from 'msw/node'; import { userHandlers } from './handlers/user'; import { productHandlers } from './handlers/product'; // Création du serveur MSW avec tous les handlers export const server = setupServer( ...userHandlers, ...productHandlers ); ``` ### 3. Configuration du worker MSW pour le navigateur ```tsx // src/mocks/browser.ts import { setupWorker } from 'msw/browser'; import { userHandlers } from './handlers/user'; import { productHandlers } from './handlers/product'; // Création du worker MSW avec tous les handlers export const worker = setupWorker( ...userHandlers, ...productHandlers ); ``` ### 4. Configuration globale des tests ```tsx // src/setupTests.ts import '@testing-library/jest-dom'; import { server } from './mocks/server'; // Activer le serveur MSW avant tous les tests beforeAll(() => server.listen({ onUnhandledRequest: 'warn' })); // Réinitialiser les handlers entre les tests afterEach(() => server.resetHandlers()); // Fermer le serveur après tous les tests afterAll(() => server.close()); ``` ### 5. Utilisation de MSW en développement ```tsx // src/index.tsx import React from 'react'; import ReactDOM from 'react-dom/client'; import App from './App'; async function bootstrap() { // Activer MSW en développement uniquement if (process.env.NODE_ENV === 'development') { const { worker } = await import('./mocks/browser'); await worker.start({ onUnhandledRequest: 'bypass' }); } const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement); root.render( <React.StrictMode> <App /> </React.StrictMode> ); } bootstrap(); ``` ## Conclusion Cette architecture présente plusieurs avantages: 1. **Organisation intuitive**: - Code organisé par domaines métier - Tests colocalisés avec le code qu'ils testent - Structure familière pour les développeurs React 2. **Séparation des préoccupations**: - Logique métier indépendante de l'interface utilisateur - Adaptateurs pour les sources de données externes - Hooks spécifiques aux domaines pour connecter le tout 3. **Testabilité améliorée**: - Tests plus faciles à écrire et à maintenir - MSW pour mocker les API sans mocking manuel - Injection de dépendances via React Context 4. **Évolutivité**: - Facile d'ajouter de nouveaux domaines - Possibilité de changer l'implémentation des adaptateurs - Structure qui s'adapte à la taille du projet Cette approche reste simple tout en offrant les avantages de l'architecture hexagonale, permettant de créer des applications React maintenables et testables.