MFormations
Modern Go Engineering

Chapitre 7

07 - Architecture Go

07 - Architecture Go

Cours 07 : Architecture Go

1. Clean Architecture en Go

1.1 Les couches

Diagramme en cours de génération...

Principe de dépendance : Les dépendances pointent vers l'intérieur. Le domaine ne dépend de rien.

// Couche Domaine - Pas de dépendances externes
package domain

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

type UserRepository interface {
    FindByID(ctx context.Context, id string) (*User, error)
    Save(ctx context.Context, user *User) error
}

type EmailService interface {
    SendWelcome(ctx context.Context, user *User) error
}

1.2 Use Cases

package usecase

type CreateUserInput struct {
    Name  string
    Email string
}

type CreateUserOutput struct {
    User *domain.User
}

type CreateUserUseCase struct {
    userRepo     domain.UserRepository
    emailService domain.EmailService
}

func NewCreateUserUseCase(
    userRepo domain.UserRepository,
    emailService domain.EmailService,
) *CreateUserUseCase {
    return &CreateUserUseCase{
        userRepo:     userRepo,
        emailService: emailService,
    }
}

func (uc *CreateUserUseCase) Execute(ctx context.Context, input CreateUserInput) (*CreateUserOutput, error) {
    if err := uc.validate(input); err != nil {
        return nil, err
    }

    user := &domain.User{
        ID:        uuid.New().String(),
        Name:      input.Name,
        Email:     input.Email,
        CreatedAt: time.Now(),
    }

    if err := uc.userRepo.Save(ctx, user); err != nil {
        return nil, fmt.Errorf("save user: %w", err)
    }

    if err := uc.emailService.SendWelcome(ctx, user); err != nil {
        // Log error but don't fail the operation
        slog.Error("failed to send welcome email", "error", err)
    }

    return &CreateUserOutput{User: user}, nil
}

func (uc *CreateUserUseCase) validate(input CreateUserInput) error {
    if input.Name == "" {
        return ErrInvalidInput("name is required")
    }
    if !strings.Contains(input.Email, "@") {
        return ErrInvalidInput("invalid email")
    }
    return nil
}

1.3 Repository Implementation

package repository

type PostgresUserRepository struct {
    db *pgxpool.Pool
}

func NewPostgresUserRepository(db *pgxpool.Pool) *PostgresUserRepository {
    return &PostgresUserRepository{db: db}
}

func (r *PostgresUserRepository) FindByID(ctx context.Context, id string) (*domain.User, error) {
    query := `SELECT id, name, email, created_at FROM users WHERE id = $1`
    
    var user domain.User
    err := r.db.QueryRow(ctx, query, id).
        Scan(&user.ID, &user.Name, &user.Email, &user.CreatedAt)
    if err == pgx.ErrNoRows {
        return nil, nil
    }
    return &user, err
}

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

1.4 Handler (Delivery)

package handler

type UserHandler struct {
    createUserUseCase *usecase.CreateUserUseCase
    getUserUseCase    *usecase.GetUserUseCase
}

func NewUserHandler(
    createUserUseCase *usecase.CreateUserUseCase,
    getUserUseCase *usecase.GetUserUseCase,
) *UserHandler {
    return &UserHandler{
        createUserUseCase: createUserUseCase,
        getUserUseCase:    getUserUseCase,
    }
}

func (h *UserHandler) Create(w http.ResponseWriter, r *http.Request) {
    var input usecase.CreateUserInput
    if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
        writeError(w, http.StatusBadRequest, "invalid JSON")
        return
    }

    output, err := h.createUserUseCase.Execute(r.Context(), input)
    if err != nil {
        writeError(w, http.StatusUnprocessableEntity, err.Error())
        return
    }

    writeJSON(w, http.StatusCreated, output.User)
}

2. Architecture Hexagonale (Ports & Adapters)

2.1 Ports (Interfaces)

// Ports - définis par le domaine
type UserPort interface {
    CreateUser(ctx context.Context, input CreateUserInput) (*User, error)
    GetUser(ctx context.Context, id string) (*User, error)
}

type OrderPort interface {
    CreateOrder(ctx context.Context, input CreateOrderInput) (*Order, error)
    GetOrder(ctx context.Context, id string) (*Order, error)
}

// Secondary ports (driven)
type UserRepositoryPort interface {
    FindByID(ctx context.Context, id string) (*User, error)
    Save(ctx context.Context, user *User) error
}

type PaymentProviderPort interface {
    Charge(ctx context.Context, amount Money, token string) (*PaymentResult, error)
    Refund(ctx context.Context, paymentID string) error
}

2.2 Adapters

// Primary adapter (driving) - HTTP
type HTTPUserAdapter struct {
    userPort UserPort
}

func NewHTTPUserAdapter(userPort UserPort) *HTTPUserAdapter {
    return &HTTPUserAdapter{userPort: userPort}
}

func (a *HTTPUserAdapter) HandleCreate(w http.ResponseWriter, r *http.Request) {
    var input CreateUserInput
    json.NewDecoder(r.Body).Decode(&input)
    user, err := a.userPort.CreateUser(r.Context(), input)
    if err != nil {
        writeError(w, 500, err.Error())
        return
    }
    writeJSON(w, 201, user)
}

// Secondary adapter (driven) - PostgreSQL
type PostgresUserAdapter struct {
    db *pgxpool.Pool
}

func NewPostgresUserAdapter(db *pgxpool.Pool) *PostgresUserAdapter {
    return &PostgresUserAdapter{db: db}
}

func (a *PostgresUserAdapter) FindByID(ctx context.Context, id string) (*User, error) {
    // Implémentation PostgreSQL
}

// Secondary adapter - Stripe
type StripePaymentAdapter struct {
    apiKey string
}

func NewStripePaymentAdapter(apiKey string) *StripePaymentAdapter {
    return &StripePaymentAdapter{apiKey: apiKey}
}

func (a *StripePaymentAdapter) Charge(ctx context.Context, amount Money, token string) (*PaymentResult, error) {
    // Implémentation Stripe
}

3. Domain-Driven Design en Go

3.1 Entity

type Order struct {
    ID        string
    CustomerID string
    Items     []OrderItem
    Status    OrderStatus
    Total     Money
    CreatedAt time.Time
    UpdatedAt time.Time
}

type OrderStatus string

const (
    OrderStatusPending   OrderStatus = "pending"
    OrderStatusConfirmed OrderStatus = "confirmed"
    OrderStatusShipped   OrderStatus = "shipped"
    OrderStatusDelivered OrderStatus = "delivered"
    OrderStatusCancelled OrderStatus = "cancelled"
)

func (o *Order) Confirm() error {
    if o.Status != OrderStatusPending {
        return fmt.Errorf("cannot confirm order in status %s", o.Status)
    }
    if len(o.Items) == 0 {
        return fmt.Errorf("cannot confirm order with no items")
    }
    o.Status = OrderStatusConfirmed
    o.UpdatedAt = time.Now()
    return nil
}

func (o *Order) Cancel() error {
    if o.Status == OrderStatusDelivered || o.Status == OrderStatusCancelled {
        return fmt.Errorf("cannot cancel order in status %s", o.Status)
    }
    o.Status = OrderStatusCancelled
    o.UpdatedAt = time.Now()
    return nil
}

3.2 Value Object

type Money struct {
    Amount   int64
    Currency string
}

func NewMoney(amount int64, currency string) (Money, error) {
    if amount < 0 {
        return Money{}, fmt.Errorf("amount must be non-negative")
    }
    if currency == "" {
        return Money{}, fmt.Errorf("currency is required")
    }
    return Money{Amount: amount, Currency: strings.ToUpper(currency)}, nil
}

func (m Money) Add(other Money) (Money, error) {
    if m.Currency != other.Currency {
        return Money{}, fmt.Errorf("currency mismatch: %s vs %s", m.Currency, other.Currency)
    }
    return Money{Amount: m.Amount + other.Amount, Currency: m.Currency}, nil
}

func (m Money) Multiply(factor int64) Money {
    return Money{Amount: m.Amount * factor, Currency: m.Currency}
}

type Email struct {
    address string
}

func NewEmail(address string) (Email, error) {
    if !strings.Contains(address, "@") {
        return Email{}, fmt.Errorf("invalid email: %s", address)
    }
    return Email{address: strings.ToLower(address)}, nil
}

func (e Email) String() string { return e.address }

3.3 Aggregate

type OrderAggregate struct {
    Order   *Order
    Payment *Payment
    Events  []DomainEvent
}

func (a *OrderAggregate) AddItem(product Product, quantity int, price Money) {
    item := OrderItem{
        ProductID: product.ID,
        Quantity:  quantity,
        UnitPrice: price,
    }
    a.Order.Items = append(a.Order.Items, item)
    a.Order.Total = a.calculateTotal()
    
    a.AddEvent(OrderItemAddedEvent{
        OrderID:   a.Order.ID,
        ProductID: product.ID,
        Quantity:  quantity,
    })
}

func (a *OrderAggregate) calculateTotal() Money {
    total := Money{Amount: 0, Currency: "EUR"}
    for _, item := range a.Order.Items {
        total, _ = total.Add(item.UnitPrice.Multiply(int64(item.Quantity)))
    }
    return total
}

3.4 Repository Pattern

type OrderRepository interface {
    Save(ctx context.Context, order *OrderAggregate) error
    FindByID(ctx context.Context, id string) (*OrderAggregate, error)
    FindByCustomer(ctx context.Context, customerID string, page, size int) ([]*OrderAggregate, int, error)
    UpdateStatus(ctx context.Context, id string, status OrderStatus) error
}

4. Dependency Injection

4.1 Manual DI (Constructor Injection)

func main() {
    // 1. Infra
    db := initDatabase()
    cache := initRedis()
    
    // 2. Repositories
    userRepo := repository.NewPostgresUserRepository(db)
    orderRepo := repository.NewPostgresOrderRepository(db)
    
    // 3. Services
    emailSvc := service.NewSESEmailService()
    paymentSvc := service.NewStripePaymentService()
    
    // 4. Use Cases
    createUserUC := usecase.NewCreateUserUseCase(userRepo, emailSvc)
    createOrderUC := usecase.NewCreateOrderUseCase(orderRepo, paymentSvc)
    
    // 5. Handlers
    userHandler := handler.NewUserHandler(createUserUC, getUserUC)
    orderHandler := handler.NewOrderHandler(createOrderUC)
    
    // 6. Router
    r := chi.NewRouter()
    r.Post("/users", userHandler.Create)
    r.Post("/orders", orderHandler.Create)
    
    http.ListenAndServe(":8080", r)
}

4.2 Google Wire

// wire.go
//go:build wireinject

package main

import (
    "github.com/google/wire"
)

func InitializeApp() (*App, error) {
    wire.Build(
        // Database
        initDatabase,
        initRedis,
        
        // Repositories
        repository.NewPostgresUserRepository,
        repository.NewPostgresOrderRepository,
        
        // Services
        service.NewSESEmailService,
        service.NewStripePaymentService,
        
        // Use Cases
        usecase.NewCreateUserUseCase,
        usecase.NewCreateOrderUseCase,
        
        // Handlers
        handler.NewUserHandler,
        handler.NewOrderHandler,
        
        // Router
        NewRouter,
        
        // App
        NewApp,
    )
    return nil, nil
}

// wire_gen.go (généré)
func InitializeApp() (*App, error) {
    db := initDatabase()
    cache := initRedis()
    userRepo := repository.NewPostgresUserRepository(db)
    orderRepo := repository.NewPostgresOrderRepository(db)
    emailSvc := service.NewSESEmailService()
    paymentSvc := service.NewStripePaymentService()
    createUserUC := usecase.NewCreateUserUseCase(userRepo, emailSvc)
    createOrderUC := usecase.NewCreateOrderUseCase(orderRepo, paymentSvc)
    userHandler := handler.NewUserHandler(createUserUC)
    orderHandler := handler.NewOrderHandler(createOrderUC)
    router := NewRouter(userHandler, orderHandler)
    app := NewApp(router)
    return app, nil
}

4.3 Uber Dig

import "go.uber.org/dig"

func BuildContainer() *dig.Container {
    c := dig.New()

    // Provide des constructeurs
    c.Provide(initDatabase)
    c.Provide(repository.NewPostgresUserRepository)
    c.Provide(service.NewSESEmailService)
    c.Provide(usecase.NewCreateUserUseCase)
    c.Provide(handler.NewUserHandler)
    c.Provide(NewRouter)
    c.Provide(NewApp)

    return c
}

func main() {
    container := BuildContainer()
    err := container.Invoke(func(app *App) {
        app.Run()
    })
    if err != nil {
        log.Fatal(err)
    }
}

5. Standard Go Project Layout

5.1 Structure recommandée

myapp/
├── cmd/
│   └── server/
│       └── main.go          # Point d'entrée
├── internal/
│   ├── domain/              # Entités, value objects
│   │   ├── user.go
│   │   └── order.go
│   ├── usecase/             # Cas d'utilisation
│   │   ├── create_user.go
│   │   └── create_order.go
│   ├── repository/          # Implémentations repositories
│   │   └── postgres/
│   │       ├── user.go
│   │       └── order.go
│   ├── handler/             # Handlers HTTP
│   │   ├── user.go
│   │   └── order.go
│   └── service/             # Services externes
│       └── email.go
├── pkg/                     # Librairies partagées
│   ├── middleware/
│   └── validator/
├── migrations/
├── config/
│   └── config.go
├── go.mod
└── go.sum

5.2 Package design principles

// internal - non importable de l'extérieur
// pkg - importable, considéré comme public API
// cmd - points d'entrée (un par binaire)

// Chaque package a une seule responsabilité
package user     // vs package models
package order   // vs package utils
package payment // vs package helpers

6. Erreurs métier

package domain

type DomainError struct {
    Code    string
    Message string
    Err     error
}

func (e *DomainError) Error() string {
    return fmt.Sprintf("[%s] %s", e.Code, e.Message)
}

func (e *DomainError) Unwrap() error {
    return e.Err
}

// Erreurs métier spécifiques
var (
    ErrUserNotFound      = &DomainError{Code: "USER_NOT_FOUND", Message: "user not found"}
    ErrInvalidEmail      = &DomainError{Code: "INVALID_EMAIL", Message: "email format is invalid"}
    ErrInsufficientFunds = &DomainError{Code: "INSUFFICIENT_FUNDS", Message: "insufficient funds"}
    ErrOrderNotCancellable = &DomainError{Code: "ORDER_NOT_CANCELLABLE", Message: "order cannot be cancelled in current status"}
)

func IsDomainError(err error) bool {
    var de *DomainError
    return errors.As(err, &de)
}

func GetDomainError(err error) *DomainError {
    var de *DomainError
    if errors.As(err, &de) {
        return de
    }
    return nil
}

Résumé

  • Clean Architecture : Domain → Use Cases → Repositories → Handlers
  • Hexagonal Architecture : Ports (interfaces) et Adapters (implémentations)
  • DDD : Entity, Value Object, Aggregate, Repository
  • DI : Constructor injection, Wire (code gen), Dig (runtime)
  • Project layout : cmd/internal/pkg, packages par domaine
  • Erreurs métier : DomainError avec code et message