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
| Concept | Définition | Exemple |
|---|---|---|
| Entity | Objet avec identité (ID) | User, Order |
| Value Object | Objet sans identité, défini par ses attributs | Email, Money, Address |
| Aggregate | Groupe d'entités avec une racine | Order + OrderItems |
| Aggregate Root | Entité racine de l'aggregate | Order (commande) |
| Domain Event | Événement métier important | OrderPlaced, PaymentReceived |
| Repository | Collection-like d'aggregates | IOrderRepository |
| Domain Service | Logique métier qui ne tient pas dans une entité | PricingService |
| Factory | Création d'objets complexes | OrderFactory |
| Specification | Règle métier réutilisable | CustomerIsEligibleForDiscount |
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)