Aller au contenu
Riadh Mnasri
← Retour au blog
3 min de lecture

DDD vs Clean Architecture vs Hexagonale, code à l'appui

Expliquer la différence entre architecture hexagonale, Clean Architecture et DDD avec des mots suffit rarement à la rendre concrète. Pour archi3, je suis allé plus loin : implémenter le même cas d'usage, passer une commande, trois fois, une fois par approche (hexagonal-order-kt, clean-architecture-order-kt, ddd-order-kt). Le résultat est plus instructif que prévu : deux des trois implémentations du domaine sont quasiment identiques.

Le domaine lui-même change à peine#

Voici Order dans la version hexagonale :

kotlin
class Order private constructor(
    val id: OrderId,
    private val items: List<OrderLine>,
    val status: OrderStatus,
) {
    companion object {
        fun create(id: OrderId, items: List<OrderLine>): Order {
            if (items.isEmpty()) throw EmptyOrderError()
            return Order(id, items, OrderStatus.PENDING)
        }
    }
 
    fun totalAmount(): Money =
        items.fold(Money.zero(items.first().unitPrice.currency)) { sum, line -> sum.add(line.subtotal()) }
}

Et voici la version Clean Architecture, dans le package entities : le même constructeur privé, la même factory create, le même invariant « au moins une ligne ». Le code est identique à quelques imports près. Ce n'est pas une coïncidence : hexagonale et Clean Architecture ne disent rien sur comment modéliser le domaine, elles disent où tracer une frontière technique autour de lui. Le contenu à l'intérieur de cette frontière ne dépend d'aucune des deux.

Ce qui change vraiment : le vocabulaire de la frontière#

Le service applicatif hexagonal :

kotlin
class PlaceOrderService(private val orders: OrderRepository) : PlaceOrder {
    override fun execute(command: PlaceOrderCommand): OrderId {
        val order = Order.create(OrderId.generate(), command.items)
        orders.save(order)
        return order.id
    }
}

L'interactor Clean Architecture :

kotlin
class PlaceOrderInteractor(private val orders: OrderGateway) : PlaceOrder {
    override fun execute(command: PlaceOrderCommand): OrderId {
        val order = Order.create(OrderId.generate(), command.items)
        orders.save(order)
        return order.id
    }
}

OrderRepository et OrderGateway sont le même concept sous deux noms : une interface définie par le cœur métier, implémentée par l'extérieur. Le port sortant hexagonal et le gateway Clean Architecture jouent exactement le même rôle. La vraie différence structurelle n'est pas là.

La différence concrète : un cercle en plus#

Clean Architecture ajoute une couche que la version hexagonale n'isole pas explicitement, interfaceadapters, avec un controller et un presenter dédiés :

kotlin
class PlaceOrderController(private val placeOrder: PlaceOrder) {
    fun handle(request: PlaceOrderRequest): String {
        val command = PlaceOrderCommand(
            items = request.items.map { line ->
                OrderLine(line.productName, line.quantity, Money.of(line.unitPriceInCents, line.currency))
            },
        )
        return placeOrder.execute(command).value
    }
}

Dans la version hexagonale, cette traduction (requête brute vers commande du domaine) se fait directement dans l'adaptateur entrant, sans package dédié. Ce n'est pas une différence de rigueur, c'est une différence de granularité : Clean Architecture nomme explicitement une étape que l'hexagonale laisse à la discrétion de l'adaptateur.

DDD est la seule des trois à changer le modèle lui-même#

C'est là que la troisième implémentation cesse de ressembler aux deux premières. Order en DDD :

kotlin
class Order private constructor(
    val id: OrderId,
    private val items: List<OrderLine>,
    val status: OrderStatus,
) {
    private val pendingEvents = mutableListOf<DomainEvent>()
 
    companion object {
        fun create(id: OrderId, items: List<OrderLine>): Order {
            if (items.isEmpty()) throw EmptyOrderError()
            val order = Order(id, items, OrderStatus.PENDING)
            order.pendingEvents.add(OrderPlaced(id, order.totalAmount()))
            return order
        }
    }
 
    fun pullDomainEvents(): List<DomainEvent> {
        val events = pendingEvents.toList()
        pendingEvents.clear()
        return events
    }
}

Même constructeur privé, même factory, même invariant, mais un concept que ni la version hexagonale ni la version Clean Architecture ne portent : l'agrégat enregistre lui-même l'événement OrderPlaced au moment de sa création, et pullDomainEvents() garantit qu'un événement n'est récupéré qu'une seule fois, comme un vrai message publié une seule fois. Ça confirme ce que la théorie affirme sans toujours convaincre : DDD répond à une question de modélisation, pas de frontière technique, et c'est la seule des trois approches qui ajoute réellement quelque chose au domaine lui-même.

Ce que ça change dans une vraie mission#

Sur MissionMatch, les trois cohabitent parce qu'elles ne se recouvrent presque pas : DDD décide ce que Order sait faire, hexagonale décide comment Order reste isolé du framework, Clean Architecture décide où nommer explicitement chaque étape de traduction. Le code le montre plus clairement que n'importe quelle explication : deux des trois architectures produisent un domaine presque identique, une seule le change vraiment.