Comment structurer une application web moderne ? Comment s'assurer qu'elle reste maintenable au fil du temps ? Dans ce guide, nous allons créer une application Next.js en utilisant une architecture hexagonale, une validation de formulaires robuste. Pour rendre l'apprentissage plus ludique, nous créerons un systÚme d'inscription à l'Académie Jedi !
Le code source est disponible sur GitHub.
Table des matiĂšres
- Introduction Ă l'Architecture Hexagonale
- Installation et Structure
- Le Domaine
- L'Infrastructure
- L'Interface Utilisateur
- Pour aller plus loin
1. Introduction Ă l'Architecture Hexagonale
1.1 Qu'est-ce que l'Architecture Hexagonale ?
L'Architecture Hexagonale, aussi connue sous le nom de "Ports and Adapters", est un modÚle d'architecture qui permet de créer des applications dont les composants business sont :
- Découplés des détails techniques
- Testables en isolation
- Indépendants du framework utilisé
L'architecture se divise en trois couches principales :
- Le Domaine : Contient la logique métier pure
- Les Ports : Définissent les interfaces pour communiquer avec le domaine
- Les Adaptateurs : Implémentent les ports pour interagir avec le monde extérieur
1.2 Pourquoi utiliser l'Architecture Hexagonale ?
Avantages
- Séparation claire des responsabilités
- Logique métier protégée et centralisée
- Tests facilités
- Flexibilité pour changer les technologies
- Maintenance simplifiée
Cas d'usage idéaux
- Applications avec des rÚgles métier complexes
- Projets destinés à évoluer dans le temps
- SystÚmes nécessitant une grande testabilité
- Applications devant supporter plusieurs interfaces
2. Installation et Structure
2.1 Installation complĂšte
1# Création du projet
2npx create-next-app@latest jedi-academy
Lors de la création du projet, répondez aux questions suivantes :
Would you like to use TypeScript? Yes
Would you like to use ESLint? Yes
Would you like to use Tailwind CSS? Yes
Would you like to use `src/` directory? Yes
Would you like to use App Router? (recommended) Yes
Would you like to customize the default import alias (@/*)? Yes
Using Next.js version 15.0.0+ with Turbopack
Une fois le projet créé, installez les dépendances :
1cd jedi-academy
2
3# Dépendances principales pour la gestion des formulaires et la validation
4npm install @hookform/resolvers zod react-hook-form
5
6# Base de données Prisma
7npm install @prisma/client
8npm install -D prisma
9
10# Installation de shadcn/ui
11npx shadcn@latest init
Lors de l'initialisation de shadcn/ui, répondez aux questions suivantes :
Would you like to use TypeScript (recommended)? Yes
Which style would you like to use? âș Default
Which color would you like to use as base color? âș Slate
Where is your global CSS file? âș src/app/globals.css
Would you like to use CSS variables for colors? âș Yes
Where is your tailwind.config.js located? âș tailwind.config.js
Configure the import alias for components: âș @/components
Configure the import alias for utils: âș @/lib/utils
Are you using React Server Components? âș Yes
# Installation des composants nécessaires
npx shadcn@latest add form input select textarea button card toast table
1# Utilitaires de dates
2npm install date-fns
3
4# Base de données
5npx prisma init --datasource-provider sqlite
2.2 Structure du projet
src/
âââ app/
â âââ actions/
â â âââ register-padawan.ts
â âââ components/
â â âââ ui/ # Composants shadcn/ui
â â âââ jedi-form.tsx
â â âââ padawan-list.tsx
â âââ lib/
â â âââ utils.ts # Utilitaires shadcn/ui
â âââ page.tsx
âââ core/
â âââ domain/
â â âââ models/
â â â âââ padawan.ts
â â âââ ports/
â â â âââ padawan-repository.ts
â â âââ services/
â â â âââ padawan-eligibility.ts
â â âââ validation/
â â â âââ padawan-schema.ts
â â âââ events/
â â âââ padawan-registered.ts
â âââ usecases/
â âââ register-padawan.ts
âââ infrastructure/
âââ db/
âââ prisma/
â âââ migrations/
â âââ schema.prisma
âââ prisma.ts
âââ repositories/
âââ prisma-padawan-repository.ts
Cette structure suit une architecture hexagonale claire avec :
app/ - Couche interface utilisateur Next.js
actions/ - Server Actions pour gérer les formulaires
components/ - Composants React avec une séparation UI/métier
lib/ - Utilitaires partagés
core/ - Logique métier pure
domain/ - ModÚles, ports et services métier
usecases/ - Cas d'utilisation de l'application
infrastructure/ - Implémentations techniques
db/ - Configuration Prisma et implémentation des repositories
3. Le Domaine : CĆur de l'Application
3.1 Comprendre le Domaine
Le domaine est le cĆur de notre application. C'est ici que nous traduisons les rĂšgles mĂ©tier en code, indĂ©pendamment de toute considĂ©ration technique. Dans notre cas, le domaine reprĂ©sente toutes les rĂšgles qui rĂ©gissent l'inscription Ă l'AcadĂ©mie Jedi.
Principes clés du domaine
- Indépendance technologique
- Pas de dépendances à des frameworks
- Pas de code lié à la persistance
- Pas de logique UI
- RÚgles métier centralisées
- Toute la logique métier est dans le domaine
- Les rÚgles sont explicites et documentées
- Les validations métier sont séparées des validations techniques
- Modélisation riche
- Utilisation de types et interfaces précis
- Value Objects pour les concepts métier importants
- Entités avec identité claire
3.2 Les composants du domaine
3.2.1 Les ModĂšles (Models)
Les modÚles représentent les concepts métier fondamentaux.
1// src/core/domain/models/padawan.ts
2export type PrimarySkill = "combat" | "healing" | "meditation" | "telepathy";
3
4export interface Padawan {
5 id?: string;
6 name: string;
7 age: number;
8 midichlorianCount: number;
9 homeworld: string;
10 primarySkill: PrimarySkill;
11 background: string;
12 createdAt?: Date;
13}
14
15export class Result<T> {
16 private constructor(
17 private readonly _isSuccess: boolean,
18 private readonly value?: T,
19 private readonly error?: string
20 ) {}
21
22 static success<T>(value: T): Result<T> {
23 return new Result<T>(true, value);
24 }
25
26 static failure<T>(error: string): Result<T> {
27 return new Result<T>(false, undefined, error);
28 }
29
30 getError(): string | undefined {
31 return this.error;
32 }
33
34 getValue(): T | undefined {
35 return this.value;
36 }
37
38 isSuccess(): boolean {
39 return this._isSuccess;
40 }
41}
3.2.2 Les ĂvĂ©nements de Domaine
Les événements représentent des faits importants qui se sont produits dans le domaine.
1// src/core/domain/events/padawan-registered.ts
2export interface DomainEvent {
3 occurredOn: Date;
4}
5
6export class PadawanRegistered implements DomainEvent {
7 occurredOn: Date = new Date();
8
9 constructor(
10 public readonly padawanId: string,
11 public readonly name: string,
12 public readonly midichlorianCount: number
13 ) {}
14}
3.2.3 Les Ports (Interfaces)
Les ports définissent comment le domaine interagit avec le monde extérieur.
1// src/core/domain/ports/padawan-repository.ts
2import { Padawan, Result } from "@/core/domain/models/padawan";
3
4export interface PadawanRepository {
5 save(padawan: Omit<Padawan, "id" | "createdAt">): Promise<Result<Padawan>>;
6 findById(id: string): Promise<Result<Padawan | null>>;
7 findAll(): Promise<Result<Padawan[]>>;
8}
3.2.4 Les Services de Domaine
Les services encapsulent la logique métier qui ne correspond pas naturellement à une entité.
1// src/core/domain/services/padawan-eligibility.ts
2import { Padawan, Result } from "@/core/domain/models/padawan";
3
4export class PadawanEligibilityService {
5 static check(padawan: Omit<Padawan, "id" | "createdAt">): Result<void> {
6 if (padawan.age < 4) {
7 return Result.failure("Too young to begin the training");
8 }
9 if (padawan.age > 30) {
10 return Result.failure("Too old to begin the training");
11 }
12
13 if (padawan.midichlorianCount < 7000) {
14 return Result.failure("Midichlorian count is too low");
15 }
16 if (padawan.midichlorianCount > 20000) {
17 return Result.failure("Suspicious midichlorian count");
18 }
19
20 if (padawan.background.length < 20) {
21 return Result.failure("Personal story needs more details");
22 }
23
24 return Result.success(void 0);
25 }
26}
3.2.5 Les Cas d'Utilisation (Use Cases)
Les cas d'utilisation orchestrent les interactions entre les différents éléments du domaine pour réaliser une fonctionnalité métier.
1// src/core/usecases/register-padawan.ts
2import { Padawan, Result } from "@/core/domain/models/padawan";
3import { PadawanRepository } from "@/core/domain/ports/padawan-repository";
4import { PadawanEligibilityService } from "@/core/domain/services/padawan-eligibility";
5import { PadawanRegistered } from "@/core/domain/events/padawan-registered";
6import { EventDispatcher } from "@/core/domain/ports/event-dispatcher";
7
8export class RegisterPadawanUseCase {
9 constructor(
10 private readonly padawanRepository: PadawanRepository,
11 private readonly eventDispatcher: EventDispatcher
12 ) {}
13
14 async execute(
15 padawan: Omit<Padawan, "id" | "createdAt">
16 ): Promise<Result<Padawan>> {
17 const eligibilityResult = PadawanEligibilityService.check(padawan);
18 if (!eligibilityResult.isSuccess()) {
19 return Result.failure(eligibilityResult.getError()!);
20 }
21
22 const saveResult = await this.padawanRepository.save(padawan);
23 if (!saveResult.isSuccess()) {
24 return Result.failure(saveResult.getError()!);
25 }
26
27 const savedPadawan = saveResult.getValue()!;
28
29 await this.eventDispatcher.dispatch(
30 new PadawanRegistered(
31 savedPadawan.id!,
32 savedPadawan.name,
33 savedPadawan.midichlorianCount
34 )
35 );
36
37 return Result.success(savedPadawan);
38 }
39}
4. L'Infrastructure
L'infrastructure implémente les ports définis dans le domaine pour interagir avec le monde extérieur.
4.1 Configuration Prisma
1// prisma/schema.prisma
2generator client {
3 provider = "prisma-client-js"
4}
5
6datasource db {
7 provider = "sqlite"
8 url = env("DATABASE_URL")
9}
10
11model Padawan {
12 id String @id @default(uuid())
13 name String
14 age Int
15 midichlorianCount Int
16 homeworld String
17 primarySkill String
18 background String
19 createdAt DateTime @default(now())
20
21 @@map("padawans")
22}
4.2 Implementation des Adaptateurs
1// src/infrastructure/db/repositories/prisma-padawan-repository.ts
2import { Padawan, Result } from "@/core/domain/models/padawan";
3import { PadawanRepository } from "@/core/domain/ports/padawan-repository";
4import { prisma } from "@/infrastructure/db/prisma";
5
6export class PrismaPadawanRepository implements PadawanRepository {
7 async save(
8 padawan: Omit<Padawan, "id" | "createdAt">
9 ): Promise<Result<Padawan>> {
10 try {
11 const saved = await prisma.padawan.create({
12 data: padawan,
13 });
14
15 return Result.success(this.mapPrismaPadawanToDomain(saved));
16 } catch (error: unknown) {
17 if (error instanceof Error) {
18 return Result.failure(`Error during save: ${error.message}`);
19 }
20 return Result.failure('An unknown error occurred during save');
21 }
22 }
23
24 async findById(id: string): Promise<Result<Padawan | null>> {
25 try {
26 const padawan = await prisma.padawan.findUnique({
27 where: { id },
28 });
29
30 return Result.success(
31 padawan ? this.mapPrismaPadawanToDomain(padawan) : null
32 );
33 } catch (error: unknown) {
34 if (error instanceof Error) {
35 return Result.failure(`Error during search: ${error.message}`);
36 }
37 return Result.failure('An unknown error occurred during search');
38 }
39 }
40
41 async findAll(): Promise<Result<Padawan[]>> {
42 try {
43 const padawans = await prisma.padawan.findMany({
44 orderBy: { createdAt: "desc" },
45 });
46
47 return Result.success(padawans.map(this.mapPrismaPadawanToDomain));
48 } catch (error: unknown) {
49 if (error instanceof Error) {
50 return Result.failure(`Error during retrieval: ${error.message}`);
51 }
52 return Result.failure('An unknown error occurred during retrieval');
53 }
54 }
55
56 private mapPrismaPadawanToDomain(padawan: any): Padawan {
57 return {
58 id: padawan.id,
59 name: padawan.name,
60 age: padawan.age,
61 midichlorianCount: padawan.midichlorianCount,
62 homeworld: padawan.homeworld,
63 primarySkill: padawan.primarySkill,
64 background: padawan.background,
65 createdAt: padawan.createdAt,
66 };
67 }
68}
5. L'Interface Utilisateur
5.1 Validation avec Zod
La validation des données d'entrée utilise Zod pour assurer la cohérence avec notre domaine.
1// src/core/domain/validation/padawan-schema.ts
2import { z } from "zod";
3
4export const padawanSchema = z.object({
5 name: z
6 .string()
7 .min(2, "A Padawan must have a name longer than 2 characters")
8 .max(50, "Even Qui-Gon Jinn doesn't have such a long name"),
9 age: z.coerce
10 .number()
11 .min(4, "Even Grogu started his training at age 4")
12 .max(30, "The Council considers this the age limit to begin training"),
13 midichlorianCount: z.coerce
14 .number()
15 .min(7000, "Midichlorian count is too low for Jedi training")
16 .max(20000, "Even Anakin only had 20000 midichlorians"),
17 homeworld: z
18 .string()
19 .min(2, "Your planet name is required"),
20 primarySkill: z.enum(["combat", "healing", "meditation", "telepathy"], {
21 errorMap: () => ({
22 message: "This skill is not recognized by the Jedi Order",
23 }),
24 }),
25 background: z
26 .string()
27 .min(20, "Tell us more about your journey"),
28});
29
30export type PadawanFormData = z.infer<typeof padawanSchema>;
Maintenant, nous pouvons générer la db.
Pour aller plus loin, prisma fournit un client permettant de visualiser la db.
5.2 Server Action pour l'inscription
1// src/app/actions/register-padawan.ts
2'use server'
3
4import { PrismaPadawanRepository } from '@/infrastructure/db/repositories/prisma-padawan-repository';
5import { ConsoleEventDispatcher } from '@/infrastructure/services/event-dispatcher';
6import { RegisterPadawanUseCase } from '@/core/usecases/register-padawan';
7import { padawanSchema } from '@/core/domain/validation/padawan-schema';
8import { revalidatePath } from 'next/cache';
9
10const padawanRepository = new PrismaPadawanRepository();
11const eventDispatcher = new ConsoleEventDispatcher();
12const registerPadawan = new RegisterPadawanUseCase(
13 padawanRepository,
14 eventDispatcher
15);
16
17export async function handlePadawanRegistration(formData: FormData) {
18 try {
19 const validatedData = padawanSchema.parse(Object.fromEntries(formData));
20 const result = await registerPadawan.execute(validatedData);
21
22 if (!result.isSuccess()) {
23 return {
24 success: false,
25 message: result.getError()
26 };
27 }
28
29 revalidatePath('/');
30 return {
31 success: true,
32 message: "May the Force be with you, young Padawan! đ"
33 };
34 } catch (error) {
35 return {
36 success: false,
37 message: "A disturbance in the Force has been detected... đ"
38 };
39 }
40}
5.3 Composant de Formulaire
1// src/app/components/jedi-form.tsx
2"use client";
3
4import { useForm } from "react-hook-form";
5import { zodResolver } from "@hookform/resolvers/zod";
6import { useToast } from "@/hooks/use-toast";
7
8import {
9 Form,
10 FormControl,
11 FormField,
12 FormItem,
13 FormLabel,
14 FormMessage,
15} from "@/components/ui/form";
16import {
17 Card,
18 CardHeader,
19 CardTitle,
20 CardDescription,
21 CardContent,
22} from "@/components/ui/card";
23import { Input } from "@/components/ui/input";
24import { Button } from "@/components/ui/button";
25import { Textarea } from "@/components/ui/textarea";
26import {
27 Select,
28 SelectContent,
29 SelectItem,
30 SelectTrigger,
31 SelectValue,
32} from "@/components/ui/select";
33import {
34 padawanSchema,
35 type PadawanFormData,
36} from "@/core/domain/validation/padawan-schema";
37import { handlePadawanRegistration } from "../actions/register-padawan";
38
39export function JediRegistrationForm() {
40 const { toast } = useToast();
41 const form = useForm<PadawanFormData>({
42 resolver: zodResolver(padawanSchema),
43 defaultValues: {
44 name: "",
45 age: 0,
46 midichlorianCount: 7000,
47 homeworld: "",
48 primarySkill: "combat",
49 background: "",
50 },
51 });
52
53 async function onSubmit(data: PadawanFormData) {
54 const formData = new FormData();
55 Object.entries(data).forEach(([key, value]) => {
56 formData.append(key, value.toString());
57 });
58
59 const result = await handlePadawanRegistration(formData);
60
61 toast({
62 title: result.success ? "Registration successful" : "Error",
63 description: result.message,
64 variant: result.success ? "default" : "destructive",
65 });
66
67 if (result.success) {
68 form.reset();
69 }
70 }
71
72 return (
73 <Card className="w-full max-w-md mx-auto">
74 <CardHeader>
75 <CardTitle>Jedi Academy</CardTitle>
76 <CardDescription>
77 Submit your application to join the Order
78 </CardDescription>
79 </CardHeader>
80 <CardContent>
81 <Form {...form}>
82 <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
83 <FormField
84 control={form.control}
85 name="name"
86 render={({ field }) => (
87 <FormItem>
88 <FormLabel>Padawan Name</FormLabel>
89 <FormControl>
90 <Input placeholder="Luke Skywalker" {...field} />
91 </FormControl>
92 <FormMessage />
93 </FormItem>
94 )}
95 />
96
97 <FormField
98 control={form.control}
99 name="age"
100 render={({ field }) => (
101 <FormItem>
102 <FormLabel>Age</FormLabel>
103 <FormControl>
104 <Input
105 type="number"
106 min="4"
107 max="30"
108 placeholder="19"
109 {...field}
110 onChange={(e) => field.onChange(parseInt(e.target.value))}
111 />
112 </FormControl>
113 <FormMessage />
114 </FormItem>
115 )}
116 />
117
118 <FormField
119 control={form.control}
120 name="midichlorianCount"
121 render={({ field }) => (
122 <FormItem>
123 <FormLabel>Midichlorian Count</FormLabel>
124 <FormControl>
125 <Input
126 type="number"
127 min="7000"
128 max="20000"
129 step="100"
130 {...field}
131 onChange={(e) => field.onChange(parseInt(e.target.value))}
132 />
133 </FormControl>
134 <FormMessage />
135 </FormItem>
136 )}
137 />
138
139 <FormField
140 control={form.control}
141 name="homeworld"
142 render={({ field }) => (
143 <FormItem>
144 <FormLabel>Homeworld</FormLabel>
145 <FormControl>
146 <Input placeholder="Tatooine" {...field} />
147 </FormControl>
148 <FormMessage />
149 </FormItem>
150 )}
151 />
152
153 <FormField
154 control={form.control}
155 name="primarySkill"
156 render={({ field }) => (
157 <FormItem>
158 <FormLabel>Primary Skill</FormLabel>
159 <Select
160 onValueChange={field.onChange}
161 defaultValue={field.value}
162 >
163 <FormControl>
164 <SelectTrigger>
165 <SelectValue placeholder="Choose a skill" />
166 </SelectTrigger>
167 </FormControl>
168 <SelectContent>
169 <SelectItem value="combat">
170 Lightsaber Combat âïž
171 </SelectItem>
172 <SelectItem value="healing">
173 Force Healing đ
174 </SelectItem>
175 <SelectItem value="meditation">
176 Deep Meditation đ§
177 </SelectItem>
178 <SelectItem value="telepathy">Telepathy đ§ </SelectItem>
179 </SelectContent>
180 </Select>
181 <FormMessage />
182 </FormItem>
183 )}
184 />
185
186 <FormField
187 control={form.control}
188 name="background"
189 render={({ field }) => (
190 <FormItem>
191 <FormLabel>Your Story</FormLabel>
192 <FormControl>
193 <Textarea
194 placeholder="Tell us about your journey..."
195 className="resize-none"
196 rows={4}
197 {...field}
198 />
199 </FormControl>
200 <FormMessage />
201 </FormItem>
202 )}
203 />
204
205 <Button type="submit" className="w-full">
206 Submit my application đ
207 </Button>
208 </form>
209 </Form>
210 </CardContent>
211 </Card>
212 );
213}
5.4 Liste des Padawans
1// src/app/components/padawan-list.tsx
2"use client";
3
4import {
5 Table,
6 TableBody,
7 TableCaption,
8 TableCell,
9 TableHead,
10 TableHeader,
11 TableRow,
12} from "@/components/ui/table";
13import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
14import { format } from "date-fns";
15import { enUS } from "date-fns/locale";
16import type { Padawan } from "@/core/domain/models/padawan";
17
18type PadawanListProps = {
19 padawans: Padawan[];
20};
21
22const skillEmoji = {
23 combat: "âïž Lightsaber Combat",
24 healing: "đ Force Healing",
25 meditation: "đ§ Deep Meditation",
26 telepathy: "đ§ Telepathy",
27} as const;
28
29export function PadawanList({ padawans }: PadawanListProps) {
30 if (padawans.length === 0) {
31 return (
32 <Card>
33 <CardContent className="pt-6">
34 <p className="text-center text-muted-foreground">
35 No Padawans registered yet...
36 </p>
37 </CardContent>
38 </Card>
39 );
40 }
41
42 return (
43 <Card>
44 <CardHeader>
45 <CardTitle>Candidates List</CardTitle>
46 </CardHeader>
47 <CardContent>
48 <Table>
49 <TableHeader>
50 <TableRow>
51 <TableHead>Name</TableHead>
52 <TableHead>Age</TableHead>
53 <TableHead>Homeworld</TableHead>
54 <TableHead>Specialty</TableHead>
55 <TableHead>Midichlorians</TableHead>
56 <TableHead>Registration</TableHead>
57 </TableRow>
58 </TableHeader>
59 <TableBody>
60 {padawans.map((padawan) => (
61 <TableRow key={padawan.id}>
62 <TableCell className="font-medium">{padawan.name}</TableCell>
63 <TableCell>{padawan.age} years</TableCell>
64 <TableCell>{padawan.homeworld}</TableCell>
65 <TableCell>{skillEmoji[padawan.primarySkill]}</TableCell>
66 <TableCell>
67 {padawan.midichlorianCount.toLocaleString()}
68 </TableCell>
69 <TableCell>
70 {format(new Date(padawan.createdAt!), "Pp", { locale: enUS })}
71 </TableCell>
72 </TableRow>
73 ))}
74 </TableBody>
75 </Table>
76 </CardContent>
77 </Card>
78 );
79}
Ces composants utilisent shadcn/ui pour créer une interface utilisateur moderne et accessible, avec :
- JediRegistrationForm :
- Validation complĂšte avec react-hook-form et Zod
- Gestion des erreurs avec des messages contextuels
- Feedback utilisateur avec des toasts
- Reset du formulaire aprÚs soumission réussie
- PadawanList :
- Affichage tabulaire des données
- Gestion du cas vide
- Formatage des dates en français
- IcÎnes pour les compétences
- Style cohérent avec le formulaire
Le tout s'intÚgre parfaitement dans notre architecture hexagonale en restant au niveau de la couche présentation, sans logique métier.
6. Pour aller plus loin
6.1 Améliorations techniques
- Cache et Performance
1// Exemple d'implémentation de cache Redis
2export class RedisPadawanCache {
3 async cacheResults(key: string, data: any): Promise<void> {
4 await redis.set(key, JSON.stringify(data), "EX", 3600);
5 }
6}
- Monitoring et Logging
1// Service de monitoring
2export class ApplicationMonitoring {
3 trackDomainEvent(event: DomainEvent): void {
4 // Envoi Ă un service de monitoring
5 }
6}
6.2 Ăvolutions fonctionnelles
- Workflow d'approbation
- SystĂšme de notation
- Gestion des promotions
- Communication entre Padawans
Conclusion
L'architecture hexagonale nous a permis de créer une application :
- Maintenable
- Séparation claire des responsabilités
- Code métier isolé et protégé
- Ăvolutive
- Facilité d'ajout de nouvelles fonctionnalités
- Possibilité de changer l'infrastructure
- Support de nouveaux canaux d'entrée/sortie
Pour aller plus loin, vous pourriez :
- Ajouter une authentification
- Implémenter un systÚme de cache
- Mettre en place du monitoring
- Mettre en place les tests automatisés
- Ajouter des webhooks
N'oubliez pas : une bonne architecture est comme la Force - elle doit ĂȘtre en Ă©quilibre ! đ