Tous les articles
architecture18 juin 2026 22 min

Clean Architecture, Architecture Hexagonale et Design Patterns : guide complet React & Go

Guide complet Clean Architecture appliquée à Go et React/Next.js : 4 couches, ports & adapters, SOLID, Design Patterns (Repository, Factory, Observer, Decorator). Exemples de code complets et tests unitaires.

La Clean Architecture, l'Architecture Hexagonale, les Design Patterns, SOLID — ces concepts reviennent dans chaque discussion technique sérieuse. Mais qu'est-ce qui les différencie vraiment ? Et comment les appliquer concrètement en React et Go ? Ce guide complet répond à ces questions avec des exemples de code réels.

Pourquoi l'architecture logicielle est cruciale

Un projet sans architecture, c'est une application qui fonctionne parfaitement... pendant les 6 premiers mois. Ensuite vient le moment redouté : ajouter une fonctionnalité prend 3 fois plus longtemps qu'au début, chaque modification casse deux autres choses, et personne n'ose toucher le code legacy.
L'architecture logicielle répond à une question simple : comment organiser le code pour qu'il reste maintenable, testable et évolutif à long terme ? Les réponses les plus éprouvées sont la Clean Architecture, l'Architecture Hexagonale (Ports & Adapters) et les principes SOLID.
La Clean Architecture n'est pas un framework, c'est une philosophie. Elle dit : votre logique métier ne doit jamais dépendre d'un framework, d'une base de données ou d'une API externe. Ce sont eux qui dépendent de vous.

La Clean Architecture — Comprendre les 4 couches

Robert C. Martin (Uncle Bob) a formalisé la Clean Architecture en 2012. Le principe central : la règle de dépendance. Les dépendances ne peuvent pointer que vers l'intérieur. Le code métier au centre ne connaît pas le monde extérieur.

Couche 1 : Entities (Domain)

Le cœur de l'application. Contient les objets métier et leurs règles pures. Aucune dépendance externe — pas de framework, pas de base de données, pas d'HTTP. C'est le code le plus stable de votre application.

Couche 2 : Use Cases (Application)

Les cas d'utilisation de votre application. Orchestre les entités pour accomplir une tâche spécifique. Connaît les entités mais pas les frameworks. Si vous changez de base de données, cette couche ne doit pas changer.

Couche 3 : Interface Adapters

Les contrôleurs HTTP, les présentateurs, les adaptateurs de repository. Font le pont entre le monde extérieur et les use cases. Convertissent les données du format externe vers le format interne, et vice versa.

Couche 4 : Frameworks & Drivers

La couche la plus externe. Contient les frameworks (Gin, React, Next.js), les bases de données (PostgreSQL, Redis), les APIs externes. Cette couche change souvent — votre code métier, jamais.

Implémentation Clean Architecture en Go

Structure de projet Go

myapp/
├── domain/                    # Couche 1 — Entities
│   ├── entity/
│   │   ├── user.go
│   │   ├── order.go
│   │   └── invoice.go
│   └── repository/            # Interfaces (pas d'implémentation)
│       ├── user_repository.go
│       └── order_repository.go
│
├── usecase/                   # Couche 2 — Use Cases
│   ├── user/
│   │   ├── create_user.go
│   │   ├── get_user.go
│   │   └── update_user.go
│   └── order/
│       ├── place_order.go
│       └── cancel_order.go
│
├── adapter/                   # Couche 3 — Interface Adapters
│   ├── handler/               # HTTP handlers (Gin)
│   │   ├── user_handler.go
│   │   └── order_handler.go
│   ├── repository/            # Implémentations concrètes
│   │   ├── postgres_user.go
│   │   └── postgres_order.go
│   └── presenter/
│       └── user_presenter.go
│
└── infrastructure/            # Couche 4 — Frameworks & Drivers
    ├── database/
    │   └── postgres.go
    ├── server/
    │   └── gin.go
    └── config/
        └── config.go

Entity Go — Règles métier pures

// domain/entity/user.go
package entity

import (
    "errors"
    "regexp"
    "strings"
    "time"
)

type User struct {
    ID        string
    Email     string
    Name      string
    Role      UserRole
    CreatedAt time.Time
}

type UserRole string

const (
    RoleAdmin    UserRole = "admin"
    RoleStandard UserRole = "standard"
)

// NewUser — règle métier : validation à la création
func NewUser(email, name string) (*User, error) {
    email = strings.TrimSpace(strings.ToLower(email))

    if !isValidEmail(email) {
        return nil, errors.New("email invalide")
    }
    if len(strings.TrimSpace(name)) < 2 {
        return nil, errors.New("le nom doit contenir au moins 2 caractères")
    }

    return &User{
        Email:     email,
        Name:      strings.TrimSpace(name),
        Role:      RoleStandard,
        CreatedAt: time.Now(),
    }, nil
}

func (u *User) CanAccessAdmin() bool {
    return u.Role == RoleAdmin
}

func (u *User) Promote() {
    u.Role = RoleAdmin
}

func isValidEmail(email string) bool {
    re := regexp.MustCompile(`^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+.[a-zA-Z]{2,}$`)
    return re.MatchString(email)
}

Repository Interface Go — La règle de dépendance en action

// domain/repository/user_repository.go
package repository

import "myapp/domain/entity"

// Interface dans le DOMAIN — pas d'import de PostgreSQL ici
// Le domain ne sait pas comment les données sont stockées
type UserRepository interface {
    Save(user *entity.User) error
    FindByID(id string) (*entity.User, error)
    FindByEmail(email string) (*entity.User, error)
    FindAll(limit, offset int) ([]*entity.User, error)
    Delete(id string) error
}

Use Case Go — Logique applicative

// usecase/user/create_user.go
package user

import (
    "myapp/domain/entity"
    "myapp/domain/repository"
    "errors"
)

type CreateUserInput struct {
    Email string
    Name  string
}

type CreateUserOutput struct {
    User *entity.User
}

type CreateUserUseCase struct {
    userRepo repository.UserRepository // injection par interface
}

func NewCreateUserUseCase(userRepo repository.UserRepository) *CreateUserUseCase {
    return &CreateUserUseCase{userRepo: userRepo}
}

func (uc *CreateUserUseCase) Execute(input CreateUserInput) (*CreateUserOutput, error) {
    // Vérifier si l'email existe déjà
    existing, _ := uc.userRepo.FindByEmail(input.Email)
    if existing != nil {
        return nil, errors.New("un compte existe déjà avec cet email")
    }

    // Créer l'entité (règles métier vérifiées dans NewUser)
    user, err := entity.NewUser(input.Email, input.Name)
    if err != nil {
        return nil, err
    }

    // Persister
    if err := uc.userRepo.Save(user); err != nil {
        return nil, errors.New("erreur lors de la sauvegarde")
    }

    return &CreateUserOutput{User: user}, nil
}

Repository PostgreSQL — Implémentation concrète

// adapter/repository/postgres_user.go
package repository

import (
    "database/sql"
    "myapp/domain/entity"
    "myapp/domain/repository"
)

// PostgresUserRepository implémente repository.UserRepository
type PostgresUserRepository struct {
    db *sql.DB
}

// Vérification à la compilation que l'interface est respectée
var _ repository.UserRepository = (*PostgresUserRepository)(nil)

func NewPostgresUserRepository(db *sql.DB) *PostgresUserRepository {
    return &PostgresUserRepository{db: db}
}

func (r *PostgresUserRepository) Save(user *entity.User) error {
    _, err := r.db.Exec(
        `INSERT INTO users (id, email, name, role, created_at)
         VALUES ($1, $2, $3, $4, $5)
         ON CONFLICT (email) DO UPDATE SET name = $3`,
        user.ID, user.Email, user.Name, user.Role, user.CreatedAt,
    )
    return err
}

func (r *PostgresUserRepository) FindByEmail(email string) (*entity.User, error) {
    row := r.db.QueryRow(
        "SELECT id, email, name, role, created_at FROM users WHERE email = $1", email,
    )
    user := &entity.User{}
    err := row.Scan(&user.ID, &user.Email, &user.Name, &user.Role, &user.CreatedAt)
    if err == sql.ErrNoRows {
        return nil, nil
    }
    return user, err
}

// ... autres méthodes

Handler HTTP Gin — Couche la plus externe

// adapter/handler/user_handler.go
package handler

import (
    "net/http"
    "myapp/usecase/user"
    "github.com/gin-gonic/gin"
)

type UserHandler struct {
    createUser *user.CreateUserUseCase
}

func NewUserHandler(createUser *user.CreateUserUseCase) *UserHandler {
    return &UserHandler{createUser: createUser}
}

func (h *UserHandler) Create(c *gin.Context) {
    var body struct {
        Email string `json:"email" binding:"required,email"`
        Name  string `json:"name" binding:"required,min=2"`
    }

    if err := c.ShouldBindJSON(&body); err != nil {
        c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
        return
    }

    output, err := h.createUser.Execute(user.CreateUserInput{
        Email: body.Email,
        Name:  body.Name,
    })
    if err != nil {
        c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
        return
    }

    c.JSON(http.StatusCreated, gin.H{
        "id":    output.User.ID,
        "email": output.User.Email,
        "name":  output.User.Name,
        "role":  output.User.Role,
    })
}

Architecture Hexagonale (Ports & Adapters)

L'Architecture Hexagonale, créée par Alistair Cockburn, est une variation de la Clean Architecture qui met l'accent sur les "ports" (interfaces) et les "adapters" (implémentations). L'idée : votre application est un hexagone. Tout ce qui entre ou sort passe par un port.

Ports : les interfaces de votre application

  • Primary Ports (ou Driving Ports) : ce que votre app expose (API HTTP, CLI, GraphQL)
  • Secondary Ports (ou Driven Ports) : ce dont votre app dépend (DB, email, paiement)

Adapters : les implémentations concrètes

  • Primary Adapters : HTTP handlers, CLI commands, event listeners
  • Secondary Adapters : PostgreSQL repo, SendGrid email, Stripe payment
// Architecture Hexagonale en Go — Ports & Adapters

// PORT (secondaire) — ce dont l'app dépend
type EmailPort interface {
    Send(to, subject, body string) error
}

type PaymentPort interface {
    Charge(amount int, currency, description string) (string, error)
}

// ADAPTER SendGrid — implémente EmailPort
type SendGridAdapter struct {
    apiKey string
}

func (a *SendGridAdapter) Send(to, subject, body string) error {
    // implémentation SendGrid
    return nil
}

// ADAPTER Stripe — implémente PaymentPort
type StripeAdapter struct {
    secretKey string
}

func (a *StripeAdapter) Charge(amount int, currency, description string) (string, error) {
    // implémentation Stripe
    return "ch_xxx", nil
}

// USE CASE — ne connaît que les ports, pas les adapters
type PlaceOrderUseCase struct {
    orders  OrderRepository
    email   EmailPort    // injection par interface
    payment PaymentPort  // injection par interface
}

func (uc *PlaceOrderUseCase) Execute(input PlaceOrderInput) error {
    // Charger le paiement via le port (Stripe en prod, mock en test)
    chargeID, err := uc.payment.Charge(input.Amount, "EUR", "Commande #"+input.OrderID)
    if err != nil {
        return err
    }

    // Envoyer confirmation via le port (SendGrid en prod, mock en test)
    return uc.email.Send(input.CustomerEmail, "Commande confirmée", "Charge: "+chargeID)
}

Clean Architecture en React / Next.js

Structure React avec Clean Architecture

src/
├── domain/                    # Entités et interfaces
│   ├── entities/
│   │   ├── User.ts
│   │   └── Order.ts
│   └── repositories/
│       ├── IUserRepository.ts
│       └── IOrderRepository.ts
│
├── usecases/                  # Logique applicative
│   ├── user/
│   │   ├── CreateUser.ts
│   │   └── GetUser.ts
│   └── order/
│       └── PlaceOrder.ts
│
├── adapters/                  # Implémentations concrètes
│   ├── repositories/
│   │   ├── ApiUserRepository.ts   # Fetch vers votre API
│   │   └── MockUserRepository.ts  # Pour les tests
│   └── presenters/
│       └── UserPresenter.ts
│
└── ui/                        # Couche React (la plus externe)
    ├── hooks/                 # Pont use cases ↔ composants
    │   ├── useUser.ts
    │   └── useOrder.ts
    └── components/            # Composants purs (pas de logique métier)
        ├── UserCard.tsx
        └── OrderSummary.tsx

Entity TypeScript

// domain/entities/User.ts
export interface User {
  id: string;
  email: string;
  name: string;
  role: 'admin' | 'standard';
  createdAt: Date;
}

export class UserDomain {
  static validate(data: Partial<User>): string[] {
    const errors: string[] = [];
    if (!data.email || !/S+@S+.S+/.test(data.email)) {
      errors.push('Email invalide');
    }
    if (!data.name || data.name.trim().length < 2) {
      errors.push('Nom trop court');
    }
    return errors;
  }

  static canAccessAdmin(user: User): boolean {
    return user.role === 'admin';
  }
}

Repository Interface TypeScript

// domain/repositories/IUserRepository.ts
import { User } from '../entities/User';

export interface IUserRepository {
  findById(id: string): Promise<User | null>;
  findByEmail(email: string): Promise<User | null>;
  save(user: Omit<User, 'id' | 'createdAt'>): Promise<User>;
  delete(id: string): Promise<void>;
}

Adapter API React

// adapters/repositories/ApiUserRepository.ts
import { User } from '@/domain/entities/User';
import { IUserRepository } from '@/domain/repositories/IUserRepository';

export class ApiUserRepository implements IUserRepository {
  private baseUrl: string;

  constructor(baseUrl = '/api') {
    this.baseUrl = baseUrl;
  }

  async findById(id: string): Promise<User | null> {
    const res = await fetch(`${this.baseUrl}/users/${id}`);
    if (res.status === 404) return null;
    if (!res.ok) throw new Error('Erreur serveur');
    const data = await res.json();
    return { ...data, createdAt: new Date(data.createdAt) };
  }

  async save(user: Omit<User, 'id' | 'createdAt'>): Promise<User> {
    const res = await fetch(`${this.baseUrl}/users`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(user),
    });
    if (!res.ok) throw new Error(await res.text());
    return res.json();
  }

  async findByEmail(email: string): Promise<User | null> {
    const res = await fetch(`${this.baseUrl}/users?email=${encodeURIComponent(email)}`);
    const data = await res.json();
    return data[0] ?? null;
  }

  async delete(id: string): Promise<void> {
    await fetch(`${this.baseUrl}/users/${id}`, { method: 'DELETE' });
  }
}

Hook React — Pont entre use case et UI

// ui/hooks/useCreateUser.ts
'use client';
import { useState } from 'react';
import { ApiUserRepository } from '@/adapters/repositories/ApiUserRepository';
import { UserDomain } from '@/domain/entities/User';

export function useCreateUser() {
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState<string | null>(null);

  const repo = new ApiUserRepository();

  const createUser = async (email: string, name: string) => {
    // Validation côté domain (avant tout appel réseau)
    const errors = UserDomain.validate({ email, name });
    if (errors.length > 0) {
      setError(errors.join(', '));
      return null;
    }

    setLoading(true);
    setError(null);
    try {
      const user = await repo.save({ email, name, role: 'standard' });
      return user;
    } catch (e) {
      setError(e instanceof Error ? e.message : 'Erreur inconnue');
      return null;
    } finally {
      setLoading(false);
    }
  };

  return { createUser, loading, error };
}

Les principes SOLID — Base de toute bonne architecture

S — Single Responsibility Principle

Une classe/fonction = une seule raison de changer. Si votre UserService gère les utilisateurs ET envoie des emails ET génère des PDF, c'est une violation du SRP. Découpez en UserService, EmailService, PdfService.

O — Open/Closed Principle

Ouvert à l'extension, fermé à la modification. Vous devez pouvoir ajouter un nouveau type de paiement (Stripe → PayPal) sans modifier le code existant. Utilisez des interfaces pour ça.

L — Liskov Substitution Principle

Un objet de type B (qui hérite de A) doit pouvoir remplacer A sans que le code appelant s'en aperçoive. Vos adapters PostgreSQL et Mock doivent être interchangeables via l'interface UserRepository.

I — Interface Segregation Principle

Plusieurs interfaces spécialisées valent mieux qu'une grosse interface générale. Ne forcez pas un UserRepository à implémenter des méthodes qu'il n'utilise pas.

D — Dependency Inversion Principle

Les modules de haut niveau ne doivent pas dépendre des modules de bas niveau. Les deux doivent dépendre d'abstractions (interfaces). C'est le fondement de la Clean Architecture.
// ❌ VIOLATION DU DIP — dépendance directe vers l'implémentation
type UserService struct {
    repo *PostgresUserRepository // dépend du concret
}

// ✅ RESPECT DU DIP — dépendance vers l'abstraction
type UserService struct {
    repo UserRepository // dépend de l'interface
}

// Injection des dépendances dans main.go
func main() {
    db := setupDB()

    // On injecte l'implémentation concrète à la construction
    userRepo := repository.NewPostgresUserRepository(db)
    userService := usecase.NewUserService(userRepo)
    userHandler := handler.NewUserHandler(userService)

    // En test, on injecte un mock à la place
    // userRepo := &MockUserRepository{}
}

Design Patterns les plus utiles

Repository Pattern

Abstraction de la couche de persistance. Vous parlez à une interface UserRepository, pas à PostgreSQL directement. Permet de changer de base de données sans toucher au code métier.

Factory Pattern

Centralise la création d'objets complexes. Utile pour créer des entités avec validation, ou instancier les bonnes implémentations selon l'environnement (prod vs test).

Observer Pattern (Event-Driven)

Découplement par événements. Quand un utilisateur est créé, émettez un event UserCreated. Plusieurs handlers (email, analytics, notifications) y réagissent indépendamment sans que le use case les connaisse.

Decorator Pattern

Ajoute des comportements sans modifier l'objet de base. Exemple : LoggingUserRepository qui enveloppe PostgresUserRepository et log toutes les opérations.
// Decorator Pattern en Go — ajout de logging sans modifier l'implémentation
type LoggingUserRepository struct {
    inner  UserRepository
    logger *log.Logger
}

func (r *LoggingUserRepository) Save(user *entity.User) error {
    r.logger.Printf("Saving user: %s", user.Email)
    err := r.inner.Save(user)
    if err != nil {
        r.logger.Printf("Error saving user %s: %v", user.Email, err)
    }
    return err
}

func (r *LoggingUserRepository) FindByEmail(email string) (*entity.User, error) {
    r.logger.Printf("Finding user by email: %s", email)
    return r.inner.FindByEmail(email)
}

// Usage dans main.go
postgresRepo := repository.NewPostgresUserRepository(db)
loggedRepo := &LoggingUserRepository{
    inner:  postgresRepo,
    logger: log.New(os.Stdout, "[USER] ", log.LstdFlags),
}
userService := usecase.NewUserService(loggedRepo)

Clean Architecture vs Architecture Hexagonale vs MVC

Ces trois patterns ne sont pas des concurrents — ils répondent à des niveaux différents :
  • MVC (Model-View-Controller) : pattern de présentation, organise le code de l'interface utilisateur. Simple, bien adapté aux petites applications.
  • Architecture Hexagonale : focus sur l'isolation du domaine via des ports et adapters. Variante de la Clean Architecture avec une terminologie différente.
  • Clean Architecture : la plus complète. Définit 4 couches concentriques avec des règles de dépendance strictes. Recommandée pour les applications complexes avec une longue durée de vie.
MVC pour les prototypes et petites apps. Architecture Hexagonale ou Clean Architecture dès que votre app a une logique métier réelle et doit durer plus de 2 ans.

Testabilité — Le vrai bénéfice de la Clean Architecture

Le vrai avantage de la Clean Architecture n'est pas la structure — c'est la testabilité. Quand vos use cases ne dépendent que d'interfaces, vous pouvez les tester sans base de données, sans HTTP, sans aucune infrastructure.
// Test du use case CreateUser sans PostgreSQL
func TestCreateUser_Success(t *testing.T) {
    // Mock repository en mémoire
    mockRepo := &MockUserRepository{users: map[string]*entity.User{}}
    uc := NewCreateUserUseCase(mockRepo)

    output, err := uc.Execute(CreateUserInput{
        Email: "test@example.com",
        Name:  "Jean Dupont",
    })

    assert.NoError(t, err)
    assert.NotNil(t, output.User)
    assert.Equal(t, "test@example.com", output.User.Email)
    assert.Equal(t, entity.RoleStandard, output.User.Role)
}

func TestCreateUser_DuplicateEmail(t *testing.T) {
    existingUser := &entity.User{Email: "exists@example.com", Name: "Existant"}
    mockRepo := &MockUserRepository{
        users: map[string]*entity.User{"exists@example.com": existingUser},
    }
    uc := NewCreateUserUseCase(mockRepo)

    _, err := uc.Execute(CreateUserInput{
        Email: "exists@example.com",
        Name:  "Nouveau",
    })

    assert.Error(t, err)
    assert.Contains(t, err.Error(), "existe déjà")
}

// MockUserRepository pour les tests
type MockUserRepository struct {
    users map[string]*entity.User
}

func (r *MockUserRepository) Save(user *entity.User) error {
    r.users[user.Email] = user
    return nil
}

func (r *MockUserRepository) FindByEmail(email string) (*entity.User, error) {
    if u, ok := r.users[email]; ok {
        return u, nil
    }
    return nil, nil
}

Quand NE PAS utiliser la Clean Architecture

  • MVP et prototypes : trop de structure pour trop peu de logique. Commencez simple, refactorisez ensuite.
  • Scripts one-shot : automatisation simple sans règles métier complexes.
  • Microservices très simples : un service CRUD basique n'a pas besoin de 4 couches.
  • Équipe junior : la courbe d'apprentissage peut ralentir une petite équipe sans expérience en architecture.
La règle générale : si votre application a plus de 5 entités métier avec des règles complexes, et qu'elle doit vivre plus de 2 ans, la Clean Architecture est un investissement rentable. Sinon, partez sur une structure plus simple et refactorisez quand la complexité augmente.

Moussa Gaye

Développeur web freelance — France, Maroc, Sénégal

Travailler ensemble →