Your Business Logic is Hostage to Your Database
Why Pure Functions Are the Only Code Pattern That Survives a Crash

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 corePart 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:
Crashes happen — Hardware fails, deployments restart, networks partition
Scale demands distribution — Single nodes can't handle production load
Replay is the recovery mechanism — Frameworks re-execute your code after failures
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?
Can Spring Boot start? (tested 150 times)
Can PostgreSQL accept connections? (tested 150 times)
Can Kafka receive messages? (tested 150 times)
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:
Business logic is invisible — Scattered among infrastructure calls
Untestable without infrastructure — Needs DB, HTTP, Kafka
Non-deterministic — Different
UUID.randomUUID()each runCoupled to Spring — Can't move to Restate/Temporal without rewrite
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.evolvecontains zero rules.
What changed:
| Traditional | Functional |
| Business logic mixed with infrastructure | Pure functions, separated concerns |
| Requires DB/HTTP/Kafka to test | Requires nothing—just call the function |
| 22 seconds per test | 2 milliseconds per test |
| Non-deterministic (side effects) | Deterministic (pure) |
| Framework-coupled | Framework-agnostic |
| Impossible to replay safely | Replay 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?
Testability: Test ALL business rules by testing decide()
Replay safety: evolve() can safely replay events without re-validating
Event sourcing: Rebuild state by folding events through evolve()
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 eitherLeft(error)orRight(success)RaiseDSL: Railway-oriented programming with early returnsComposition: 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:
eitherblock: Railway-oriented programming DSLensure(condition) { error }: Early return on failure (short-circuits)All business rules explicit: Every validation is visible
Type-safe errors: Can't forget to handle an error case
No exceptions: Error path is part of the type
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
ifstatements — No conditional logicNo 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:
decide()is pure — Same inputs = Same outputs (OrderCreated event identical)ctx.run()is journaled — Payment result cached (no re-execution)evolve()is pure — Safe to re-execute (deterministic state transition)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 domain —
decide(),evolve()Side effects at edges —
ctx.run(),ctx.timer()State in Restate —
ctx.get(),ctx.set()Errors as exceptions —
TerminalExceptionfor 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)
| Metric | Traditional | Functional |
| Test startup | 15 seconds | 0 seconds |
| Single test | 2 seconds | 2 milliseconds |
| 150 tests | 25 minutes | 0.3 seconds |
| Infrastructure needed | Docker, PostgreSQL, Kafka | None |
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
Pick one aggregate
Extract
decideExtract
evolveTest in milliseconds
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
| Part | Title | Status |
| 1 | The Glue Code Tax | ✓ Complete |
| 2 | Durable Execution | ✓ Complete |
| 3 | The Functional Foundation | 📍 You are here |
| 4 | OMS Demo: Complete Implementation | Next → |
| 5 | Building Reliable AI Agents |
Resources
Functional Programming:
Functional Event Sourcing Decider - Jérémie Chassaing's original article
Railway Oriented Programming - Scott Wlaschin's foundational work
Arrow-KT Documentation - Functional programming for Kotlin
Domain-Driven Design:
Domain-Driven Design - Eric Evans' original work
Implementing Domain-Driven Design - Vaughn Vernon
Hexagonal Architecture:
- Hexagonal Architecture - Alistair Cockburn
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.

