Skip to main content

Hexagonal Architecture (Ports & Adapters)

Hexagonal architecture — also called Ports & Adapters — was introduced by Alistair Cockburn in 2005. It predates Clean Architecture but solves the same problem with a different visualization: the domain sits in the middle, surrounded by ports (interfaces it defines) that the outside world plugs into via adapters.

The hexagon

┌─────────────────────────┐
│ Primary adapters │ driving side
│ (UI, REST controllers, │ — what CALLS the domain
│ tests, CLI) │
└───────────┬─────────────┘
│ via primary ports (use cases)

┌───────────────────────────────────────────────────┐
│ │
│ DOMAIN │
│ - Entities (Pure Kotlin, no Android, no I/O) │
│ - Use cases / application services │
│ - Ports (interfaces the domain needs) │
│ │
└─────────┬──────────────────────────────┬───────────┘
│ │
▼ via secondary ports ▼
┌──────────────────┐ ┌──────────────────┐
│ Secondary │ │ Secondary │ driven side
│ adapters: │ │ adapters: │ — what the domain CALLS
│ Room, Retrofit, │ │ FCM, DataStore, │
│ FileSystem │ │ Crashlytics │
└──────────────────┘ └──────────────────┘

Two types of adapters:

  • Primary (driving) — initiate calls INTO the domain. UI, tests, background workers, a REST server. They consume primary ports (use cases, application services).
  • Secondary (driven) — the domain calls THEM. Databases, networks, external services, message buses. They implement secondary ports (repository interfaces, notification senders, etc.) defined by the domain.

A concrete example — checkout flow

Let's design a checkout use case following hexagonal discipline.

Step 1 — Define the domain, ports, entities

// :domain module — zero Android imports

// ── Entities (pure business types)
data class Cart(val items: List<CartItem>, val total: Money)
data class Order(val id: OrderId, val items: List<CartItem>, val total: Money, val placedAt: Instant)
@JvmInline value class OrderId(val raw: String)

// ── Secondary ports — the domain DEMANDS these from the outside
interface CartRepository {
suspend fun current(): Cart
suspend fun clear()
}

interface OrderRepository {
suspend fun save(order: Order)
}

interface PaymentGateway {
suspend fun charge(amount: Money, method: PaymentMethod): PaymentReceipt
}

interface NotificationSender {
suspend fun notify(userId: UserId, message: String)
}

// ── Primary port — the app's capability, exposed to primary adapters
class PlaceOrderUseCase @Inject constructor(
private val cart: CartRepository, // secondary ports...
private val orders: OrderRepository,
private val payment: PaymentGateway,
private val notifier: NotificationSender,
private val clock: Clock // even time is abstracted
) {
suspend operator fun invoke(
userId: UserId,
paymentMethod: PaymentMethod
): Result<OrderId> = runCatching {
val current = cart.current()
require(current.items.isNotEmpty()) { "Cart is empty" }

val receipt = payment.charge(current.total, paymentMethod)
val order = Order(
id = OrderId(receipt.orderId),
items = current.items,
total = current.total,
placedAt = clock.now()
)
orders.save(order)
cart.clear()
notifier.notify(userId, "Order ${order.id.raw} placed")
order.id
}
}

Notice the domain has:

  • No import android.*
  • No import retrofit2.*
  • No import androidx.room.*
  • No direct System.currentTimeMillis() — it uses Clock

It's pure Kotlin. It compiles on JVM, iOS (KMP), and any JUnit classpath.

Step 2 — Secondary adapters implement the ports

// :data module — implementations of the ports

class RoomCartRepository @Inject constructor(
private val dao: CartDao
) : CartRepository {
override suspend fun current(): Cart = dao.get().toDomain()
override suspend fun clear() = dao.clear()
}

class RetrofitPaymentGateway @Inject constructor(
private val api: PaymentApi
) : PaymentGateway {
override suspend fun charge(amount: Money, method: PaymentMethod): PaymentReceipt =
api.charge(ChargeRequest(amount.cents, method.toDto())).toDomain()
}

class FcmNotificationSender @Inject constructor(
private val messaging: FirebaseMessaging,
private val userTokenRepo: UserTokenRepository
) : NotificationSender {
override suspend fun notify(userId: UserId, message: String) {
val token = userTokenRepo.get(userId) ?: return
messaging.sendToToken(token, message)
}
}

// Hilt binds ports to adapters
@Module @InstallIn(SingletonComponent::class)
abstract class AdaptersModule {
@Binds abstract fun bindCartRepository(impl: RoomCartRepository): CartRepository
@Binds abstract fun bindPaymentGateway(impl: RetrofitPaymentGateway): PaymentGateway
@Binds abstract fun bindNotificationSender(impl: FcmNotificationSender): NotificationSender
}

Step 3 — Primary adapters call the use case

// :feature:checkout — the UI is a primary adapter
@HiltViewModel
class CheckoutViewModel @Inject constructor(
private val placeOrder: PlaceOrderUseCase,
private val authRepo: AuthRepository
) : ViewModel() {

fun onSubmit(method: PaymentMethod) = viewModelScope.launch {
val user = authRepo.currentUser() ?: return@launch
placeOrder(user.id, method)
.onSuccess { id -> _events.send(NavigateToConfirmation(id.raw)) }
.onFailure { e -> _state.update { it.copy(error = e.message) } }
}
}
// A scheduled background worker is ALSO a primary adapter
@HiltWorker
class NightlyOrderRetryWorker @AssistedInject constructor(
@Assisted ctx: Context,
@Assisted params: WorkerParameters,
private val pendingOrders: PendingOrdersRepository,
private val placeOrder: PlaceOrderUseCase
) : CoroutineWorker(ctx, params) {
override suspend fun doWork(): Result {
pendingOrders.list().forEach { pending ->
placeOrder(pending.userId, pending.paymentMethod)
}
return Result.success()
}
}

Same use case, different primary adapters (UI vs background worker). The domain doesn't care who's calling.


The testing superpower

Because ports are interfaces, tests swap real adapters for fakes:

class PlaceOrderUseCaseTest {

// In-memory fakes — no Android, no network
private val cart = FakeCartRepository()
private val orders = FakeOrderRepository()
private val payment = FakePaymentGateway()
private val notifier = FakeNotificationSender()
private val clock = FixedClock(Instant.parse("2025-01-01T00:00:00Z"))

private val placeOrder = PlaceOrderUseCase(cart, orders, payment, notifier, clock)

@Test fun `empty cart fails`() = runTest {
cart.set(Cart(emptyList(), Money.Zero))

val result = placeOrder(userId = UserId("u1"), paymentMethod = sampleCard)

assertTrue(result.isFailure)
}

@Test fun `successful checkout saves order, clears cart, notifies`() = runTest {
cart.set(sampleCart)
payment.respondWith(PaymentReceipt(orderId = "o1"))

val result = placeOrder(UserId("u1"), sampleCard)

assertTrue(result.isSuccess)
assertTrue(orders.all().any { it.id.raw == "o1" })
assertEquals(Cart.Empty, cart.get())
assertTrue(notifier.sent.any { it.first == UserId("u1") })
}

@Test fun `payment failure rolls back cart and order`() = runTest {
cart.set(sampleCart)
payment.failWith(IOException("network"))

val result = placeOrder(UserId("u1"), sampleCard)

assertTrue(result.isFailure)
assertFalse(orders.all().isNotEmpty()) // no order saved
assertNotEquals(Cart.Empty, cart.get()) // cart preserved
}
}

No Robolectric. No MockK strictly required (though often convenient). No emulator. Tests run in tens of milliseconds.


Port granularity — how small or large

Ports should express what the domain wants, not what the backend ships. A common mistake is making RetrofitUserApi the port — you've just leaked REST semantics into the domain.

Leaky port

HTTP-shaped port

  • interface UserApi { suspend fun getUser(@Path("id") id: String): UserDto }
  • Domain has to know what UserDto fields look like
  • Changing backends means changing domain code
  • DTOs leak into use cases
  • Cannot unit-test without HTTP stubs
Domain-shaped port

Capability port

  • interface UserRepository { suspend fun fetch(id: UserId): User }
  • Domain knows only User (domain entity)
  • Backend swap happens entirely in :data
  • DTOs live in :data, mapped to User
  • Trivial fakes for unit tests

Rule of thumb: if switching from REST to GraphQL would change a port's signature, the port is too low-level. Refactor until the port describes capability, not transport.


Symmetric ports — the two hexagon halves

Secondary (driven) ports — domain → outside

These are the majority of ports. The domain needs data, storage, or messaging:

interface UserRepository
interface PaymentGateway
interface NotificationSender
interface Clock
interface AnalyticsRecorder
interface FeatureFlagReader
interface CrashReporter

Each implementation is a secondary adapter.

Primary (driving) ports — outside → domain

Use cases (application services) are primary ports:

class PlaceOrderUseCase(/* deps */)
class CancelOrderUseCase(/* deps */)
class GetOrderHistoryUseCase(/* deps */)

UI, background workers, CLI tools, test frameworks are primary adapters that invoke these.

Ports are always defined in the domain

:domain
├── ports/ <-- both primary and secondary
│ ├── UserRepository.kt (secondary)
│ ├── PaymentGateway.kt (secondary)
│ └── ...
├── usecase/ <-- primary ports
│ ├── PlaceOrderUseCase.kt
│ └── ...
└── entity/
└── User.kt

Ports never live in :data or :feature:*. That would invert the dependency.


Adapter patterns

Composite adapter — several sources behind one port

class CompositeUserRepository @Inject constructor(
private val network: NetworkUserSource,
private val cache: RoomUserCache,
@IoDispatcher private val io: CoroutineDispatcher
) : UserRepository {

override fun observe(id: UserId): Flow<User> = cache.observe(id)
.onStart {
withContext(io) { runCatching { network.fetch(id) }.onSuccess(cache::save) }
}

override suspend fun refresh(id: UserId) = withContext(io) {
cache.save(network.fetch(id))
}
}

Domain sees one UserRepository. Behind it, the adapter orchestrates a network source and a cache (single-source-of-truth pattern — see Offline-First).

Null-object adapter — disabled feature

object NoOpAnalytics : AnalyticsRecorder {
override suspend fun record(event: Event) { /* do nothing */ }
}

// Bind in a flavor or when feature flag is off
@Provides @Singleton
fun provideAnalytics(flags: FeatureFlags, realImpl: FirebaseAnalyticsRecorder): AnalyticsRecorder =
if (flags.analyticsEnabled) realImpl else NoOpAnalytics

The domain calls the port unchanged; the adapter decides to do nothing. Compare to wrapping every analytics call in if (enabled) { ... }.

Decorating adapter — cross-cutting behavior

class LoggingPaymentGateway(
private val delegate: PaymentGateway,
private val logger: Logger
) : PaymentGateway {
override suspend fun charge(amount: Money, method: PaymentMethod): PaymentReceipt {
logger.info("charging $amount via $method")
return try {
delegate.charge(amount, method).also { logger.info("ok: ${it.orderId}") }
} catch (t: Throwable) {
logger.error("failed: ${t.message}"); throw t
}
}
}

Log, trace, retry, cache — all as decorators of the real adapter. The domain never changes.


Comparing Hexagonal, Clean, and Onion

Hexagonal (Cockburn, 2005)Clean (Martin, 2012)Onion (Palermo, 2008)
DiagramHexagon with primary (top) + secondary (bottom) adaptersConcentric circles (entities → use cases → adapters → frameworks)Concentric circles (domain → domain services → application → infrastructure)
Layer count2 (domain + adapters)4+4
Named ports?Yes, core conceptInformally (interfaces)Yes, also called "dependency inversion"
Dependency ruleInwardInwardInward
Practical differenceSame. Pick whichever diagram helps your team discuss.

All three enforce: the domain depends on nothing external.


When Hexagonal is overkill

For production apps of 10+ screens with real business logic, Hexagonal/ Clean is worth every bit of effort.


Key takeaways

Practice exercises

  1. 01

    Identify your ports

    Pick one feature. List every external system the domain uses (DB, network, clock, notifications). Write a port interface for each.

  2. 02

    Refactor to use Clock

    Find every System.currentTimeMillis() in your use cases. Introduce an interface Clock { fun now(): Instant } and inject it. Now time-dependent tests are deterministic.

  3. 03

    Add a LoggingAdapter decorator

    Pick a secondary port. Wrap its implementation with a LoggingXAdapter that logs every call + result. Bind via Hilt flavor or flag.

  4. 04

    Swap an adapter

    Replace one Retrofit-backed adapter with an in-memory fake in a debug build. Prove the rest of the app doesn't change.

Continue reading