DDD vs Clean Architecture vs Hexagonal, code included

Explaining the difference between hexagonal architecture, Clean Architecture, and DDD in words rarely makes it concrete enough. For archi3, I went further: implementing the same use case, placing an order, three times, once per approach (hexagonal-order-kt, clean-architecture-order-kt, ddd-order-kt). The result is more instructive than expected: two of the three domain implementations turn out nearly identical.
The domain itself barely changes#
Here's Order in the hexagonal version:
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()) }
}And here's the Clean Architecture version, in the entities package: the same private constructor, the same create factory, the same "at least one line" invariant. The code is identical down to a handful of imports. That isn't a coincidence: hexagonal and Clean Architecture say nothing about how to model the domain, they say where to draw a technical boundary around it. What sits inside that boundary depends on neither.
What actually changes: the boundary's vocabulary#
The hexagonal application service:
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
}
}The Clean Architecture interactor:
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 and OrderGateway are the same concept under two names: an interface defined by the business core, implemented from the outside. The hexagonal driven port and the Clean Architecture gateway play exactly the same role. The real structural difference isn't there.
The concrete difference: one extra circle#
Clean Architecture adds a layer the hexagonal version doesn't explicitly isolate, interfaceadapters, with a dedicated controller and presenter:
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
}
}In the hexagonal version, this translation (raw request to domain command) happens directly inside the driving adapter, with no dedicated package. That isn't a difference in rigor, it's a difference in granularity: Clean Architecture explicitly names a step the hexagonal approach leaves to the adapter's discretion.
DDD is the only one of the three that changes the model itself#
This is where the third implementation stops looking like the first two. Order in DDD:
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
}
}Same private constructor, same factory, same invariant, but a concept neither the hexagonal nor the Clean Architecture version carries: the aggregate registers the OrderPlaced event itself at creation time, and pullDomainEvents() guarantees an event is only retrieved once, the same way a real message would only be published once. That confirms something theory claims but doesn't always land: DDD answers a modeling question, not a boundary question, and it's the only one of the three approaches that actually adds something to the domain itself.
What this changes on a real engagement#
On MissionMatch, all three coexist because they barely overlap: DDD decides what Order knows how to do, hexagonal decides how Order stays isolated from the framework, Clean Architecture decides where to explicitly name each translation step. The code shows it more clearly than any explanation could: two of the three architectures produce an almost identical domain, only one actually changes it.


