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