Skip to main content

Command Palette

Search for a command to run...

Your Business Logic is Hostage to Your Database

Why Pure Functions Are the Only Code Pattern That Survives a Crash

Updated
22 min read
Your Business Logic is Hostage to Your Database

How to Read This Article (Important)

This is a long-form architectural deep dive, not a quick tutorial.

You'll get the most value if you read it in order:

  • Part I explains why distributed systems force replay and why impurity becomes dangerous

  • Part II introduces purity as a constraint, not a preference

  • Part III presents the Decider pattern (decide / evolve) as the functional core

  • Part IV grounds the theory in Restate-style durable execution

  • Part V proves the approach with testing, replay, and production data

  • Part VI zooms out into DDD, Hexagonal Architecture, and long-term maintainability

If you already know functional programming, skim examples if needed—do not skip the transitions between sections. That's where the architectural insight lives.


Part I — The Problem Distributed Systems Force on You

TL;DR

The uncomfortable truth: If your business logic can't be tested in milliseconds without Docker, databases, or message brokers, you're not testing business logic—you're testing infrastructure integration.

The solution: Separate pure decision-making from effectful execution using functional programming principles. Two functions—decide() and evolve()—enable business logic that's:

  • Testable in 2ms without infrastructure

  • Portable across any runtime (in-memory, Restate, traditional)

  • Deterministic enough for crash replay

  • Completely isolated from framework coupling

This isn't academic FP. This is how we deleted 60% of our test infrastructure and ran 10,000 business rule scenarios in under a second.

Uncomfortable truth: replay is not a corner case—it is the default failure mode.


The Production Incident That Changed Everything

It was 2 AM when my phone buzzed with PagerDuty alerts. Our order processing system had crashed mid-execution—nothing unusual for distributed systems. What happened next changed how I think about code.

The handler restarted. It replayed. And it charged the customer twice.

The code looked innocent:

fun processOrder(orderId: String): Result {
    val id = UUID.randomUUID()           // Step 1: Generate ID
    val payment = chargeCard(100.00)     // Step 2: Charge card
    val order = Order(id, payment)       // Step 3: Create order
    saveToDatabase(order)                // Step 4: Save
    return Result.Success
}

On first execution:

id = "abc-123"
payment = charged $100.00
💥 CRASH (before save)

On replay after crash:

id = "def-456"        ← DIFFERENT ID!
payment = charged $100.00  ← DUPLICATE CHARGE!
💥 DISASTER

This incident wasn't caused by bad engineers—it was caused by impure logic meeting replay.


Checkpoint: What We've Established So Far

  • Crashes are normal in distributed systems

  • Replay is inevitable

  • Non-determinism + replay = duplicate effects

If this already feels uncomfortable, that's good—you're confronting real system constraints.


Part II — Why Purity Is No Longer Optional

The Forces That Collapse the Design Space

Four forces converge in distributed systems that eliminate most design choices:

  1. Crashes happen — Hardware fails, deployments restart, networks partition

  2. Scale demands distribution — Single nodes can't handle production load

  3. Replay is the recovery mechanism — Frameworks re-execute your code after failures

  4. CI testing must be fast — 25-minute test suites kill velocity

We'll call this the replay hazard—any logic that produces different results on re-execution.

From here on, I'll refer to it by name instead of re-explaining it.


The Testing Problem Nobody Solves

This test is slow not because of data—but because business rules are welded to infrastructure.

I've reviewed hundreds of microservices. Here's what I see:

@SpringBootTest
@Testcontainers
class OrderServiceTest {
    @Container
    val postgres = PostgreSQLContainer("postgres:15")

    @Container
    val kafka = KafkaContainer()

    @Autowired
    lateinit var orderService: OrderService

    @Test
    fun `should create order`() {
        // Wait for containers: 15 seconds
        // Execute test: 2 seconds
        // Teardown: 5 seconds

        val result = orderService.createOrder(...)

        assertEquals(OrderStatus.CREATED, result.status)
    }
}

Test suite runtime: 25 minutes for 150 tests.

Question: What are you actually testing?

  1. Can Spring Boot start? (tested 150 times)

  2. Can PostgreSQL accept connections? (tested 150 times)

  3. Can Kafka receive messages? (tested 150 times)

  4. Can you create an order if the customer exists? (tested once)

22 seconds of infrastructure, 200ms of business logic.

You're not testing business rules. You're testing that your integration works. And you're doing it slowly, expensively, and with massive cognitive overhead.


Checkpoint

  • Infrastructure tests verify integration, not intent

  • Business rules cannot be tested in isolation

  • This makes correctness expensive


The Root Cause: Coupling Business Logic to Infrastructure

Here's what "typical" order processing looks like:

@Service
class OrderService(
    private val orderRepository: OrderRepository,
    private val customerRepository: CustomerRepository,
    private val inventoryService: InventoryService,
    private val eventPublisher: EventPublisher,
    private val transactionTemplate: TransactionTemplate
) {
    fun createOrder(request: CreateOrderRequest): Order {
        return transactionTemplate.execute {
            // Database call 1
            val customer = customerRepository.findById(request.customerId)
                ?: throw CustomerNotFoundException()

            // Database call 2
            val existingOrder = orderRepository.findByIdempotencyKey(request.idempotencyKey)
            if (existingOrder != null) return existingOrder

            // Business logic buried here (3 lines among 40)
            if (request.items.isEmpty()) {
                throw EmptyOrderException()
            }
            val total = request.items.sumOf { it.price * it.quantity }

            // HTTP call
            val reservation = inventoryService.reserve(request.items)

            // Database call 3
            val order = Order(
                id = UUID.randomUUID(),
                customerId = customer.id,
                items = request.items,
                total = total,
                status = OrderStatus.CREATED
            )
            orderRepository.save(order)

            // Kafka publish
            eventPublisher.publish(OrderCreatedEvent(order))

            order
        }
    }
}

Problems:

  1. Business logic is invisible — Scattered among infrastructure calls

  2. Untestable without infrastructure — Needs DB, HTTP, Kafka

  3. Non-deterministic — Different UUID.randomUUID() each run

  4. Coupled to Spring — Can't move to Restate/Temporal without rewrite

  5. Impossible to replay safely — Side effects everywhere (replay hazard!)

The real question: Where's the business logic? What are the actual rules for creating an order?

Answer: It's buried inside 40 lines of infrastructure code. Good luck testing JUST that.


Part III — The Functional Core: decide / evolve

The One Pattern That Survives Replay

Instead of asking "how do I execute this?" we ask "what decision does the domain make?"

Here's the radical idea: Business logic should be a pure function.

// Pure business logic - NO infrastructure
fun decide(command: CreateOrder, state: Order?, time: Instant): Either<OrderError, List<OrderEvent>>

// Pure state evolution - NO side effects
fun evolve(state: Order?, event: OrderEvent): Order?

That's it. Two functions. Zero dependencies. Complete business logic.

All business rules live in decide. evolve contains zero rules.

What changed:

TraditionalFunctional
Business logic mixed with infrastructurePure functions, separated concerns
Requires DB/HTTP/Kafka to testRequires nothing—just call the function
22 seconds per test2 milliseconds per test
Non-deterministic (side effects)Deterministic (pure)
Framework-coupledFramework-agnostic
Impossible to replay safelyReplay is trivial

Pure Functions: The Foundation

What Makes a Function Pure?

A pure function has exactly two properties:

1. Deterministic: Same inputs → Always same output

// ✅ PURE - Always returns same result
fun calculateTotal(items: List<LineItem>): Money {
    return items.fold(Money.ZERO) { acc, item ->
        acc + (item.price * item.quantity)
    }
}

// Test it:
val items = listOf(LineItem("widget", 2, Money(10.00)))
calculateTotal(items)  // → 20.00
calculateTotal(items)  // → 20.00 (SAME EVERY TIME)

// ❌ IMPURE - Different result each time
fun generateOrderId(): String {
    return UUID.randomUUID().toString()  // Non-deterministic!
}

// Test it:
generateOrderId()  // → "abc-123"
generateOrderId()  // → "def-456" (DIFFERENT!)

2. No Side Effects: Doesn't modify external state

// ✅ PURE - Returns new value, no mutation
fun addItem(order: Order, item: LineItem): Order {
    return order.copy(items = order.items + item)  // Immutable
}

// ❌ IMPURE - Mutates external state
fun addItem(order: Order, item: LineItem) {
    order.items.add(item)  // Mutation!
}

// ❌ IMPURE - Calls external service
fun reserveInventory(item: LineItem): Reservation {
    return inventoryService.reserve(item)  // Side effect!
}

// ❌ IMPURE - Writes to database
fun saveOrder(order: Order) {
    orderRepository.save(order)  // Side effect!
}

Why Purity Matters: The Replay Hazard Returns

Remember the replay hazard? You cannot safely replay impure functions. Ever.

This is why durable execution requires functional programming. It's not an aesthetic choice—it's a correctness requirement.


The Decider Pattern: Pure Business Logic

The Decider pattern structures business logic as two pure functions.

The Contract

// Input: What you want to do + Current state + Time
// Output: What happened (or why it can't happen)
decide: (Command, State, Time) → Either<Error, List<Event>>

// Input: Current state + What happened
// Output: New state
evolve: (State, Event) → State

// Initial state for new aggregates
initialState: () → State

The Rules

decide() contains ALL business logic:

  • Validation rules

  • State transition rules

  • Business invariants

  • Authorization checks

evolve() contains ZERO business logic:

  • Just mechanical state updates

  • No validation (already done in decide)

  • No conditionals (event already validated)

  • Pure data transformation

Why this separation?

  1. Testability: Test ALL business rules by testing decide()

  2. Replay safety: evolve() can safely replay events without re-validating

  3. Event sourcing: Rebuild state by folding events through evolve()

  4. Clarity: Business rules in ONE place, not scattered


Checkpoint

  • Business logic is now pure

  • State transitions are mechanical

  • Replay becomes safe by construction


Railway-Oriented Programming: Making Errors Explicit

Traditional error handling obscures the happy path:

// ❌ Traditional: Errors hidden in exceptions
fun createOrder(cmd: CreateOrder): Order {
    if (cmd.items.isEmpty()) {
        throw EmptyOrderException()  // Where did this come from?
    }

    val customer = customerRepository.findById(cmd.customerId)
        ?: throw CustomerNotFoundException()  // Another exception!

    if (customer.status == CustomerStatus.BLOCKED) {
        throw CustomerBlockedException()  // And another!
    }

    // Happy path buried among error handling
    return Order(...)
}

Problems:

  • Unclear what errors are possible (need to read implementation)

  • Exceptions don't compose (can't chain operations)

  • Type system doesn't help (return type says nothing about errors)

Enter Arrow-KT Either

// ✅ Functional: Errors in type signature
fun createOrder(
    cmd: CreateOrder,
    state: Order?,
    time: Instant
): Either<OrderError, NonEmptyList<OrderEvent>>

// Type signature tells you:
// - Returns EITHER an error OR events
// - Errors are OrderError type
// - Success is guaranteed non-empty list of events

Arrow-KT provides:

  • Either<E, A>: A value that's either Left(error) or Right(success)

  • Raise DSL: Railway-oriented programming with early returns

  • Composition: Chain operations that might fail


The Complete Decider Implementation

Domain Model (Immutable)

import arrow.core.Either
import arrow.core.NonEmptyList
import arrow.core.nonEmptyListOf
import arrow.core.raise.either
import arrow.core.raise.ensure

@Serializable
data class Order(
    val orderId: OrderId,
    val customerId: CustomerId,
    val items: ImmutableList<LineItem>,
    val total: Money,
    val status: OrderStatus,
    val version: Long,
    val createdAt: Instant,
    val updatedAt: Instant
) {
    companion object {
        fun empty(orderId: OrderId) = Order(
            orderId = orderId,
            customerId = CustomerId(""),
            items = persistentListOf(),
            total = Money.ZERO,
            status = OrderStatus.PENDING,
            version = 0,
            createdAt = Instant.EPOCH,
            updatedAt = Instant.EPOCH
        )
    }
}

// Commands (input)
sealed interface OrderCommand {
    val orderId: OrderId

    @Serializable
    data class CreateOrder(
        override val orderId: OrderId,
        val customerId: CustomerId,
        val items: ImmutableList<LineItem>
    ) : OrderCommand

    @Serializable
    data class ConfirmOrder(override val orderId: OrderId) : OrderCommand

    @Serializable
    data class CancelOrder(
        override val orderId: OrderId,
        val reason: String
    ) : OrderCommand
}

// Events (output)
sealed interface OrderEvent {
    val orderId: OrderId
    val timestamp: Instant
    val version: Long

    @Serializable
    data class OrderCreated(
        override val orderId: OrderId,
        val customerId: CustomerId,
        val items: ImmutableList<LineItem>,
        val total: Money,
        override val timestamp: Instant,
        override val version: Long
    ) : OrderEvent

    @Serializable
    data class OrderConfirmed(
        override val orderId: OrderId,
        override val timestamp: Instant,
        override val version: Long
    ) : OrderEvent

    @Serializable
    data class OrderCancelled(
        override val orderId: OrderId,
        val reason: String,
        override val timestamp: Instant,
        override val version: Long
    ) : OrderEvent
}

// Errors (explicit)
sealed interface OrderError {
    data class OrderAlreadyExists(val orderId: OrderId) : OrderError
    data class OrderNotFound(val orderId: OrderId) : OrderError
    data class EmptyLineItems(val orderId: OrderId) : OrderError
    data class InvalidTotal(val orderId: OrderId, val total: Money) : OrderError
    data class InvalidStateTransition(
        val orderId: OrderId,
        val from: OrderStatus,
        val to: OrderStatus
    ) : OrderError
    data class CannotCancelShipped(val orderId: OrderId) : OrderError
}

The Decider: ALL Business Logic

/**
 * OrderDecider: Pure business logic
 *
 * Signature: (Command, State, Time) → Either<Error, NonEmptyList<Event>>
 *
 * RULES:
 * - NO side effects (no DB, no HTTP, no Kafka)
 * - NO mutable state (everything immutable)
 * - NO exceptions (use Either for errors)
 * - Deterministic (same input = same output ALWAYS)
 * - Contains ALL business rules
 */
object OrderDecider {

    fun decide(
        command: OrderCommand,
        state: Order?,
        currentTime: Instant  // Time passed in for determinism!
    ): Either<OrderError, NonEmptyList<OrderEvent>> =
        when (command) {
            is OrderCommand.CreateOrder -> handleCreate(command, state, currentTime)
            is OrderCommand.ConfirmOrder -> handleConfirm(command, state, currentTime)
            is OrderCommand.CancelOrder -> handleCancel(command, state, currentTime)
        }

    /**
     * Business Rule: Create Order
     *
     * Preconditions:
     * - Order must not already exist
     * - Must have at least one line item
     * - Total must be positive
     *
     * Postconditions:
     * - OrderCreated event with calculated total
     */
    private fun handleCreate(
        cmd: OrderCommand.CreateOrder,
        state: Order?,
        currentTime: Instant
    ): Either<OrderError, NonEmptyList<OrderEvent>> = either {
        // Rule: Cannot recreate existing order
        ensure(state == null || state.items.isEmpty()) {
            OrderError.OrderAlreadyExists(cmd.orderId)
        }

        // Rule: Must have items
        ensure(cmd.items.isNotEmpty()) {
            OrderError.EmptyLineItems(cmd.orderId)
        }

        // Rule: Calculate total (business logic)
        val total = cmd.items.fold(Money.ZERO) { acc, item ->
            acc + (item.price * item.quantity)
        }

        // Rule: Total must be positive
        ensure(total.amount > 0) {
            OrderError.InvalidTotal(cmd.orderId, total)
        }

        // Success: Return event
        nonEmptyListOf(
            OrderEvent.OrderCreated(
                orderId = cmd.orderId,
                customerId = cmd.customerId,
                items = cmd.items,
                total = total,
                timestamp = currentTime,  // Deterministic!
                version = 0L
            )
        )
    }

    private fun handleConfirm(
        cmd: OrderCommand.ConfirmOrder,
        state: Order?,
        currentTime: Instant
    ): Either<OrderError, NonEmptyList<OrderEvent>> = either {
        ensure(state != null) {
            OrderError.OrderNotFound(cmd.orderId)
        }

        ensure(state.status == OrderStatus.PENDING) {
            OrderError.InvalidStateTransition(
                cmd.orderId,
                state.status,
                OrderStatus.CONFIRMED
            )
        }

        nonEmptyListOf(
            OrderEvent.OrderConfirmed(
                orderId = cmd.orderId,
                timestamp = currentTime,
                version = state.version + 1
            )
        )
    }

    private fun handleCancel(
        cmd: OrderCommand.CancelOrder,
        state: Order?,
        currentTime: Instant
    ): Either<OrderError, NonEmptyList<OrderEvent>> = either {
        ensure(state != null) {
            OrderError.OrderNotFound(cmd.orderId)
        }

        ensure(state.status != OrderStatus.SHIPPED) {
            OrderError.CannotCancelShipped(cmd.orderId)
        }

        nonEmptyListOf(
            OrderEvent.OrderCancelled(
                orderId = cmd.orderId,
                reason = cmd.reason,
                timestamp = currentTime,
                version = state.version + 1
            )
        )
    }
}

What's happening:

  1. either block: Railway-oriented programming DSL

  2. ensure(condition) { error }: Early return on failure (short-circuits)

  3. All business rules explicit: Every validation is visible

  4. Type-safe errors: Can't forget to handle an error case

  5. No exceptions: Error path is part of the type

  6. Time passed in: Deterministic—same input = same output

The Evolver: Pure State Transitions

/**
 * OrderEvolver: Pure state evolution
 *
 * Signature: (State, Event) → State
 *
 * RULES:
 * - NO business logic (that's in decide)
 * - NO validation (events are pre-validated)
 * - Just mechanical state updates
 * - Immutable (return new state, don't mutate)
 */
object OrderEvolver {

    fun evolve(state: Order?, event: OrderEvent): Order? =
        when (event) {
            is OrderEvent.OrderCreated -> Order(
                orderId = event.orderId,
                customerId = event.customerId,
                items = event.items,
                total = event.total,
                status = OrderStatus.PENDING,
                version = event.version,
                createdAt = event.timestamp,
                updatedAt = event.timestamp
            )

            is OrderEvent.OrderConfirmed -> state?.copy(
                status = OrderStatus.CONFIRMED,
                version = event.version,
                updatedAt = event.timestamp
            )

            is OrderEvent.OrderCancelled -> state?.copy(
                status = OrderStatus.CANCELLED,
                version = event.version,
                updatedAt = event.timestamp
            )
        }

    /**
     * Rehydrate aggregate from event stream
     * (Used in event sourcing or testing)
     */
    fun rehydrate(events: List<OrderEvent>): Order? =
        events.fold(null as Order?) { state, event ->
            evolve(state, event)
        }
}

Notice:

  • No if statements — No conditional logic

  • No validation — Events are already validated

  • No business rules — Just data transformation

  • Immutable updates — Using .copy()


Part IV — Making Purity Practical with Durable Execution

Where Side Effects Are Allowed (and Only There)

Pure functions don't eliminate side effects—they quarantine them.

The Decider pattern gives us pure business logic. But real systems need:

  • Database reads/writes

  • External API calls

  • Event publishing

  • Time and random values

The solution: Push all side effects to the edges, wrap them with Restate's journaling.

Journaled Time

// ❌ WRONG - Non-deterministic, breaks replay
val now = Instant.now()

// ✅ CORRECT - Journaled, deterministic on replay
val now = ctx.timer()  // Restate journals this

Journaled Side Effects

// ❌ WRONG - Re-executes on replay, causes duplicates
kafkaProducer.send(event)

// ✅ CORRECT - Journaled, runs exactly once
ctx.run("publish-event") {
    kafkaProducer.send(event)
}

Why Purity + Journaling Compose

Why replay works:

  1. decide() is pure — Same inputs = Same outputs (OrderCreated event identical)

  2. ctx.run() is journaled — Payment result cached (no re-execution)

  3. evolve() is pure — Safe to re-execute (deterministic state transition)

  4. Time is journaled — Same timestamp on replay

Without purity, replay would:

  • Generate different events each time

  • Duplicate payments

  • Corrupt state

Functional programming isn't optional for durable execution—it's required.


If You Stop Reading Here

If you remember only one thing:

In distributed systems, replay is inevitable. Pure functions are the only logic that survives replay unchanged.

Everything else in this article is a consequence of that fact.


The Restate Handler: Orchestrating Pure Logic

@RestateComponent
@VirtualObject
class OrderAggregate(
    private val eventPublisher: EventPublisher
) {
    companion object {
        private val ORDER_STATE = stateKey<Order>("order")
    }

    @Handler
    suspend fun create(ctx: ObjectContext, cmd: OrderCommand.CreateOrder): String {
        // 1. Get journaled time (deterministic!)
        val currentTime = ctx.timer()

        // 2. Get current state
        val state = ctx.get(ORDER_STATE)

        // 3. Call PURE domain logic
        val result = OrderDecider.decide(cmd, state, currentTime)

        // 4. Convert Either to Restate exception pattern
        val events = result.getOrElse { error ->
            throw TerminalException(400, "Failed: $error")
        }

        // 5. Side effects (journaled - runs exactly once)
        events.forEach { event ->
            ctx.run("publish-${event::class.simpleName}") {
                eventPublisher.publish("order-events", event)
            }
        }

        // 6. Evolve state (pure)
        val newState = events.fold(state, OrderEvolver::evolve)

        // 7. Save (infrastructure)
        ctx.set(ORDER_STATE, newState)

        return "Created: ${cmd.orderId.value}"
    }
}

The pattern:

  • Pure logic in domaindecide(), evolve()

  • Side effects at edgesctx.run(), ctx.timer()

  • State in Restatectx.get(), ctx.set()

  • Errors as exceptionsTerminalException for business errors


Part V — Testing, Replay, and Production Reality

Why 2ms Tests Change Everything

Speed matters not for convenience, but because it changes how often you test.

The transformation:

// ❌ Before: Integration test (22 seconds)
@SpringBootTest
@Testcontainers
class OrderServiceTest {
    @Container val postgres = PostgreSQLContainer("postgres:15")
    @Container val kafka = KafkaContainer()
    @Autowired lateinit var orderService: OrderService

    @Test
    fun `should create order`() {
        val result = orderService.createOrder(...)
        assertEquals(OrderStatus.CREATED, result.status)
    }
}

// ✅ After: Unit test (2 milliseconds)
class OrderDeciderTest {
    private val fixedTime = Instant.parse("2024-01-15T10:00:00Z")

    @Test
    fun `should create order with valid items`() {
        // Given
        val state = null  // New order
        val command = OrderCommand.CreateOrder(
            orderId = OrderId("order-123"),
            customerId = CustomerId("customer-1"),
            items = persistentListOf(
                LineItem("widget", 2, Money(10.00, "USD"))
            )
        )

        // When
        val result = OrderDecider.decide(command, state, fixedTime)

        // Then
        result.shouldBeRight().shouldHaveSize(1)
        val event = result.getOrNull()!!.first() as OrderEvent.OrderCreated
        event.total shouldBe Money(20.00, "USD")
        event.items.size shouldBe 1
    }

    @Test
    fun `should reject order with no items`() {
        val state = null
        val command = OrderCommand.CreateOrder(
            orderId = OrderId("order-123"),
            customerId = CustomerId("customer-1"),
            items = persistentListOf()  // Empty!
        )

        val result = OrderDecider.decide(command, state, fixedTime)

        result.shouldBeLeft(
            OrderError.EmptyLineItems(OrderId("order-123"))
        )
    }

    @Test
    fun `cannot cancel shipped order`() {
        val state = Order.empty(OrderId("order-123")).copy(
            status = OrderStatus.SHIPPED
        )
        val command = OrderCommand.CancelOrder(
            orderId = OrderId("order-123"),
            reason = "Customer request"
        )

        val result = OrderDecider.decide(command, state, fixedTime)

        result.shouldBeLeft(
            OrderError.CannotCancelShipped(OrderId("order-123"))
        )
    }
}

Test execution stats:

OrderDeciderTest > should create order with valid items: 1.2ms
OrderDeciderTest > should reject order with no items: 0.8ms
OrderDeciderTest > cannot cancel shipped order: 0.6ms

Total: 2.6ms for 3 tests

Property-Based Testing

@Test
fun `total is always sum of line items`() {
    checkAll(Arb.orderCommand()) { command ->
        val result = OrderDecider.decide(command, null, Instant.now())

        result.onRight { events ->
            val event = events.first() as OrderEvent.OrderCreated
            val expectedTotal = event.items.fold(Money.ZERO) { acc, item ->
                acc + (item.price * item.quantity)
            }
            event.total shouldBe expectedTotal
        }
    }
}

Run 10,000 scenarios in under 1 second. Try that with Testcontainers.

Replay Debugging: Production Incident Reproduction

When something goes wrong in production, you can replay the exact scenario:

@Test
fun `reproduce production incident ORDER-2024-0115-001`() {
    // Exact state from production logs
    val state = Order(
        orderId = OrderId("ORDER-2024-0115-001"),
        status = OrderStatus.PENDING,
        // ... exact production state
    )

    // Exact command that caused the issue
    val command = OrderCommand.CancelOrder(
        orderId = OrderId("ORDER-2024-0115-001"),
        reason = "Customer request"
    )

    // Reproduce locally - DETERMINISTIC!
    val result = OrderDecider.decide(command, state, productionTimestamp)

    // Now we can debug, fix, and add a regression test
}

Checkpoint

  • Business logic is testable without infrastructure

  • Production bugs are reproducible locally

  • Confidence increases as cost decreases


Part VI — Architecture, DDD, and Longevity

Hexagonal Architecture with a Pure Core

Hexagonal Architecture works because it isolates purity at the center.

The Layers

1. Domain Core (Pure, Portable)

// ✅ No dependencies on infrastructure
// ✅ No annotations
// ✅ No framework coupling
// ✅ Testable in milliseconds

object OrderDecider {
    fun decide(command: OrderCommand, state: Order?, time: Instant): Either<OrderError, NonEmptyList<OrderEvent>>
}

object OrderEvolver {
    fun evolve(state: Order?, event: OrderEvent): Order?
}

2. Application Layer (Orchestration)

Adapts pure domain to Restate infrastructure.

3. Infrastructure (Adapters)

Kafka, PostgreSQL, Restate—all replaceable.

The Key Benefit: Swappable Infrastructure

Same domain, different infrastructure:

// Infrastructure 1: In-memory (testing)
class InMemoryOrderService(private val decider: OrderDecider) {
    private val states = mutableMapOf<OrderId, Order>()

    fun create(cmd: OrderCommand.CreateOrder, time: Instant): Either<OrderError, Order> {
        val state = states[cmd.orderId]
        return decider.decide(cmd, state, time).map { events ->
            val newState = events.fold(state, OrderEvolver::evolve)
            states[cmd.orderId] = newState!!
            newState
        }
    }
}

// Infrastructure 2: Restate (production durable execution)
@RestateComponent
@VirtualObject
class RestateOrderService(private val decider: OrderDecider) {
    // Uses ctx.run() for side effects, ctx.timer() for time
}

// Infrastructure 3: Traditional Spring Boot
@Service
class SpringOrderService(
    private val decider: OrderDecider,
    private val repository: OrderRepository
) {
    @Transactional
    fun create(cmd: OrderCommand.CreateOrder): Either<OrderError, Order> {
        val state = repository.findById(cmd.orderId)
        return decider.decide(cmd, state, Instant.now()).map { events ->
            val newState = events.fold(state, OrderEvolver::evolve)
            repository.save(newState!!)
            newState
        }
    }
}

Same OrderDecider.decide() in all three. Zero changes to business logic.


DDD: Aggregates, Entities, Value Objects

The Decider pattern aligns perfectly with Domain-Driven Design.

Value Objects (Immutable, Validated)

@JvmInline
@Serializable
value class OrderId(val value: String) {
    init {
        require(value.isNotBlank()) { "OrderId cannot be blank" }
    }
}

@Serializable
data class Money(val amount: Double, val currency: String) {
    init {
        require(amount >= 0) { "Money amount cannot be negative" }
        require(currency.length == 3) { "Currency must be 3-letter code" }
    }

    companion object {
        val ZERO = Money(0.0, "USD")
    }

    operator fun plus(other: Money): Money {
        require(currency == other.currency) { "Cannot add different currencies" }
        return Money(amount + other.amount, currency)
    }

    operator fun times(multiplier: Int): Money =
        Money(amount * multiplier, currency)
}

Aggregate Root (Consistency Boundary)

// Order is the root, LineItems are children
// All changes go through Order commands
// Cannot modify LineItems directly

// Invariants enforced in decide():
// - Order must have at least one item
// - Total must equal sum of line items
// - Status transitions must be valid

Ubiquitous Language

The Decider pattern makes domain language explicit:

// Commands use ubiquitous language
sealed interface OrderCommand {
    data class CreateOrder(...)
    data class ConfirmOrder(...)
    data class CancelOrder(...)
    data class ShipOrder(...)
}

// Events reflect business facts
sealed interface OrderEvent {
    data class OrderCreated(...)
    data class OrderConfirmed(...)
    data class OrderCancelled(...)
    data class OrderShipped(...)
}

// Errors are business-meaningful
sealed interface OrderError {
    data class OrderAlreadyExists(...)
    data class InvalidStateTransition(...)
    data class CannotCancelShipped(...)
}

Domain experts can read this code. That's the goal of DDD.


Final Synthesis

Calculation is timeless. Execution is replaceable.

Frameworks change. Runtimes change. Databases change.

Pure business rules survive all of them.


Pros and Cons: The Honest Assessment

Pros

1. Testability (Massive Win)

MetricTraditionalFunctional
Test startup15 seconds0 seconds
Single test2 seconds2 milliseconds
150 tests25 minutes0.3 seconds
Infrastructure neededDocker, PostgreSQL, KafkaNone

2. Portability (Future-Proof)

Same business logic runs on: In-memory, Restate, Spring Boot, Temporal, any future runtime.

3. Replay Safety (Correctness)

Pure functions can safely replay without duplicate charges, non-deterministic results, or state corruption.

4. Debuggability (Clarity)

All business rules in ONE place—decide().

5. Fearless Refactoring (Confidence)

Business logic has no dependencies → Safe to change. Tests run in milliseconds → Instant feedback.

Cons

1. Learning Curve (Upfront Cost)

New concepts: pure functions, Either types, immutability, Railway-oriented programming, Decider pattern. Time investment: 2-4 weeks to internalize.

2. Boilerplate (More Code)

More explicit types, more code—but every piece has a clear purpose.

3. Team Adoption (Social)

Not all developers are comfortable with functional programming concepts.

4. Not Always Worth It (Context Dependent)

Don't use this for: CRUD apps, internal tools, prototypes.

Do use this for: Money/transactions, multi-step workflows, high-concurrency entities, long-running processes.


A Practical Migration Path

  1. Pick one aggregate

  2. Extract decide

  3. Extract evolve

  4. Test in milliseconds

  5. Move side effects to the edges

No rewrites. No big bang.


Closing Thought

If your business logic cannot survive a crash, it does not belong in a distributed system.

Purity isn't an aesthetic choice anymore.

It's a correctness requirement.


What's Next

In Part 4, we'll build a complete Order Management System demonstrating:

  • Full implementation using these patterns

  • 6 traditional services → 2 Restate Virtual Objects

  • 800 lines → 400 lines

  • Complete Event Sourcing + CQRS

  • Working code in GitHub repository

  • Before/after metrics


Series Navigation

PartTitleStatus
1The Glue Code Tax✓ Complete
2Durable Execution✓ Complete
3The Functional Foundation📍 You are here
4OMS Demo: Complete ImplementationNext →
5Building Reliable AI Agents

Resources

Functional Programming:

Domain-Driven Design:

Hexagonal Architecture:

Restate:


About the Author

Ravi Lanka is a Senior Backend Engineer, building production distributed systems that handle global container logistics. He specializes in event-driven architectures, CQRS/Event Sourcing, functional programming (Kotlin/Arrow-KT), and durable execution frameworks.

Eight years of writing retry logic, distributed locks, and state machines taught him: there's a better way.

🔗 Connect: GitHub | LinkedIn 📦 Code: restate-oms-demo


Using functional programming in your distributed systems? Found a better way to structure domain logic? I'd love to hear your approach—drop a comment or DM me on LinkedIn.

Durable Execution for Production Systems

Part 3 of 3

A 5-part series on eliminating infrastructure complexity with durable execution, functional programming, and modern distributed systems patterns.

Start from the beginning

I Deleted 500 Lines of Infrastructure Code. Nothing Broke.

The Glue Code Tax: Why 60% of Your Microservices Code is Infrastructure