Making Invalid States Unrepresentable in Kotlin
Sealed Classes and Either in a Hexagonal Architecture
Hexagonal architecture does a good job of separating business logic from infrastructure. But separation from the outside world is only part of the story. The domain model itself can still be imprecise, allowing states that should never exist and using exceptions for outcomes that are an expected part of the business.
This article builds on the Ktor and Exposed implementation from the previous article. That implementation was a direct translation of the Spring Boot version: clean architecture, but a domain model that still relies on runtime validation.
We will improve it in two ways:
- replacing the
Statusenum with a sealed class hierarchy that makes invalid order states unrepresentable at compile time - replacing exceptions with
Eitherto make failure an explicit part of the method signature
The Problem With the Status Enum
The baseline Order class uses a Status enum to track which stage of the lifecycle the order is in.
enum class Status {
PAYMENT_EXPECTED,
PAID,
PREPARING,
READY,
TAKEN
}
State transitions are methods on Order that check the current status at runtime and throw if the call is invalid.
fun markPaid(): Order {
if (status != Status.PAYMENT_EXPECTED) {
throw IllegalStateException("Order is already paid")
}
return copy(status = Status.PAID)
}
fun markBeingPrepared(): Order {
if (status != Status.PAID) {
throw IllegalStateException("Order is not paid")
}
return copy(status = Status.PREPARING)
}
This works, but it has a few problems. The model allows any Status value to be combined with any data. A TAKEN order can technically be passed to a method that expects a PAYMENT_EXPECTED order and only fail at runtime. The business rules are scattered across conditionals. And calling operations in the wrong order is something the compiler is entirely unaware of.
The model allows states that should never exist.
Modeling the Order Lifecycle With Sealed Classes
The design principle behind the fix is simple:
If a state is impossible in the domain, it should be impossible in code.
Instead of a single Order class that can hold any status, we define a sealed class hierarchy where each subclass represents a specific lifecycle state. Each state only exposes the transitions that are valid for it.
sealed class Order {
abstract val id: Uuid
abstract val location: Location
abstract val items: List<LineItem>
fun cost() = items.map(LineItem::cost).reduce(BigDecimal::add)
data class Placed(
override val id: Uuid = Uuid.random(),
override val location: Location,
override val items: List<LineItem>
) : Order() {
fun update(location: Location, items: List<LineItem>): Placed = copy(location = location, items = items)
fun pay(): Paid = Paid(id, location, items)
}
data class Paid(
override val id: Uuid,
override val location: Location,
override val items: List<LineItem>
) : Order() {
fun startPreparing(): InPreparation = InPreparation(id, location, items)
}
data class InPreparation(
override val id: Uuid,
override val location: Location,
override val items: List<LineItem>
) : Order() {
fun finishPreparing(): Ready = Ready(id, location, items)
}
data class Ready(
override val id: Uuid,
override val location: Location,
override val items: List<LineItem>
) : Order() {
fun take(): Taken = Taken(id, location, items)
}
data class Taken(
override val id: Uuid,
override val location: Location,
override val items: List<LineItem>
) : Order()
}
Each transition is now a function from one type to another. Placed.pay() returns a Paid. Paid.startPreparing() returns an InPreparation. Calling startPreparing() on an Order.Placed is a compile error: that method does not exist on that type.
The when expression over Order is exhaustive. Adding a new state to the hierarchy forces you to handle it everywhere a when is used, and the compiler tells you what you missed.
We can test each transition directly as a unit test.
class OrderTransitionTest : FunSpec({
test("paying a placed order produces a paid order") {
val order = anOrder()
val paid = order.pay()
paid.id shouldBe order.id
paid.location shouldBe order.location
paid.items shouldContainExactly order.items
}
test("starting preparation of a paid order produces an order in preparation") {
val order = aPaidOrder()
val inPreparation = order.startPreparing()
inPreparation.id shouldBe order.id
inPreparation.location shouldBe order.location
inPreparation.items shouldContainExactly order.items
}
})
In the baseline, markPaid() returned Order, so there was nothing structural to assert on beyond the status field. Here, each transition returns a distinct type, so the test can confirm the full identity of the result. Each test documents an intentional transition in the order lifecycle.
How Sealed Classes Simplify the Use Cases
In the baseline, CoffeeShop and CoffeeMachine fetched an Order and called mutation methods that did runtime validation internally. With the sealed hierarchy, the use cases do a type check and then call a transition that is guaranteed to be valid.
Here is payOrder before and after.
Before (baseline):
override fun payOrder(orderId: Uuid, creditCard: CreditCard): Payment =
transactionScope.execute {
val order = orders.findById(orderId)
orders.save(order.markPaid())
payments.save(Payment(orderId, creditCard, Clock.System.now()))
}
After:
override fun payOrder(orderId: Uuid, creditCard: CreditCard): Either<OrderError, Payment> =
transactionScope.execute {
either {
val order = orders.findById(orderId).bind()
ensure(order is Order.Placed) { OrderError.AlreadyPaid }
orders.save(order.pay())
payments.save(Payment(orderId, creditCard, Clock.System.now()))
}
}
The ensure call is a type check. If the order is not a Placed instance, the either { } block short-circuits with OrderError.AlreadyPaid. If it passes, Kotlin’s smart cast means that order is now known to be Order.Placed, and order.pay() is available without any casting.
CoffeeMachine follows the same pattern:
override fun startPreparingOrder(orderId: Uuid): Either<OrderError, Order> =
transactionScope.execute {
either {
val order = orders.findById(orderId).bind()
ensure(order is Order.Paid) { OrderError.NotPaid }
orders.save(order.startPreparing())
}
}
The use cases become simple orchestrators: fetch, check the type, execute the transition, persist. No hidden guards, no internal mutation.
The Problem With Exceptions
The baseline communicated failures through exceptions: OrderNotFound was thrown by InMemoryOrders.findById, PaymentNotFound by InMemoryPayments.findByOrderId, and IllegalStateException by the domain methods. Ktor’s StatusPages plugin caught them globally and mapped them to HTTP responses.
This works, but the method signatures say nothing about what can go wrong. Callers must know which exceptions to catch. The compiler cannot help. Testing error paths requires catching exceptions rather than asserting on return values. And IllegalStateException is used for business rule violations, outcomes that are expected and entirely normal. That is the same exception type used to signal programming errors.
Explicit Error Handling With Either
Either<Error, Result> from Arrow represents a value that can be one of two things: a Left containing a failure, or a Right containing a success. Failures become part of the method signature.
We first define all possible domain errors as a sealed class:
sealed class OrderError {
data object NotFound : OrderError()
data object PaymentNotFound : OrderError()
data object AlreadyPaid : OrderError()
data object NotPaid : OrderError()
data object NotBeingPrepared : OrderError()
data object NotReady : OrderError()
}
These describe business failures in domain language. The Orders secondary port returns Either instead of throwing:
interface Orders {
fun save(order: Order): Order
fun findById(orderId: Uuid): Either<OrderError, Order>
fun deleteById(orderId: Uuid)
}
The in-memory stub implements this with .right() and .left() from Arrow:
class InMemoryOrders : Orders {
private val orders = mutableMapOf<Uuid, Order>()
override fun save(order: Order): Order {
orders[order.id] = order
return order
}
override fun findById(orderId: Uuid): Either<OrderError, Order> =
orders[orderId]?.right() ?: OrderError.NotFound.left()
override fun deleteById(orderId: Uuid) {
orders.remove(orderId)
}
}
In the use cases, bind() inside an either { } block unwraps a Right value or short-circuits with the Left. The happy path reads top to bottom; error handling is implicit. Returning to the payOrder example from the previous section:
override fun payOrder(orderId: Uuid, creditCard: CreditCard): Either<OrderError, Payment> =
transactionScope.execute {
either {
val order = orders.findById(orderId).bind()
ensure(order is Order.Placed) { OrderError.AlreadyPaid }
orders.save(order.pay())
payments.save(Payment(orderId, creditCard, Clock.System.now()))
}
}
The bind() call either gives us an Order to work with, or exits the block with OrderError.NotFound. The rest of the method only runs if the lookup succeeded.
The acceptance tests now assert on Either values directly:
class AcceptanceTests : FunSpec({
val orders: Orders = InMemoryOrders()
val payments: Payments = InMemoryPayments()
val customer: OrderingCoffee = CoffeeShop(orders, payments)
val barista: PreparingCoffee = CoffeeMachine(orders)
fun given(order: Order) = orders.save(order)
test("customer can pay the order") {
val order = given(anOrder())
val creditCard = aCreditCard()
val payment = customer.payOrder(order.id, creditCard).shouldBeRight()
payment.orderId shouldBe order.id
payment.creditCard shouldBe creditCard
}
test("order cannot be updated if it has been paid") {
val order = given(aPaidOrder())
val result = customer.updateOrder(order.id, Location.TAKE_AWAY, emptyList())
result.shouldBeLeft() shouldBe OrderError.AlreadyPaid
}
test("barista cannot start preparing an order that has not been paid") {
val order = given(anOrder())
val result = barista.startPreparingOrder(order.id)
result.shouldBeLeft() shouldBe OrderError.NotPaid
}
})
Kotest’s Arrow extensions provide shouldBeRight(), which asserts and unwraps the value in one call. shouldBeLeft() does the same for the error. Testing error paths is now the same as testing the happy path. No exception catching required.
How Adapters Handle Either
The StatusPages plugin from the baseline is removed. Instead, each route handler calls fold directly on the Either result, handling success and failure in one place.
fun Route.orderRoutes(orderingCoffee: OrderingCoffee, preparingCoffee: PreparingCoffee) {
put<Orders.ById> { resource ->
val request = call.receive<OrderRequest>()
orderingCoffee.updateOrder(resource.id, request.location, request.domainItems())
.fold(
{ error -> call.respondError(error) },
{ order -> call.respond(HttpStatusCode.OK, OrderResponse.fromDomain(order)) }
)
}
// ...
}
Domain errors map to HTTP status codes in a single extension function:
suspend fun ApplicationCall.respondError(error: OrderError) = when (error) {
OrderError.NotFound,
OrderError.PaymentNotFound -> respond(HttpStatusCode.NotFound)
OrderError.AlreadyPaid,
OrderError.NotPaid,
OrderError.NotBeingPrepared,
OrderError.NotReady -> respond(HttpStatusCode.Conflict)
}
The when expression is exhaustive: adding a new error type to OrderError forces handling it here.
The persistence adapter also changes. ExposedOrdersRepository.findById now returns Either instead of throwing, and save must derive the Status for the database from the sealed class type before persisting:
object ExposedOrdersRepository : Orders {
override fun save(order: Order): Order {
val status = when (order) {
is Order.Placed -> Status.PAYMENT_EXPECTED
is Order.Paid -> Status.PAID
is Order.InPreparation -> Status.PREPARING
is Order.Ready -> Status.READY
is Order.Taken -> Status.TAKEN
}
OrdersTable.upsert {
it[OrdersTable.id] = order.id
it[OrdersTable.location] = order.location
it[OrdersTable.status] = status
}
// ...
return order
}
override fun findById(orderId: Uuid): Either<OrderError, Order> {
val orderRow = OrdersTable.selectAll()
.where { OrdersTable.id eq orderId }
.singleOrNull() ?: return OrderError.NotFound.left()
val items = OrderItemsTable.selectAll()
.where { OrderItemsTable.orderId eq orderId }
.map { it.toLineItem() }
return orderRow.toOrder(items).right()
}
// ...
}
private fun ResultRow.toOrder(items: List<LineItem>): Order {
val id = this[OrdersTable.id]
val location = this[OrdersTable.location]
return when (this[OrdersTable.status]) {
Status.PAYMENT_EXPECTED -> Order.Placed(id, location, items)
Status.PAID -> Order.Paid(id, location, items)
Status.PREPARING -> Order.InPreparation(id, location, items)
Status.READY -> Order.Ready(id, location, items)
Status.TAKEN -> Order.Taken(id, location, items)
}
}
The persistence layer still uses a Status enum in the database schema. That is a storage detail, not a domain concern. The adapter is responsible for translating between them. Both when expressions are exhaustive: one maps from sealed class to status for writing, the other maps from status to sealed class for reading.
Trade-offs
These improvements come with real costs.
The sealed class hierarchy means the persistence adapter has more to do. Every save must determine the status from the type, and every findById must reconstruct the correct sealed class subtype from the stored status. The baseline had no such translation; the Status field mapped directly.
Arrow introduces a functional style that not everyone on a team may be comfortable with. The either { } builder, bind(), and ensure() are a new vocabulary. The fold pattern in adapters is straightforward, but it takes a bit of time to read fluently at first.
The approach pays off most in domains with meaningful state machines and many transitions, or systems where explicit error contracts matter, for example when the error types drive HTTP responses or need to be documented as part of a public API. For a simple CRUD service where most errors are “not found” or “validation failed”, the extra machinery is harder to justify.
Summary
Hexagonal architecture separates business logic from infrastructure. Kotlin lets us go further and strengthen the domain model itself.
Two changes make the biggest difference:
Replacing the
Statusenum with a sealed class hierarchy makes invalid order states unrepresentable at compile time. Illegal transitions are not runtime exceptions. They are code that does not compile.Replacing exceptions with
Eithermakes failure part of the method signature. The compiler ensures callers handle both outcomes. Testing error paths becomes as straightforward as testing the happy path.
Neither change requires hexagonal architecture, but the architecture creates a clean place for them. The domain model lives in a module with no framework dependencies, which makes it easy to apply type-level constraints without worrying about what the framework expects.
You can find the example code on GitHub.