MFormations
Modern Backend Engineering

Chapitre 8

Chapitre 08 — Architecture Backend

Chapitre 08 — Architecture Backend

Cours complet — Architecture Backend

1. Clean Architecture (Robert C. Martin, 2012)

Principe

                    ┌──────────────────────┐
                    │    Frameworks &       │
                    │     Drivers           │
                    │  (HTTP, DB, Queue)    │
                    └──────────┬────────────┘
                               │
                    ┌──────────┴────────────┐
                    │    Interface           │
                    │    Adapters            │
                    │  (Controllers,         │
                    │   Presenters,          │
                    │   Repositories)        │
                    └──────────┬────────────┘
                               │
                    ┌──────────┴────────────┐
                    │    Application         │
                    │    Use Cases           │
                    │  (Services)            │
                    └──────────┬────────────┘
                               │
                    ┌──────────┴────────────┐
                    │    Domain              │
                    │  (Entities,            │
                    │   Value Objects,       │
                    │   Domain Events)       │
                    └───────────────────────┘

Règle de dépendance

  • Les dépendances vont de l'extérieur vers l'intérieur
  • Le code du domaine ne dépend de rien (pas de frameworks, pas de DB)
  • Les interfaces sont définies dans le domaine, implémentées dans l'infrastructure

Structure de projet

src/
  domain/               ← Noyau (aucune dépendance externe)
    entities/
      User.ts
      Order.ts
    value-objects/
      Email.ts
      Money.ts
    events/
      UserCreated.ts
    repositories/       ← Interfaces
      IUserRepository.ts
    services/
      IEmailService.ts

  application/          ← Use cases
    use-cases/
      CreateUser.ts
      PlaceOrder.ts
    dto/
      CreateUserDTO.ts
      PlaceOrderDTO.ts

  infrastructure/       ← Implémentations concrètes
    database/
      PostgresUserRepository.ts
    email/
      SendGridEmailService.ts
    queue/
      RabbitMQEventBus.ts

  presentation/         ← Interface utilisateur
    http/
      controllers/
        UserController.ts
      middleware/
        AuthMiddleware.ts
      routes/
        userRoutes.ts
    graphql/
      resolvers/
        userResolver.ts

Exemple : Entité du domaine

// domain/entities/User.ts
export class User {
  private constructor(
    public readonly id: string,
    public readonly name: string,
    public readonly email: Email,  // Value Object
    public readonly createdAt: Date,
  ) {}

  static create(props: { id: string; name: string; email: string }): User {
    if (props.name.length < 2) {
      throw new DomainError('Name must be at least 2 characters')
    }
    return new User(
      props.id,
      props.name,
      Email.create(props.email),  // Validation dans le VO
      new Date(),
    )
  }

  // Méthode métier (pas simple getter/setter)
  changeName(newName: string): void {
    if (newName.length < 2) throw new DomainError('Invalid name')
    this.name = newName
    // Domaine event
    DomainEvents.raise(new UserNameChanged(this.id, this.name))
  }
}

// domain/value-objects/Email.ts
export class Email {
  private constructor(public readonly value: string) {}

  static create(email: string): Email {
    if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
      throw new DomainError(`Invalid email: ${email}`)
    }
    return new Email(email)
  }

  equals(other: Email): boolean {
    return this.value === other.value
  }
}

2. Domain-Driven Design (DDD)

Concepts clés

ConceptDéfinitionExemple
EntityObjet avec identité (ID)User, Order
Value ObjectObjet sans identité, défini par ses attributsEmail, Money, Address
AggregateGroupe d'entités avec une racineOrder + OrderItems
Aggregate RootEntité racine de l'aggregateOrder (commande)
Domain EventÉvénement métier importantOrderPlaced, PaymentReceived
RepositoryCollection-like d'aggregatesIOrderRepository
Domain ServiceLogique métier qui ne tient pas dans une entitéPricingService
FactoryCréation d'objets complexesOrderFactory
SpecificationRègle métier réutilisableCustomerIsEligibleForDiscount

Exemple : Aggregate Order

// domain/entities/Order.ts
export class Order extends AggregateRoot {
  private items: OrderItem[] = []
  private status: OrderStatus = OrderStatus.PENDING

  private constructor(
    public readonly id: string,
    public readonly customerId: string,
  ) {
    super()
  }

  static create(props: { customerId: string }): Order {
    const order = new Order(uuid(), props.customerId)
    order.addDomainEvent(new OrderCreated(order.id, order.customerId))
    return order
  }

  addItem(productId: string, quantity: number, price: Money): void {
    if (this.status !== OrderStatus.PENDING) {
      throw new DomainError('Cannot modify a non-pending order')
    }
    const existing = this.items.find(i => i.productId === productId)
    if (existing) {
      existing.increaseQuantity(quantity)
    } else {
      this.items.push(new OrderItem(uuid(), productId, quantity, price))
    }
    this.addDomainEvent(new OrderItemAdded(this.id, productId, quantity))
  }

  submit(): void {
    if (this.items.length === 0) {
      throw new DomainError('Cannot submit empty order')
    }
    this.status = OrderStatus.SUBMITTED
    this.addDomainEvent(new OrderSubmitted(this.id, this.total))
  }

  get total(): Money {
    return this.items.reduce(
      (sum, item) => sum.add(item.subtotal),
      Money.ZERO,
    )
  }
}

// domain/value-objects/Money.ts
export class Money {
  constructor(
    public readonly amount: number,
    public readonly currency: string = 'EUR',
  ) {
    if (amount < 0) throw new DomainError('Amount cannot be negative')
  }

  static ZERO = new Money(0)

  add(other: Money): Money {
    if (this.currency !== other.currency) {
      throw new DomainError('Cannot add different currencies')
    }
    return new Money(this.amount + other.amount, this.currency)
  }

  multiply(factor: number): Money {
    return new Money(this.amount * factor, this.currency)
  }
}

3. Hexagonal Architecture (Ports & Adapters)

Principe (Alistair Cockburn, 2005)

  [HTTP Adapter] →───┐              ┌───→ [PostgreSQL Adapter]
  [CLI Adapter]  →───┤   CORE      ├───→ [Redis Adapter]
  [GraphQL]      →───┤  (DOMAIN)   ├───→ [Kafka Adapter]
                     │             │
  [API Port]    ←────┤             ├────→ [Repository Port]
  [UI Port]     ←────┘             └────→ [Message Port]
  • Ports : interfaces définies dans le domaine (ex: IOrderRepository)
  • Adapters : implémentations concrètes (ex: PostgresOrderRepository)
  • Le domaine ne connaît que les ports, pas les adapters
// Port (domain)
interface IOrderRepository {
  save(order: Order): Promise<void>
  findById(id: string): Promise<Order | null>
  findByCustomerId(customerId: string, limit: number, offset: number): Promise<Order[]>
}

// Adapter (infrastructure)
class PostgresOrderRepository implements IOrderRepository {
  constructor(private db: Pool) {}

  async save(order: Order): Promise<void> {
    await this.db.query('BEGIN')
    try {
      await this.db.query(
        'INSERT INTO orders (id, customer_id, status, total, currency) VALUES ($1, $2, $3, $4, $5) ON CONFLICT (id) DO UPDATE SET status = $3',
        [order.id, order.customerId, order.status, order.total.amount, order.total.currency],
      )
      for (const item of order.items) {
        await this.db.query(
          'INSERT INTO order_items (id, order_id, product_id, quantity, unit_price) VALUES ($1, $2, $3, $4, $5)',
          [item.id, order.id, item.productId, item.quantity, item.price.amount],
        )
      }
      await this.db.query('COMMIT')
    } catch (err) {
      await this.db.query('ROLLBACK')
      throw err
    }
  }

  async findById(id: string): Promise<Order | null> {
    const row = await this.db.query('SELECT * FROM orders WHERE id = $1', [id])
    if (row.rows.length === 0) return null
    return this.hydrateOrder(row.rows[0])
  }

  private async hydrateOrder(row: any): Promise<Order> {
    const order = Order.create({ customerId: row.customer_id })
    const items = await this.db.query(
      'SELECT * FROM order_items WHERE order_id = $1',
      [row.id],
    )
    for (const item of items.rows) {
      order.addItem(item.product_id, item.quantity, new Money(item.unit_price))
    }
    return order
  }
}

4. CQRS (Command Query Responsibility Segregation)

Principe

      ┌─────── Client ────────┐
      │                        │
      ▼                        ▼
  [Command]                [Query]
      │                        │
      ▼                        ▼
  [Command Handler]      [Query Handler]
      │                        │
      ▼                        ▼
  [Write DB]              [Read DB]
  (Normalized)           (Denormalized)
  • Command : modifie l'état (CreateOrder, UpdateUser) → return void
  • Query : lit l'état (GetOrder, ListUsers) → return data
  • Le même modèle n'est pas utilisé pour lire et écrire
  • Le read model peut être optimisé pour l'affichage
// Command
export class PlaceOrderCommand implements ICommand {
  constructor(
    public readonly customerId: string,
    public readonly items: Array<{ productId: string; quantity: number }>,
  ) {}
}

// Command Handler
export class PlaceOrderHandler implements ICommandHandler<PlaceOrderCommand> {
  constructor(
    private orderRepo: IOrderRepository,
    private productRepo: IProductRepository,
    private eventBus: IEventBus,
  ) {}

  async handle(command: PlaceOrderCommand): Promise<void> {
    const order = Order.create({ customerId: command.customerId })

    for (const item of command.items) {
      const product = await this.productRepo.findById(item.productId)
      order.addItem(product.id, item.quantity, product.price)
    }

    order.submit()
    await this.orderRepo.save(order)
    await this.eventBus.publish(new OrderPlacedEvent(order))
  }
}

// Query
export class GetOrderQuery implements IQuery {
  constructor(public readonly orderId: string) {}
}

// Query Handler (lecture du read model)
export class GetOrderHandler implements IQueryHandler<GetOrderQuery, OrderDTO> {
  constructor(private readDb: Pool) {}

  async handle(query: GetOrderQuery): Promise<OrderDTO> {
    const row = await this.readDb.query(
      `SELECT o.id, o.customer_id, u.name as customer_name,
              o.status, o.total, o.currency, o.created_at,
              json_agg(json_build_object(
                'product_id', oi.product_id,
                'product_name', p.name,
                'quantity', oi.quantity,
                'unit_price', oi.unit_price
              )) as items
       FROM orders o
       JOIN users u ON u.id = o.customer_id
       JOIN order_items oi ON oi.order_id = o.id
       JOIN products p ON p.id = oi.product_id
       WHERE o.id = $1
       GROUP BY o.id, u.name`,
      [query.orderId],
    )

    if (row.rows.length === 0) return null
    return row.rows[0] as OrderDTO
  }
}

5. Event Sourcing

Principe

┌──────────────────────────────────────────────────┐
│                Event Store                        │
├──────────────────────────────────────────────────┤
│ Event 1: OrderCreated                            │
│ Event 2: OrderItemAdded(productId: "abc", qty:2)│
│ Event 3: PaymentReceived(amount: 99.99)          │
│ Event 4: OrderShipped(tracking: "TRK123")        │
│ ...                                              │
└──────────────────────────────────────────────────┘
         ↓ Rejouer les événements
    [Current State: Order]
  • Au lieu de stocker l'état, on stocke les événements
  • L'état courant est reconstruit en rejouant les événements
  • Append-only : on ne supprime/modifie jamais un événement
  • Snapshot : sauvegarde périodique de l'état pour éviter de tout rejouer
// Événements
export class OrderCreated implements IDomainEvent {
  constructor(
    public readonly orderId: string,
    public readonly customerId: string,
    public readonly occurredAt: Date = new Date(),
  ) {}
}

export class OrderItemAdded implements IDomainEvent {
  constructor(
    public readonly orderId: string,
    public readonly productId: string,
    public readonly quantity: number,
    public readonly price: number,
  ) {}
}

export class OrderPaid implements IDomainEvent {
  constructor(
    public readonly orderId: string,
    public readonly amount: number,
  ) {}
}

// Aggregate avec Event Sourcing
export class Order extends EventSourcedAggregate {
  public status: OrderStatus = OrderStatus.PENDING
  public items: OrderItem[] = []
  public totalPaid: number = 0

  // Reconstruire depuis les événements
  static loadFromHistory(events: IDomainEvent[]): Order {
    const order = new Order()
    for (const event of events) {
      order.apply(event)
    }
    order.clearEvents()
    return order
  }

  apply(event: IDomainEvent): void {
    switch (event.constructor) {
      case OrderCreated:
        const e1 = event as OrderCreated
        this.id = e1.orderId
        this.customerId = e1.customerId
        break
      case OrderItemAdded:
        const e2 = event as OrderItemAdded
        this.items.push(new OrderItem(e2.productId, e2.quantity))
        break
      case OrderPaid:
        this.totalPaid += (event as OrderPaid).amount
        if (this.totalPaid >= this.total) {
          this.status = OrderStatus.PAID
        }
        break
    }
  }

  addItem(productId: string, quantity: number): void {
    this.raise(new OrderItemAdded(this.id, productId, quantity))
  }
}

// Event Store
class PostgresEventStore implements IEventStore {
  async save(aggregateId: string, events: IDomainEvent[], expectedVersion: number): Promise<void> {
    const client = await this.pool.connect()
    try {
      await client.query('BEGIN')
      // Vérifier la version (optimistic concurrency)
      const version = await client.query(
        'SELECT version FROM aggregates WHERE id = $1 FOR UPDATE',
        [aggregateId],
      )
      if (version.rows.length > 0 && version.rows[0].version !== expectedVersion) {
        throw new ConcurrencyError('Optimistic lock failed')
      }

      for (const event of events) {
        await client.query(
          'INSERT INTO events (aggregate_id, event_type, data, version) VALUES ($1, $2, $3, $4)',
          [aggregateId, event.constructor.name, JSON.stringify(event), expectedVersion + 1],
        )
      }

      await client.query(
        `INSERT INTO aggregates (id, version) VALUES ($1, $2)
         ON CONFLICT (id) DO UPDATE SET version = $2`,
        [aggregateId, expectedVersion + events.length],
      )
      await client.query('COMMIT')
    } catch (err) {
      await client.query('ROLLBACK')
      throw err
    }
  }
}

6. Saga Pattern

Problème : Transactions distribuées

Dans une architecture microservices, une seule opération métier peut traverser plusieurs services. Saga permet de gérer la cohérence sans transaction distribuée (2PC).

Choreography Saga (décentralisé)

Order Service → OrderCreated → Payment Service → PaymentReceived → Shipping Service
                      ↑                                      ↓
                  (rollback)                          (rollback si échec)
                      ↑                                      ↓
                 Refund Payment                        Cancel Shipping
// Choreography : chaque service écoute et réagit
class OrderService {
  async createOrder(command: CreateOrder) {
    const order = await this.orderRepo.save(command)
    await this.eventBus.publish(new OrderCreated(order.id, order.customerId, order.total))
  }

  // Écouter les événements des autres services
  async onPaymentFailed(event: PaymentFailed) {
    await this.orderRepo.cancel(event.orderId)
    await this.eventBus.publish(new OrderCancelled(event.orderId, 'Payment failed'))
  }
}

class PaymentService {
  async onOrderCreated(event: OrderCreated) {
    try {
      await this.processPayment(event.orderId, event.amount)
      await this.eventBus.publish(new PaymentSucceeded(event.orderId))
    } catch (err) {
      await this.eventBus.publish(new PaymentFailed(event.orderId, err.message))
    }
  }
}

Orchestration Saga (centralisé)

Saga Orchestrator
  ├── Step 1: Create Order → Order Service
  ├── Step 2: Process Payment → Payment Service
  ├── Step 3: Reserve Inventory → Inventory Service
  ├── Step 4: Confirm Shipping → Shipping Service
  │
  └── En cas d'échec : compenser les steps déjà réussis
       ├── Compensate Step 3: Release Inventory
       ├── Compensate Step 2: Refund Payment
       └── Compensate Step 1: Cancel Order
class CreateOrderSaga {
  private state: SagaState = { status: 'PENDING', compensations: [] }

  async execute(command: CreateOrderSagaCommand) {
    try {
      // Step 1
      const order = await this.orderClient.create(command.customerId)
      this.state.compensations.push(() => this.orderClient.cancel(order.id))

      // Step 2
      const payment = await this.paymentClient.process(order.id, command.total)
      this.state.compensations.push(() => this.paymentClient.refund(payment.id))

      // Step 3
      const reservation = await this.inventoryClient.reserve(command.items)
      this.state.compensations.push(() => this.inventoryClient.release(reservation.id))

      // Step 4
      const shipment = await this.shippingClient.ship(order.id, command.address)
      this.state.compensations.push(() => this.shippingClient.cancel(shipment.id))

      this.state.status = 'COMPLETED'
      return { orderId: order.id, shipmentId: shipment.id }

    } catch (err) {
      this.state.status = 'COMPENSATING'
      // Compenser dans l'ordre inverse
      for (const compensate of this.state.compensations.reverse()) {
        try {
          await compensate()
        } catch (compErr) {
          console.error('Compensation failed:', compErr)
          // Log pour correction manuelle
        }
      }
      this.state.status = 'FAILED'
      throw new SagaFailedError(err.message)
    }
  }
}

7. Dependency Injection

Pattern

// 1. Wire tout manuellement (Composition Root)
class CompositionRoot {
  static createOrderModule(): {
    handler: PlaceOrderHandler
    controller: OrderController
  } {
    const db = new Pool(process.env.DATABASE_URL)
    const cache = new Redis(process.env.REDIS_URL)
    const eventBus = new RabbitMQEventBus(process.env.RABBITMQ_URL)

    const orderRepo = new PostgresOrderRepository(db)
    const productRepo = new CachedProductRepository(
      new PostgresProductRepository(db),
      new RedisCache(cache),
    )

    const handler = new PlaceOrderHandler(orderRepo, productRepo, eventBus)
    const controller = new OrderController(handler)

    return { handler, controller }
  }
}

// 2. Container (InversifyJS / tsyringe / NestJS)
import { Container, injectable, inject } from 'tsyringe'

@injectable()
class PlaceOrderHandler {
  constructor(
    @inject('IOrderRepository') private orderRepo: IOrderRepository,
    @inject('IProductRepository') private productRepo: IProductRepository,
    @inject('IEventBus') private eventBus: IEventBus,
  ) {}
}

const container = new Container()
container.register<IOrderRepository>('IOrderRepository', { useClass: PostgresOrderRepository })
container.register<IProductRepository>('IProductRepository', { useClass: CachedProductRepository })
container.register<IEventBus>('IEventBus', { useClass: RabbitMQEventBus })

8. Middleware Chains

Pattern

type NextFunction = () => Promise<void>
type Middleware<T> = (context: T, next: NextFunction) => Promise<void>

class Pipeline<T> {
  private middlewares: Middleware<T>[] = []

  use(mw: Middleware<T>): this {
    this.middlewares.push(mw)
    return this
  }

  async execute(context: T): Promise<void> {
    let index = 0
    const next = async () => {
      if (index < this.middlewares.length) {
        const mw = this.middlewares[index++]
        await mw(context, next)
      }
    }
    await next()
  }
}

// Usage
const pipeline = new Pipeline<CommandContext>()

pipeline.use(async (ctx, next) => {
  console.time(`command:${ctx.command.constructor.name}`)
  await next()
  console.timeEnd(`command:${ctx.command.constructor.name}`)
})

pipeline.use(async (ctx, next) => {
  if (!ctx.user) throw new AuthorizationError('Not authenticated')
  await next()
})

pipeline.use(async (ctx, next) => {
  // Transaction middleware
  await ctx.unitOfWork.start()
  try {
    await next()
    await ctx.unitOfWork.commit()
  } catch (err) {
    await ctx.unitOfWork.rollback()
    throw err
  }
})

// Handler
pipeline.use(async (ctx) => {
  ctx.result = await ctx.handler.handle(ctx.command)
})

await pipeline.execute(new CommandContext(command, user, handler))

Références

  • Clean Architecture (Robert C. Martin)
  • Domain-Driven Design (Eric Evans)
  • Implementing Domain-Driven Design (Vaughn Vernon)
  • Microservices Patterns (Chris Richardson)
  • Building Event-Driven Microservices (Adam Bellemare)