Essential and Accidental Complexity: How Far Can Functional Programming Go in Spring?
0. Start with familiar code
An order service built with Kotlin and Spring usually looks something like this.
@Transactional
fun create(request: OrderRequest): OrderResponse {
val order = GoodsOrder()
for (itemRequest in request.items) {
val goods = goodsService.getGoods(itemRequest.goodsId)
goods.decreaseStock(itemRequest.quantity)
val subtotal = goods.calculateTotalPrice(itemRequest.quantity)
val discount = discountService.calculateDiscount(goods, quantity, subtotal)
val orderItem = OrderItem(
goods = goods,
quantity = itemRequest.quantity,
unitPrice = goods.price,
subtotal = subtotal,
discountAmount = discount
)
order.addItem(orderItem)
}
order.calculatePrices()
return OrderResponse.from(orderRepository.save(order))
}
It works. It works well. But look closely, and side effects are hiding throughout it.
Start with decreaseStock(). It changes state, but its signature doesn't tell us that.
fun decreaseStock(quantity: Int) {
if (stock < quantity) throw OutOfStockException(...)
stock -= quantity
}
Its return type is Unit. You have to open the implementation to discover that it modifies Goods.stock. cancel() goes further: a method on GoodsOrder also changes inventory on Goods.
fun cancel() {
changeStatus(OrderStatus.CANCELLED)
items.forEach { it.goods.increaseStock(it.quantity) }
}
Next, a single loop mixes database access, stock validation, price calculation, discounts, and mutation. Testing only the business logic looks like this.
val goodsService = mock<GoodsService>()
val discountService = mock<DiscountService>()
val orderRepository = mock<OrderRepository>()
whenever(goodsService.getGoods(1L)).thenReturn(goods)
whenever(discountService.calculateDiscount(any(), any(), any())).thenReturn(1000)
whenever(orderRepository.save(any())).thenAnswer { it.arguments[0] }
Three mocks before the test even begins. All I wanted to check was whether ordering two items at KRW 10,000 each produces a total of KRW 20,000.
Finally, decreaseStock() throws when stock is insufficient. But is that really exceptional? In an order service, insufficient stock is an expected business outcome.
Why does this matter in practice? Hidden side effects are hard to catch in review. A new teammate calling cancel() without knowing it affects inventory can introduce bugs. Mixing I/O and calculation makes tests expensive; enough time spent setting up mocks makes skipping tests feel faster.
Exception-based errors don't expose possible failures in the signature. A caller can ship without handling one and discover it through a production incident. Each issue looks small, but together they affect development speed and reliability as the service grows.
Eventually, these frustrations lead you toward FP: immutable data, pure functions, and typed errors. They seem to target exactly these problems.
I tried pushing FP as far as I could in a Kotlin/Spring order service. I turned JPA entities into data class instances, replaced exceptions with sealed classes, made discount policies higher-order functions, and introduced Arrow's Either. I'll call the conventional code above iteration 1 and the FP version iteration 2.
Some changes worked; others didn't. The interesting part was that the obstacles fell into two different categories.
One was a limitation of the tools: JPA resisting val, or @Transactional not recognizing Either. Different tools can remove those obstacles.
The other came from the problem itself: tracking change requires state, and loosening encapsulation weakens a safety boundary. These trade-offs remain regardless of the tools.
In his 1986 essay No Silver Bullet, Fred Brooks distinguishes accidental complexity from essential complexity. That distinction matters because the responses are different.
An accidental barrier is a choice: change tools, or respect their conventions and work around it. An essential barrier requires compromise. There is no perfect answer, so you have to design the trade-off deliberately.
This is a record of pushing FP into a Kotlin/Spring order service and asking, for each obstacle: is this essential or accidental?
1. Can JPA entities be immutable? An accidental barrier
If mutable state causes side effects, immutability seems the obvious answer. Kotlin gives us data class and val, so I tried applying them directly to JPA entities.
// Before: mutable entity
@Entity
class GoodsOrder(
@OneToMany(mappedBy = "order", cascade = [CascadeType.ALL])
var items: MutableList<OrderItem> = mutableListOf(),
@Enumerated(EnumType.STRING)
var status: OrderStatus = OrderStatus.PENDING,
var totalPrice: Int = 0,
var totalDiscount: Int = 0,
var finalPrice: Int = 0,
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
var id: Long = 0
) {
fun addItem(item: OrderItem) {
items.add(item)
item.order = this
}
fun changeStatus(newStatus: OrderStatus) {
if (!status.canTransitionTo(newStatus)) throw InvalidStatusException(...)
this.status = newStatus
}
}
// After: an immutable entity experiment
@Entity
data class GoodsOrder(
@OneToMany(mappedBy = "order", cascade = [CascadeType.ALL])
val items: List<OrderItem> = emptyList(),
@Enumerated(EnumType.STRING)
val status: OrderStatus = OrderStatus.PENDING,
val totalPrice: Int = 0,
val totalDiscount: Int = 0,
val finalPrice: Int = 0,
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long = 0
)
// Return a new object with copy() instead of mutating
fun GoodsOrder.withItem(item: OrderItem): GoodsOrder =
copy(items = items + item)
fun GoodsOrder.withStatus(newStatus: OrderStatus): GoodsOrder =
copy(status = newStatus)
var became val, and mutation methods disappeared. Chains such as order.withItem(item).withCalculatedPrices() express intent without manually sequencing mutations. It compiles. The app starts. INSERT works.
But UPDATE doesn't.
val updated = order.withStatus(OrderStatus.CONFIRMED)
orderRepository.save(updated)
// In the DB: status = PENDING. Unchanged.
// No exception or error log. A silent failure.
Collections are worse.
val order = orderRepository.findById(1L).get()
println(order.items.size)
// Output: 0
// Three items were inserted. No 500: a successful response with an empty array.
Not a compilation error—a silent runtime failure. That's more dangerous than a straightforward rejection: without tests, the first report could be a production user saying their order status never changed.
Why does this happen?
This experiment ran into four assumptions in Hibernate's model.
Dirty checking: Hibernate detects changes to managed entity fields at transaction completion and generates UPDATE statements. With no changes to the original fields, it sees nothing to update.
copy() and the persistence context: copy() creates another JVM object, not the instance Hibernate was managing. Calling save() doesn't make that copy a mutation of the original managed object; in this experiment, it led toward insertion rather than the intended update.
PersistentBag: Hibernate replaces @OneToMany collections with its own implementation. In this setup, the immutable property prevented that replacement, leaving the constructor's emptyList() in place.
Generated equals() and hashCode(): a data class derives them from its properties, whereas entity identity is tied to its ID. Changing status can change equality and hashing—an awkward fit for managed entity identity and collections.
Then why does it compile?
Kotlin's kotlin("plugin.jpa") generates a no-argument constructor, and kotlin("plugin.spring") opens the relevant classes. With those framework requirements satisfied at build time, the data class plus val experiment builds without a warning about its persistence behavior.
A warning about the risks of declaring a JPA entity as a data class would have helped. There wasn't one. It's natural to assume something that compiles will work.
Verdict: accidental complexity
This barrier comes from JPA/Hibernate's design choices.
Other persistence tools accommodate immutable data. With Exposed or jOOQ, val and data classes fit naturally into the surrounding model. Hibernate's mutable-entity approach is a historical design, not a requirement of persistence itself.
There is an essential ingredient mixed in, though: tracking changes requires a representation of state. Dirty checking is one accidental implementation; modifying existing state is part of the underlying problem. I'll return to that distinction in section 4.
2. Work around the barrier: separate the domain model
If immutable JPA entities aren't a good fit, change the approach. Rather than applying FP to the entity itself, create a separate model and move business logic into it.
Leave JPA entities as Hibernate expects: var, ordinary classes, and MutableList. Extract only the data needed for business calculations into an immutable model.
/** Immutable domain model: no JPA dependency */
data class GoodsSnapshot(
val id: Long,
val name: String,
val price: Int,
val stock: Int,
val categoryDiscountRate: Int
)
Then write pure functions over that model. They know nothing about the database or Spring, and don't throw business exceptions.
/** Functional Core: pure input-to-output calculation */
object OrderCalculator {
fun calculate(
inputs: List<OrderItemInput>,
goodsMap: Map<Long, GoodsSnapshot>,
discountPolicy: (GoodsSnapshot, Int, Int) -> Int
): OrderResult {
val aggregated = inputs.groupBy { it.goodsId }
.map { (goodsId, group) -> OrderItemInput(goodsId, group.sumOf { it.quantity }) }
for (input in aggregated) {
val goods = goodsMap[input.goodsId]
?: return OrderResult.Failed(listOf(OrderError.GoodsNotFound(input.goodsId)))
if (goods.stock < input.quantity)
return OrderResult.Failed(listOf(OrderError.OutOfStock(goods.id, goods.stock, input.quantity)))
val subtotal = goods.price * input.quantity
val discount = discountPolicy(goods, input.quantity, subtotal)
// ... assemble the result
}
// ... result assembly omitted
return OrderResult.Success(OrderCalculation(items, totalPrice, totalDiscount, finalPrice, stockDeductions))
}
}
Return errors as sealed-class values rather than exceptions.
sealed class OrderError {
data class OutOfStock(val goodsId: Long, val available: Int, val requested: Int) : OrderError()
data class GoodsNotFound(val goodsId: Long) : OrderError()
}
sealed class OrderResult {
data class Success(val calculation: OrderCalculation) : OrderResult()
data class Failed(val errors: List<OrderError>) : OrderResult()
}
The service coordinates I/O: fetch entities, convert them to snapshots, call the core, then assemble and persist entities from the result.
/** Imperative Shell: coordinate I/O */
@Transactional
fun create(request: OrderRequest): OrderResponse {
// 1. I/O: fetch goods from the database
val goodsEntities = request.items.map { it.goodsId }.distinct()
.associateWith { goodsService.getGoods(it) }
// 2. Convert JPA entities to immutable snapshots
val snapshots = goodsEntities.mapValues { (_, g) -> g.toSnapshot() }
// 3. Call the core: pure computation, no DB or business exceptions
val inputs = request.items.map { OrderItemInput(it.goodsId, it.quantity) }
val result = OrderCalculator.calculate(inputs, snapshots, discountPolicy)
// 4. Throw on failure; assemble and persist entities on success
return when (result) {
is OrderResult.Failed -> throw result.errors.first().toException()
is OrderResult.Success -> {
val calc = result.calculation
for (deduction in calc.stockDeductions) {
goodsEntities[deduction.goodsId]!!.applyStockDeduction(deduction.quantity)
}
val order = GoodsOrder()
for (itemCalc in calc.items) {
order.addItem(OrderItem(
goods = goodsEntities[itemCalc.goodsId]!!,
quantity = itemCalc.quantity,
unitPrice = itemCalc.unitPrice,
subtotal = itemCalc.subtotal,
discountAmount = itemCalc.discountAmount
))
}
order.totalPrice = calc.totalPrice
order.totalDiscount = calc.totalDiscount
order.finalPrice = calc.finalPrice
OrderResponse.from(orderRepository.save(order))
}
}
}
This structure has a name: Functional Core / Imperative Shell, presented by Gary Bernhardt in his 2012 talk Boundaries. The core computes through pure functions; the shell orchestrates I/O.

How does testing change?
This was the most immediately noticeable improvement.
// Before: three mocks
val goodsService = mock<GoodsService>()
val discountService = mock<DiscountService>()
val orderRepository = mock<OrderRepository>()
whenever(goodsService.getGoods(1L)).thenReturn(goods)
whenever(discountService.calculateDiscount(any(), any(), any())).thenReturn(1000)
whenever(orderRepository.save(any())).thenAnswer { it.arguments[0] }
val service = OrderService(orderRepository, goodsService, discountService)
// Only now can the test begin
// After: zero mocks
val result = OrderCalculator.calculate(
inputs = listOf(OrderItemInput(1L, 2)),
goodsMap = mapOf(1L to GoodsSnapshot(1L, "T-shirt", 10000, 5, 10)),
discountPolicy = { _, _, subtotal -> (subtotal * 0.10).toInt() }
)
assert(result is OrderResult.Success)
assertEquals(18000, (result as OrderResult.Success).calculation.finalPrice)
// Done. No DB, Spring context, or mocks.
Without a database, tests run in milliseconds. Identical inputs produce identical outputs. Instead of investigating environmental reasons for failure, I can inspect the inputs.
The cost: a larger shell
Iteration 1's create() was 15 lines. After FC/IS, it was 35. Mapping from entities to snapshots and back from core results added code.
But much of that extra code made previously implicit behavior explicit.
For example, aggregating quantities when the same product appears twice. Iteration 1 happened to work through instance identity in JPA's first-level cache. Iteration 2 explicitly aggregates with groupBy. One extra line removed a dependency on JPA internals.
Pure mapping—toSnapshot and entity assembly—is genuine overhead. Separate models necessarily introduce it. Whether testability pays for that cost depends on the project. As business logic grows more complex, the core's benefit grows while much of the mapping cost stays fixed.
With FC/IS in place, I looked for more logic to move into the core: stock validation and discount policies. What friction would each introduce, and was it essential or accidental?
3. Expand the territory of pure functions
3-1. Stock validation: the essential tension between encapsulation and composition
In iteration 1, stock deduction was a method on Goods.
class Goods {
fun decreaseStock(quantity: Int) {
if (stock < quantity) throw OutOfStockException(
"Insufficient stock. Available: $stock, requested: $quantity"
)
stock -= quantity
}
}
That method does two things: validate availability and deduct stock. From an OOP perspective, that's natural: the object protects its own invariant, stock >= 0.
But iteration 2 already validates stock in OrderCalculator. Calling decreaseStock() in the shell validates twice. The core returns sealed-class errors; the entity throws exceptions. The two patterns conflict.
I separated validation from execution.
// Core: a pure function of two integers
fun validateStock(stock: Int, quantity: Int): StockValidation =
if (stock >= quantity) StockValidation.Sufficient
else StockValidation.Insufficient(available = stock, requested = quantity)
// Entity: deduct only. The core validates first.
class Goods {
fun applyStockDeduction(quantity: Int) {
stock -= quantity
}
}
validateStock(10, 3) returns StockValidation.Sufficient. No mocks, no database—one function call.
This also removed OutOfStockException from the entity's imports. The domain entity no longer depended on a Spring HTTP exception hierarchy through BusinessException and HttpStatus.
There is a trade-off. applyStockDeduction() doesn't validate. Someone bypassing the core could create negative stock. Iteration 1's method defended itself regardless of the caller.
Verdict: essential complexity.
This isn't a tool limitation. Encapsulation—objects protecting themselves—and composition—separating validation from execution—trade off in any language or framework. Looser encapsulation makes composition and testing easier but weakens the guard. FC/IS relies on the flow: the shell calls the core first and executes only its successful result. The compiler doesn't enforce that boundary; review must.
3-2. Discount policies: only an accidental barrier
After the tension around stock validation, converting discount policies offered a sharp contrast.
Iteration 1 used the Strategy pattern.
interface DiscountPolicy {
fun calculate(goods: Goods, quantity: Int, subtotal: Int): Int
}
@Component
class QuantityDiscountPolicy : DiscountPolicy {
override fun calculate(goods: Goods, quantity: Int, subtotal: Int): Int =
if (quantity >= 3) (subtotal * 0.10).toInt() else 0
}
@Component
class CategoryDiscountPolicy : DiscountPolicy {
override fun calculate(goods: Goods, quantity: Int, subtotal: Int): Int {
if (goods.category.discountRate <= 0) return 0
return (subtotal * goods.category.discountRate / 100.0).toInt()
}
}
@Service
class DiscountService(private val policies: List<DiscountPolicy>) {
fun calculateDiscount(goods: Goods, quantity: Int, subtotal: Int): Int =
policies.maxOfOrNull { it.calculate(goods, quantity, subtotal) } ?: 0
}
One interface, two implementations, and a service: four classes across two files.
But DiscountPolicy has only one method, calculate(). It's essentially a function of type (Goods, Int, Int) -> Int.
typealias DiscountPolicy = (GoodsSnapshot, Int, Int) -> Int
val quantityDiscount: DiscountPolicy = { _, quantity, subtotal ->
if (quantity >= 3) (subtotal * 0.10).toInt() else 0
}
val categoryDiscount: DiscountPolicy = { goods, _, subtotal ->
if (goods.categoryDiscountRate <= 0) 0
else (subtotal * goods.categoryDiscountRate / 100.0).toInt()
}
fun bestDiscount(policies: List<DiscountPolicy>): DiscountPolicy =
{ goods, quantity, subtotal ->
policies.maxOfOrNull { it(goods, quantity, subtotal) } ?: 0
}
val defaultDiscountPolicy = bestDiscount(listOf(quantityDiscount, categoryDiscount))
Four classes became three functions and one higher-order function. Two files became one. @Component and @Service disappeared, along with the dependency on the JPA entity. Accepting GoodsSnapshot made the policy usable directly in the core.
The shell's awkward adapter disappeared too. Previously, the discount service expected Goods while the core used snapshots, requiring a function wrapper. Once the policy itself accepted snapshots, there was nothing to adapt.
The lack of friction has a simple explanation: discounts here involve no I/O, database reads, or mutation. Product information, quantity, and subtotal go in; a discount amount comes out. Pure computation naturally fits a pure function.
Verdict: accidental complexity.
Implementing a single-abstract-method interface through classes and DI was a Spring convention. The Strategy pattern means making behavior replaceable, which function values accomplish too. Interfaces in OOP and function types in FP are different routes to treating behavior as a value.
If a policy read discount rates from a database, things would change. The shell would need to fetch those values before injecting them into the core. Hardcoded rates made this implementation a fortunate, simple case.
What the contrast reveals
Stock validation introduced an essential tension; discounts didn't. What's different?
Does the logic involve I/O? Discounts were pure computation. Stock validation was pure too, but deduction changes state; separating the two introduced tension with encapsulation.
Here, I/O determined the difficulty of the FP transition. Without it, there was little resistance. With it, separation exposed an essential trade-off. That was section 3's main finding.
4. Hit the barriers: essential and accidental together
Up to section 3, ordinary Kotlin features improved the structure without fighting Spring. From here, the story changes.
4-1. Why doesn't cancel() move cleanly into the core? An essential barrier
FC/IS worked well for create(). Could it do the same for cancel()?
Here's iteration 1's cancellation code.
fun cancel() {
changeStatus(OrderStatus.CANCELLED)
items.forEach { it.goods.increaseStock(it.quantity) }
}
Status-transition validation could become a pure function.
fun validateStatusTransition(from: OrderStatus, to: OrderStatus): StatusTransitionResult =
if (from.canTransitionTo(to)) StatusTransitionResult.Allowed(from, to)
else StatusTransitionResult.Denied(from, to)
validateStatusTransition(PENDING, CANCELLED) needs neither mocks nor a database. So far, so good.
The problem was restoring inventory: items.forEach { it.goods.increaseStock(it.quantity) }. I tried producing commands in the core, adding CancelCommand, StockRestoration, OrderItemSnapshot, and buildStockRestorations.
Then I deleted all of them.
The shell still had to execute through the JPA entity graph. A command saying ‘restore two units to goods ID 1’ merely added a translation step before traversing the same relationships again. It was YAGNI.
The key difference between create() and cancel() is this:

Verdict: essential complexity.
This isn't caused by JPA. Exposed or jOOQ would still require changing persisted state. Pure functions can validate or plan that change, but execution is necessarily stateful. This is the essential ingredient from section 1: modifying something that already exists entails a state transition. Dirty checking is just one implementation.
In this design, constructing new data favored FP; modifying existing managed objects favored OOP. That difference came from the use case, not merely the tool.
After cancellation, I pushed in another direction: what if I removed exceptions altogether?
4-2. What do we lose without exceptions? A cluster of accidental barriers
The core returned sealed-class errors, but the shell converted them back into exceptions for @ControllerAdvice. I removed that conversion: the service returned Either<OrderError, OrderResponse>, and the controller mapped it to HTTP.
// Before: service throws; @ControllerAdvice handles globally
@RestController
class OrderController {
@PostMapping @ResponseStatus(HttpStatus.CREATED)
fun create(@RequestBody request: OrderRequest): OrderResponse {
return orderService.create(request) // One line
}
}
// After: service returns Either; controller folds it
@RestController
class OrderController {
@PostMapping
fun create(@RequestBody request: OrderRequest): ResponseEntity<Any> {
return orderService.create(request).fold(
ifLeft = { it.toResponseEntity() },
ifRight = { ResponseEntity.status(HttpStatus.CREATED).body(it) }
)
}
}
Several losses appeared immediately.
Centralized handling through @ControllerAdvice disappeared. Previously, controllers simply called business logic while one handler mapped errors to HTTP. Now every controller method repeated a fold branch.
ResponseEntity<Any> also caused trouble. Success contained OrderResponse; failure contained ErrorResponse. The compiler saw only Any, and automatic Swagger documentation lost its useful response shape.
The project became inconsistent: orders used sealed-class errors, while products and categories still used exceptions. Two error-handling styles coexisted.
Then I noticed something ironic.
@ControllerAdvicewas not just a global exception handler. It was already providing the separation of concerns I wanted from FP.
It separated error handling from business logic and centralized it through AOP. Replacing it with controller-level sealed-class handling scattered that concern again.
FP doesn't automatically improve separation of concerns. Replacing a framework's existing mechanism can do the opposite.
Verdict: accidental complexity. Spring's exception-oriented handler is a design choice; a different framework, such as Ktor with StatusPages, offers different handling options. But replacing the mechanism within this Spring project cost more than the original problem. Accidental doesn't mean impossible—it can mean too expensive.
5. The @Transactional trap: inspect the bytecode
Although section 4-2 showed diminishing returns, I went one step further. The handwritten OrderResult started the experiment; propagating value-based errors through services and controllers led me to Arrow's Either<OrderError, T> and Raise DSL, using raise() and bind().
One question remained: when a @Transactional method returns Either.Left, does it roll back or commit?
It commits. To see why, open Arrow's implementation.
What Arrow Raise actually does
Inside either { }, raise(error) throws internally.
raise(error)
→ throw NoTrace(error, raise)
extends RaiseCancellationException
extends CancellationException
extends IllegalStateException
extends RuntimeException
The exception belongs to the runtime exception hierarchy. Spring normally rolls back on unchecked exceptions. Shouldn't this roll back too?
Execution order is the answer
either { } is an inline Kotlin function. Its try-catch is inlined into the method's bytecode.
@Transactional AOP proxy (outer)
└ actual method call
└ either { } try-catch (inner)
└ raise(error)
└ → throw NoTrace
└ catch (e: RaiseCancellationException) → return Either.Left(error)
└ method returns Either.Left normally
└ proxy sees a normal return, not an exception → commit
The inner either catch converts the exception to Either.Left before the transaction proxy can see it. The proxy observes a normal return and commits.
Why is that dangerous?
Our current flow is safe because pure calculation happens before I/O. A failed calculation returns Left without reaching inventory deduction.
But what if someone changes the order?
@Transactional
fun dangerousCreate(request: OrderRequest): Either<OrderError, OrderResponse> = either {
// I/O: deduct stock first
goodsEntities[goodsId]!!.applyStockDeduction(quantity)
// Core: calculate afterward
val calc = OrderCalculator.calculateEither(inputs, snapshots, policy).bind()
// ^^^^
// Left: raise() -> exception -> caught by either -> Either.Left
// @Transactional commits
// Stock deducted, no order created: inconsistent data.
}
With exception-based errors, an exception escaping the method triggers rollback even after earlier I/O, under the transaction's rollback rules.
Returning Either removes that automatic safety net. This isn't an Arrow bug; it's a structural mismatch between value-based errors and exception-based transaction management.
The Spring team's position
Spring Framework issue #27323, ‘Result return types for @Transactional Kotlin functions’, was declined.
The team declined to add recognition of value-based errors such as Result to @Transactional. A returned error value in this flow does not trigger exception-based rollback.
Verdict: accidental complexity. Rollback based on a return value is technically implementable; it isn't the mechanism chosen here.
But this is no isolated choice. @Transactional, @ControllerAdvice, and the HTTP response model fit an exception-oriented framework. Changing one crosses assumptions shared by the others.
Enough accidental complexity can feel essential. One barrier is manageable; barriers across every layer approach the cost of replacing the framework. Pushing value-based error handling throughout Spring means fighting its surrounding conventions.
6. A map of the barriers: where to push and where to work around
Here's the journey in one place.
Summary of the classifications
| Transition | Barrier | Response |
|---|---|---|
| Immutable JPA entities (§1) | Accidental | Work around: separate the domain model |
| FC/IS (§2) | — | Structure around the accidental barrier |
| Separate stock validation (§3-1) | Essential | Compromise: guard through execution flow |
| Discount policies (§3-2) | Accidental | Remove: pure computation fits naturally |
| Move cancellation into the core (§4-1) | Essential | Compromise: validate in core, execute in shell |
| Remove exceptions (§4-2) | Accidental | Work around: cost exceeds benefit |
| @Transactional + Either (§5) | Accidental | Work around: preserve the rollback safety net |
A pattern emerges.
In pure computation—discounts, stock checks, and order totals—FP was natural and useful. Accidental barriers were easy to bypass; essential trade-offs were manageable.
At framework boundaries, several accidental barriers appeared together. Each was individually negotiable, but their shared exception-oriented assumptions meant changing one disturbed the others.
Where to stop

The practical boundary was between pure computation and framework infrastructure. Push into the former; beyond that, you're negotiating the framework's conventions.
A practical structure: FP in the core, OOP in the shell, conversion at the boundary
// Core: pure functions, Either errors, no mocks required
object OrderCalculator {
fun calculate(
inputs: List<OrderItemInput>,
goodsMap: Map<Long, GoodsSnapshot>,
discountPolicy: DiscountPolicy
): Either<OrderError, OrderCalculation> = either {
// Pure computation only: no DB or business exceptions
}
}
// Domain model: immutable, independent of JPA
data class GoodsSnapshot(val id: Long, val price: Int, val stock: Int, ...)
// Discount policies: function types and higher-order functions
typealias DiscountPolicy = (GoodsSnapshot, Int, Int) -> Int
val defaultPolicy = bestDiscount(listOf(quantityDiscount, categoryDiscount))
// Shell: follow Spring conventions; convert Either to exceptions
@Service
class OrderService(...) {
@Transactional
fun create(request: OrderRequest): OrderResponse {
val calc = OrderCalculator.calculate(inputs, snapshots, defaultPolicy)
.getOrElse { error -> throw error.toException() }
// Boundary conversion preserves transactional rollback
// I/O: deduct stock, assemble entities, persist
...
}
}
// Controller: delegate; @ControllerAdvice handles errors
@RestController
class OrderController(private val orderService: OrderService) {
@PostMapping @ResponseStatus(HttpStatus.CREATED)
fun create(@RequestBody request: OrderRequest): OrderResponse =
orderService.create(request)
}
What does this structure accomplish?
- Core: pure functions, Either, and sealed classes, independently testable.
- Shell: Spring services, transactions, and exception-based handling, without fighting the framework.
- Boundary:
getOrElse { throw it.toException() }connects the two. Transactions retain rollback behavior,@ControllerAdvicehandles errors centrally, and controllers remain simple delegates. The core stays easy to test without discarding the framework's safety mechanisms.
7. Conclusion
One question ran through the experiment: is this barrier essential or accidental?
Essential barriers demand deliberate compromise. Looser encapsulation increases flexibility but weakens protection. Constructing new data and modifying existing state favor different structures in this design. No tool eliminates the trade-off.
Accidental barriers are choices. Separate models can avoid JPA's immutable-entity difficulties. Centralized value-based HTTP handling is possible too—but when its cost exceeds its benefit, working around the barrier is better.
Pushing the experiment made those distinctions concrete. Otherwise I would have stopped at ‘FC/IS sounds good.’ Trying it revealed how raise() interacts with transactions and how much separation @ControllerAdvice already provided.
Finally, FP versus OOP may be the wrong framing. The more useful distinction here was pure versus impure.
- Pure computation without I/O is easy to test and reason about, regardless of paradigm.
- Impure code introduces external dependencies and testing complexity, regardless of paradigm. FC/IS is fundamentally about separating pure computation from effects, not merely adopting FP. That boundary is where FP found a useful home in this Spring service.
This article concerns layers within one service. Error propagation between services—gRPC Status, failures in event-driven systems—and distributed transactions introduce another set of problems. Their essential and accidental components deserve separate treatment.
"No single development... by itself promises even one order of magnitude improvement." — Fred Brooks, "No Silver Bullet" (1986)
Forty years later, FP isn't a silver bullet either. But knowing which kind of barrier you're facing helps you decide where to push and where to work around.