Hexagonal Architecture in EAF
Hexagonal Architecture (also known as Ports and Adapters) is a core architectural pattern in the EAF framework, ensuring clean separation of concerns and making applications highly testable and maintainable.
๐ฏ Architecture Overviewโ
The hexagonal architecture organizes code into distinct layers with clear dependencies flowing inward toward the domain core.
graph TB
subgraph "External World"
A[Web UI]
B[Mobile App]
C[External APIs]
D[Database]
E[Message Queue]
F[File System]
end
subgraph "Adapters"
G[REST Controllers]
H[GraphQL Resolvers]
I[Event Handlers]
J[JPA Repositories]
K[NATS Publishers]
L[File Storage]
end
subgraph "Ports"
M[Inbound Ports]
N[Outbound Ports]
end
subgraph "Application Layer"
O[Command Handlers]
P[Query Handlers]
Q[Application Services]
end
subgraph "Domain Layer"
R[Aggregates]
S[Domain Services]
T[Value Objects]
U[Domain Events]
end
A --> G
B --> H
C --> I
G --> M
H --> M
I --> M
M --> O
M --> P
O --> R
P --> R
Q --> R
R --> S
Q --> N
N --> J
N --> K
N --> L
J --> D
K --> E
L --> F
style R fill:#e8f5e8
style S fill:#e8f5e8
style T fill:#e8f5e8
style U fill:#e8f5e8
style M fill:#f0f8ff
style N fill:#f0f8ff
๐๏ธ Implementation in EAFโ
Domain Layer (Core)โ
The domain layer contains business logic and should have no dependencies on external frameworks:
// Domain Entity
@AggregateRoot
class Order(
val id: OrderId,
private val customerId: CustomerId,
private var status: OrderStatus = OrderStatus.DRAFT
) {
private val events = mutableListOf<DomainEvent>()
fun confirm(): Order {
require(status == OrderStatus.DRAFT) { "Order already confirmed" }
status = OrderStatus.CONFIRMED
events.add(OrderConfirmedEvent(id, customerId))
return this
}
}
// Domain Service
@DomainService
class OrderPricingService {
fun calculatePrice(order: Order, discountRules: DiscountRules): Money {
// Pure business logic
return order.items
.sumOf { it.price }
.let { discountRules.apply(it) }
}
}
Application Layerโ
The application layer orchestrates domain operations and coordinates with external systems:
// Application Service
@ApplicationService
class OrderApplicationService(
private val orderRepository: OrderRepository, // Port
private val paymentService: PaymentService, // Port
private val eventPublisher: EventPublisher // Port
) {
suspend fun confirmOrder(command: ConfirmOrderCommand): Result<OrderId> {
return try {
val order = orderRepository.findById(command.orderId)
?: return Result.failure("Order not found")
// Business operation
order.confirm()
// Coordinate with external systems
paymentService.processPayment(order.totalAmount)
orderRepository.save(order)
// Publish domain events
order.getUncommittedEvents().forEach { event ->
eventPublisher.publish(event)
}
Result.success(order.id)
} catch (e: Exception) {
Result.failure(e.message ?: "Unknown error")
}
}
}
Ports (Interfaces)โ
Ports define contracts between layers:
// Inbound Port (driven by external world)
interface OrderCommandHandler {
suspend fun handle(command: ConfirmOrderCommand): Result<OrderId>
suspend fun handle(command: CancelOrderCommand): Result<Unit>
}
// Outbound Port (drives external systems)
interface OrderRepository {
suspend fun findById(id: OrderId): Order?
suspend fun save(order: Order): Order
suspend fun findByCustomerId(customerId: CustomerId): List<Order>
}
interface PaymentService {
suspend fun processPayment(amount: Money): PaymentResult
suspend fun refundPayment(paymentId: PaymentId): RefundResult
}
interface EventPublisher {
suspend fun publish(event: DomainEvent): PublishResult
}
Adapters (Infrastructure)โ
Adapters implement ports and handle external system integration:
// Inbound Adapter - REST Controller
@RestController
@RequestMapping("/api/orders")
class OrderController(
private val orderCommandHandler: OrderCommandHandler
) {
@PostMapping("/{orderId}/confirm")
suspend fun confirmOrder(
@PathVariable orderId: String,
@RequestBody request: ConfirmOrderRequest
): ResponseEntity<OrderResponse> {
val command = ConfirmOrderCommand(OrderId(orderId))
return when (val result = orderCommandHandler.handle(command)) {
is Result.Success -> ResponseEntity.ok(OrderResponse(result.value))
is Result.Failure -> ResponseEntity.badRequest().build()
}
}
}
// Outbound Adapter - JPA Repository
@Repository
class JpaOrderRepository(
private val jpaRepository: SpringDataOrderRepository,
private val orderMapper: OrderMapper
) : OrderRepository {
override suspend fun findById(id: OrderId): Order? = withContext(Dispatchers.IO) {
jpaRepository.findById(id.value)?.let { entity ->
orderMapper.toDomain(entity)
}
}
override suspend fun save(order: Order): Order = withContext(Dispatchers.IO) {
val entity = orderMapper.toEntity(order)
val savedEntity = jpaRepository.save(entity)
orderMapper.toDomain(savedEntity)
}
}
// Outbound Adapter - NATS Event Publisher
@Component
class NatsEventPublisher(
private val natsConnection: Connection
) : EventPublisher {
override suspend fun publish(event: DomainEvent): PublishResult {
return try {
val message = eventSerializer.serialize(event)
natsConnection.publish(event.subject(), message)
PublishResult.Success
} catch (e: Exception) {
PublishResult.Failure(e)
}
}
}
๐งช Testing Benefitsโ
Hexagonal architecture makes testing much easier:
Domain Layer Testingโ
class OrderTest {
@Test
fun `should confirm draft order`() {
// Given
val order = Order(OrderId.generate(), CustomerId.generate())
// When
order.confirm()
// Then
assertThat(order.status).isEqualTo(OrderStatus.CONFIRMED)
assertThat(order.getUncommittedEvents())
.hasSize(1)
.first()
.isInstanceOf(OrderConfirmedEvent::class.java)
}
}
Application Layer Testing with Mocksโ
@ExtendWith(MockKExtension::class)
class OrderApplicationServiceTest {
@MockK private lateinit var orderRepository: OrderRepository
@MockK private lateinit var paymentService: PaymentService
@MockK private lateinit var eventPublisher: EventPublisher
private lateinit var orderService: OrderApplicationService
@BeforeEach
fun setup() {
orderService = OrderApplicationService(orderRepository, paymentService, eventPublisher)
}
@Test
fun `should confirm order successfully`() = runTest {
// Given
val order = Order(OrderId.generate(), CustomerId.generate())
val command = ConfirmOrderCommand(order.id)
coEvery { orderRepository.findById(order.id) } returns order
coEvery { paymentService.processPayment(any()) } returns PaymentResult.Success
coEvery { orderRepository.save(any()) } returns order
coEvery { eventPublisher.publish(any()) } returns PublishResult.Success
// When
val result = orderService.confirmOrder(command)
// Then
assertThat(result.isSuccess).isTrue()
coVerify { orderRepository.findById(order.id) }
coVerify { paymentService.processPayment(any()) }
coVerify { orderRepository.save(any()) }
coVerify { eventPublisher.publish(any()) }
}
}
๐ง Configuration and Dependency Injectionโ
EAF uses Spring Boot's dependency injection to wire adapters to ports:
@Configuration
class OrderConfiguration {
@Bean
fun orderApplicationService(
orderRepository: OrderRepository,
paymentService: PaymentService,
eventPublisher: EventPublisher
): OrderApplicationService = OrderApplicationService(
orderRepository,
paymentService,
eventPublisher
)
@Bean
fun orderCommandHandler(
orderApplicationService: OrderApplicationService
): OrderCommandHandler = orderApplicationService
}
@TestConfiguration
class OrderTestConfiguration {
@Bean
@Primary
fun mockOrderRepository(): OrderRepository = mockk()
@Bean
@Primary
fun mockPaymentService(): PaymentService = mockk()
@Bean
@Primary
fun mockEventPublisher(): EventPublisher = mockk()
}
๐ Best Practicesโ
1. Dependency Directionโ
- Dependencies always point inward toward the domain
- The domain layer has no knowledge of infrastructure
- Application layer defines interfaces (ports) for external dependencies
2. Port Designโ
// โ
Good: Focused, domain-oriented interface
interface CustomerRepository {
suspend fun findById(id: CustomerId): Customer?
suspend fun save(customer: Customer): Customer
suspend fun findActiveCustomers(): List<Customer>
}
// โ Bad: Infrastructure-leaking interface
interface CustomerRepository {
fun findById(id: String): CustomerEntity?
fun save(entity: CustomerEntity): CustomerEntity
fun executeQuery(sql: String): ResultSet
}
3. Error Handlingโ
// โ
Good: Domain-specific errors
sealed class OrderError {
object OrderNotFound : OrderError()
object OrderAlreadyConfirmed : OrderError()
data class PaymentFailed(val reason: String) : OrderError()
}
suspend fun confirmOrder(command: ConfirmOrderCommand): Result<OrderId, OrderError>
// โ Bad: Infrastructure errors leaking
suspend fun confirmOrder(command: ConfirmOrderCommand): Order // throws SQLException
๐ Related Documentationโ
- Domain-Driven Design - Domain modeling patterns
- Test-Driven Development - Testing strategies
- EAF Overview - Framework architecture principles
Hexagonal Architecture in EAF ensures clean separation of concerns, making applications highly testable, maintainable, and adaptable to changing requirements.