Concepts fondamentaux
Cette page explique les principes fondamentaux utilisés dans Structus et comment ils s'intègrent dans une architecture propre.
Domain-Driven Design (DDD)
Le Domain-Driven Design est une approche qui place le domaine métier au centre du développement logiciel. Structus fournit les blocs de construction pour implémenter le DDD en Kotlin.
Entité vs Objet Valeur
Entité
Une entité est un objet avec une identité qui persiste à travers le temps, même si ses attributs changent.
abstract class Entity<ID : Any>(open val id: ID)
Caractéristiques des entités:
- Identité unique (généralement via un ID)
- Peut être modifiée au fil du temps
- Égalité basée sur l'identité, pas sur les attributs
Exemple:
class User(override val id: UserId, var email: Email) : Entity<UserId>(id)
Objet Valeur
Un objet valeur est immuable et n'a pas d'identité. Il est défini entièrement par ses attributs.
interface ValueObject
Caractéristiques des objets valeur:
- Immuables (généralement des data classes)
- Pas d'identité distincte
- Égalité basée sur les attributs
- Validation dans le constructeur
Exemple:
data class Email(val value: String) : ValueObject {
init {
require(value.matches(EMAIL_REGEX)) { "Email invalide" }
}
}
Agrégat Racine
Un agrégat est un groupe d'objets traités comme une unité. L'agrégat racine est le point d'entrée de l'agrégat.
abstract class AggregateRoot<ID : Any> : Entity<ID> {
private val _domainEvents = mutableListOf<DomainEvent>()
val domainEvents: List<DomainEvent> get() = _domainEvents
fun recordEvent(event: DomainEvent) {
_domainEvents.add(event)
}
fun clearEvents() {
_domainEvents.clear()
}
}
Caractéristiques des agrégats racine:
- Maintien des invariants métier
- Point d'entrée pour toutes les modifications
- Enregistrement des événements de domaine
- Frontière de transaction
Exemple:
class Order(override val id: OrderId) : AggregateRoot<OrderId>() {
private val _items = mutableListOf<OrderItem>()
val items: List<OrderItem> get() = _items
fun addItem(item: OrderItem) {
_items.add(item)
recordEvent(OrderItemAddedEvent(id.value, item.productId))
}
}
Événements de domaine
Les événements de domaine représentent quelque chose qui s'est produit dans le domaine.
interface DomainEvent {
val eventId: String
val occurredOn: Instant
val aggregateId: String
val eventType: String
}
Caractéristiques des événements de domaine:
- Immuables
- Décrivent un fait passé
- Contiennent toutes les informations pertinentes
- Nommés au passé (Created, Updated, Deleted)
Exemple:
data class OrderCreatedEvent(
override val aggregateId: String,
val customerId: String,
override val occurredOn: Instant = Instant.now(),
override val eventId: String = UUID.randomUUID().toString(),
override val eventType: String = "OrderCreated"
) : DomainEvent
Repository (Dépôt)
Un repository encapsule la logique pour récupérer et stocker des agrégats.
interface Repository<T : AggregateRoot<ID>, ID : Any>
interface CommandRepository<T : AggregateRoot<ID>, ID : Any> : Repository<T, ID>
interface QueryRepository<T : AggregateRoot<ID>, ID : Any> : Repository<T, ID>
Caractéristiques des repositories:
- Interface définie dans la couche domaine
- Implémentation dans la couche infrastructure
- Ressemble à une collection d'objets
- Cache les détails de la persistance
Exemple:
interface OrderRepository : Repository {
suspend fun findById(id: OrderId): Order?
suspend fun save(order: Order)
suspend fun delete(id: OrderId)
}
Command Query Separation (CQS)
CQS sépare les opérations qui modifient l'état (commandes) de celles qui lisent l'état (requêtes).
Commande vs Requête (CQS)
Commande
Une commande est une intention de changer l'état du système.
interface Command
Caractéristiques des commandes:
- Modifie l'état
- Pas de retour de données (sauf confirmation)
- Nommée à l'impératif (CreateOrder, UpdateUser)
- Validée avant traitement
Exemple:
data class CreateOrderCommand(
val customerId: String,
val items: List<OrderItemDto>
) : Command
Requête
Une requête est une demande de données sans effet secondaire.
interface Query
Caractéristiques des requêtes:
- Lecture seule
- Retourne des données
- Pas d'effet secondaire
- Optimisée pour la lecture
Exemple:
data class GetOrderByIdQuery(val orderId: String) : Query
Gestionnaires de commandes
Les gestionnaires de commandes contiennent la logique pour traiter une commande.
interface CommandHandler<C : Command, R> {
suspend operator fun invoke(command: C): R
}
Caractéristiques des gestionnaires de commandes:
- Traitent une seule commande
- Contiennent la logique d'application
- Orchestrent les opérations de domaine
- G èrent la persistance et les événements
Exemple:
class CreateOrderCommandHandler(
private val orderRepository: OrderRepository,
private val customerRepository: CustomerRepository,
private val outboxRepository: MessageOutboxRepository
) : CommandHandler<CreateOrderCommand, Result<OrderId>> {
override suspend operator fun invoke(command: CreateOrderCommand): Result<OrderId> {
// Logique d'implémentation
}
}
Gestionnaires de requêtes
Les gestionnaires de requêtes traitent les requêtes et retournent des données.
interface QueryHandler<Q : Query, R> {
suspend operator fun invoke(query: Q): R
}
Caractéristiques des gestionnaires de requêtes:
- Lecture seule
- Optimisés pour la performance
- Peuvent contourner le modèle de domaine
- Retournent des DTOs
Exemple:
class GetOrderByIdQueryHandler(
private val orderRepository: OrderQueryRepository
) : QueryHandler<GetOrderByIdQuery, OrderDto?> {
override suspend operator fun invoke(query: GetOrderByIdQuery): OrderDto? {
// Logique d'implémentation
}
}
Event Publishing (Publication d'événements)
Les événements de domaine doivent être publiés de manière fiable.
Message Outbox (Boîte de messages sortants)
Le pattern Outbox transactionnel garantit une publication fiable des événements.
interface MessageOutboxRepository : Repository {
suspend fun save(event: DomainEvent)
suspend fun findUnpublished(limit: Int): List<OutboxMessage>
suspend fun markAsPublished(messageId: String)
}
Caractéristiques du pattern Outbox:
- Atomicité avec les changements de domaine
- Assurance de livraison des événements
- Cohérence éventuelle
- Transactions isolées
Exemple:
override suspend operator fun invoke(command: CreateOrderCommand): Result<OrderId> {
return runCatching {
database.transaction {
// 1. Créer et sauvegarder l'agrégat
val order = Order.create(...)
orderRepository.save(order)
// 2. Sauvegarder les événements dans l'outbox (même transaction)
order.domainEvents.forEach { outboxRepository.save(it) }
// 3. Effacer les événements de l'agrégat
order.clearEvents()
order.id
}
}
}
Publication d'événements
Les événements sont publiés de manière asynchrone depuis l'outbox.
interface DomainEventPublisher {
suspend fun publish(event: DomainEvent)
}
Caractéristiques de la publication d'événements:
- Asynchrone
- Idempotente
- Gestion des erreurs et réessais
- Livraison au moins une fois
Architecture en couches
Structus encourage une architecture en couches propre:
┌─────────────────────────────────────────┐
│ Couche Présentation │
│ (Contrôleurs, DTOs, APIs REST, etc.) │
└─────────────────────────────────────────┘
↓ dépend de
┌─────────────────────────────────────────┐
│ Couche Application │
│ (Commandes, Requêtes, Gestionnaires) │
└─────────────────────────────────────────┘
↓ dépend de
┌─────────────────────────────────────────┐
│ Couche Domaine │
│ (Entités, Objets Valeur, Repositories) │
└─────────────────────────────────────────┘
↑ implémente
┌─────────────────────────────────────────┐
│ Couche Infrastructure │
│ (Persistance, API externes, etc.) │
└─────────────────────────────────────────┘
Règles de dépendance
- La couche domaine ne dépend de rien d'autre
- La couche application dépend uniquement du domaine
- La couche présentation dépend de l'application et du domaine
- La couche infrastructure dépend de tout, mais est utilisée indirectement
Ces règles maintiennent la couche domaine pure et testable.
Prochaines étapes
- Architecture - Vue d'ensemble de l'architecture
- Implémentation CQRS - Détails sur l'implémentation CQRS
- Pattern Outbox Transactionnel - Fiabilité des événements