AIDE vs Hexagonal Architecture (Ports & Adapters)¶
1) Overview¶
Hexagonal Architecture describes business logic as the center, connected to outside systems through Ports and Adapters.
AIDE is aligned with this pattern, but it usually places the port/adapter behavior inside each Feature boundary using two concrete files:
- Store.kt (state persistence and external service execution)
- Handler.kt (event/input boundary, route/command adaptation)
This makes the architecture easier to apply in AI-oriented teams where tooling and third-party interfaces change fast.
2) Core Philosophy Comparison¶
| Axis | Hexagonal Architecture | AIDE |
|---|---|---|
| Core idea | Domain core independent from external systems | Domain logic independent and colocated by feature boundary |
| Port concept | Explicit interfaces for input/output | |
| Input/Output mapping | Dedicated adapter layers | Handler.kt + small adapters embedded in feature |
| Dependency direction | Ports and use cases isolate infrastructure | Feature-local dependency graph with explicit boundary files |
| AI-era volatility handling | Strong if ports are well-defined | Stronger because volatile adapters are physically centralized in fewer files |
Context Budget and Locality as shared axis¶
- AIDE keeps port-like contracts close to behavior, reducing the number of files needed to understand one change.
- For AI/agent workflows, context budget is consumed mainly by
Types.ktandLogic.kt; adapters are moved to feature edges. - This is a direct fit with hexagonal intent: outside world can change without affecting core behavior.
Where they differ¶
- Hexagonal often keeps adapters as separate folders (
adapters/in,adapters/out). - AIDE keeps adapter semantics explicit but merges many external touchpoints into
Handler.ktandStore.ktnear the feature domain.
3) Structural Comparison¶
flowchart LR
subgraph HEX["Hexagonal Architecture"]
direction TB
subgraph PORTS["Ports"]
IP["Input Port (UseCase API)"]
OP["Output Port (Repository/Infra API)"]
end
subgraph ADAPTERS["Adapters"]
DIR_IN["Incoming Adapter (HTTP/Queue/Event)"]
DIR_OUT_DB["Outgoing Adapter (DB)"]
DIR_OUT_TOOL["Outgoing Adapter (LLM/Tool/API)"]
end
APP["Application / Core"]
DOMAIN["Domain Model"]
DIR_IN --> APP
APP --> IP
APP --> OP
OP --> DIR_OUT_DB
OP --> DIR_OUT_TOOL
APP --> DOMAIN
end
subgraph AIDE["AIDE Feature"]
direction TB
subgraph F["features/order/"]
TYPES["Types.kt"]
LOGIC["Logic.kt"]
STORE["Store.kt"]
HANDLER["Handler.kt"]
TYPES --> LOGIC
LOGIC --> STORE
LOGIC --> HANDLER
STORE --> HANDLER
end
HANDLER --> IN["HTTP / Queue / Event Source"]
STORE --> DB["DB"]
STORE --> LLM["LLM/Tools"]
STORE --> API["External APIs"]
end
HEX -. "same intent" .- AIDE
style HEX fill:#fff4ec,color:#000
style AIDE fill:#eef7ff,color:#000
4) Port/Adapter vs AIDE Internal Structure¶
In Hexagonal terms¶
- Input port = interface used by external callers to trigger use case.
- Output port = interface used by use case to reach side effects.
- Adapter = concrete bridge between port and runtime tech.
In AIDE terms¶
- Input boundary (input adaptation) →
Handler.kt - Parses request/input payload
- Maps to feature command / value objects
- Performs protocol-specific status/error mapping
- Output boundary (output adaptation) →
Store.kt - Converts pure outputs into persistence or integration operations
- Contains anti-corruption mapping with DB, LLM, and tool interfaces
Why this matters for AI-era systems¶
LLM, tool, and DB integrations are now frequently replaceable.
In AIDE, because all external bindings are pushed into Store.kt and Handler.kt:
- A provider swap changes a small number of files.
- The feature's core logic stays untouched.
- Test strategy stays stable because
Logic.ktremains unit-testable.
This means Port/Adapter value is not reduced; it is amplified under volatility.
5) Kotlin Example¶
Hexagonal (classic)¶
// src/main/kotlin/com/example/hexagonal/order/domain/model/Order.kt
package com.example.hexagonal.order.domain.model
data class PlaceOrderCommand(
val orderId: String,
val cartId: String,
)
data class PlaceOrderResult(
val invoiceId: String,
)
data class OrderSnapshot(
val orderId: String,
val cartId: String,
val summary: String,
)
// src/main/kotlin/com/example/hexagonal/order/application/port/OrderPorts.kt
package com.example.hexagonal.order.application.port
import com.example.hexagonal.order.domain.model.OrderSnapshot
interface LLMPort {
fun summarize(text: String): String
}
interface OrderRepositoryPort {
fun save(order: OrderSnapshot)
}
interface PlaceOrderUseCase {
fun execute(command: com.example.hexagonal.order.domain.model.PlaceOrderCommand): com.example.hexagonal.order.domain.model.PlaceOrderResult
}
// src/main/kotlin/com/example/hexagonal/order/application/usecase/PlaceOrderService.kt
package com.example.hexagonal.order.application.usecase
import com.example.hexagonal.order.application.port.LLMPort
import com.example.hexagonal.order.application.port.OrderRepositoryPort
import com.example.hexagonal.order.application.port.PlaceOrderUseCase
import com.example.hexagonal.order.domain.model.OrderSnapshot
import com.example.hexagonal.order.domain.model.PlaceOrderCommand
import com.example.hexagonal.order.domain.model.PlaceOrderResult
import org.springframework.stereotype.Service
@Service
class PlaceOrderService(
private val llmPort: LLMPort,
private val orderRepository: OrderRepositoryPort,
) : PlaceOrderUseCase {
override fun execute(command: PlaceOrderCommand): PlaceOrderResult {
val summary = llmPort.summarize("order=${command.orderId}")
orderRepository.save(
OrderSnapshot(
orderId = command.orderId,
cartId = command.cartId,
summary = summary,
),
)
return PlaceOrderResult(invoiceId = "inv-${command.orderId}")
}
}
// src/main/kotlin/com/example/hexagonal/order/adapter/out/db/OrderRepositoryAdapter.kt
package com.example.hexagonal.order.adapter.out.db
import com.example.hexagonal.order.application.port.OrderRepositoryPort
import com.example.hexagonal.order.domain.model.OrderSnapshot
import org.springframework.stereotype.Repository
@Repository
class OrderRepositoryAdapter : OrderRepositoryPort {
override fun save(order: OrderSnapshot) {
// TODO: replace with persistence logic
}
}
// src/main/kotlin/com/example/hexagonal/order/adapter/out/llm/OpenAILLMAdapter.kt
package com.example.hexagonal.order.adapter.out.llm
import com.example.hexagonal.order.application.port.LLMPort
import org.springframework.stereotype.Component
@Component
class OpenAILLMAdapter : LLMPort {
override fun summarize(text: String): String {
return "summary: $text"
}
}
// src/main/kotlin/com/example/hexagonal/order/adapter/in/web/PlaceOrderController.kt
package com.example.hexagonal.order.adapter.`in`.web
import com.example.hexagonal.order.application.usecase.PlaceOrderService
import com.example.hexagonal.order.domain.model.PlaceOrderCommand
import com.example.hexagonal.order.domain.model.PlaceOrderResult
import jakarta.validation.constraints.NotBlank
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
@RestController
@RequestMapping("/orders")
class PlaceOrderController(
private val placeOrderService: PlaceOrderService,
) {
@PostMapping
fun placeOrder(@RequestBody request: PlaceOrderRequest): ResponseEntity<PlaceOrderResult> {
return ResponseEntity.ok(placeOrderService.execute(request.toCommand()))
}
}
data class PlaceOrderRequest(
@field:NotBlank
val orderId: String,
@field:NotBlank
val cartId: String,
) {
fun toCommand() = PlaceOrderCommand(orderId = orderId, cartId = cartId)
}
AIDE equivalent¶
// src/main/kotlin/com/example/aaa/order/features/order/types.kt
package com.example.aaa.order.features.order
import jakarta.validation.constraints.NotBlank
data class OrderCommand(
val orderId: String,
val cartId: String,
)
data class PlaceOrderInvoice(
val invoiceId: String,
)
data class PersistedOrder(
val orderId: String,
val cartId: String,
val summary: String,
)
sealed interface OrderPortError {
data class InvalidInput(val reason: String) : OrderPortError
}
interface OrderPorts {
fun summarize(text: String): String
fun save(input: PersistedOrder)
}
fun placeOrderUseCase(command: OrderCommand, ports: OrderPorts): PlaceOrderInvoice {
val summary = ports.summarize("order=${command.orderId}")
ports.save(
PersistedOrder(
orderId = command.orderId,
cartId = command.cartId,
summary = summary,
),
)
return PlaceOrderInvoice(invoiceId = "inv-${command.orderId}")
}
// src/main/kotlin/com/example/aaa/order/features/order/store.kt
package com.example.aaa.order.features.order
import org.springframework.stereotype.Repository
import java.time.Instant
@Repository
class OrderStore : OrderPorts {
private val rows = mutableListOf<PersistedOrder>()
override fun summarize(text: String): String = "summary: $text"
override fun save(input: PersistedOrder) {
rows.add(input.copy(summary = "${input.summary} @ ${Instant.now()}"))
}
}
// src/main/kotlin/com/example/aaa/order/features/order/handler.kt
package com.example.aaa.order.features.order
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
import jakarta.validation.Valid
@RestController
@RequestMapping("/features/orders")
class OrderHandler(
private val orderStore: OrderStore,
) {
@PostMapping
fun handle(@Valid @RequestBody request: PlaceOrderRequest): ResponseEntity<PlaceOrderInvoice> {
val invoice = placeOrderUseCase(
request.toDomainCommand(),
orderStore,
)
return ResponseEntity.ok(invoice)
}
}
data class PlaceOrderRequest(
@field:NotBlank
val orderId: String,
@field:NotBlank
val cartId: String,
) {
fun toDomainCommand() = OrderCommand(orderId = orderId, cartId = cartId)
}
6) Migration Guide¶
For existing hexagonal codebases, migrate with these steps:
- Inventory ports first: list all input/output interfaces and consumers.
- Identify one vertical slice with low coupling and move it to
features/<name>/. - Place all core invariants in
Logic.ktand remove framework references. - Move output adapters into
Store.kt(DB, LLM, tools, queues). - Move inbound adaptation into
Handler.kt. - Replace cross-feature port module imports with explicit feature boundary contracts.
- Keep shared infra contracts in
shared/only when truly common. - Add contract tests around handlers and store, then migrate other features.
AI replacement scenarios¶
- New LLM provider: only adapter construction in feature
Store.kt+ handler config changes. - New tool contract: update tool wrapper inside
Store.kt. - New DB driver: swap DB adapter in feature store or shared adapter and keep all logic tests intact.
7) Conclusion¶
Hexagonal Architecture remains philosophically compatible with AIDE. The practical AIDE interpretation is feature-embedded ports/adapters. That is why in volatile AI systems, AIDE often reduces migration and replacement cost: change points stay feature-local and dependency direction remains stable.