skip to content

'Closure of operations' is a Supple Design pattern where an operation's return type is the same type as its arguments -- for example, Money.add(Money other): Money rather than Money.add(Money other): BigDecimal. What problem does this solve, and when does forcing closure actually hurt the design?

level: seniorimportance: should knowfreq 35%

answer

  1. same-type in, same-type out
  2. Money.add(Money): Money is chainable
  3. avoids leaking raw representation like BigDecimal
  4. doesn't work when result is genuinely a different concept
  5. forcing closure creates an overly permissive type

basics

~20 s

If combining two things of a type gives you back the same type, you can chain operations endlessly (add, add, add) without ever stepping outside that type into something less meaningful, like a raw number that's lost its currency and rules.

solid answer

~40 s

Closure of operations keeps an operation's result within the same conceptual set as its inputs -- add(Money): Money, union(Set): Set -- so results can be fed straight back into further operations without translation, and without leaking internal representation like a raw BigDecimal that's lost the currency and rounding rules Money enforces. This dramatically simplifies interfaces and keeps the abstraction self-contained. The trade-off is that not every meaningful operation naturally closes: Money.divide(int): Money risks losing precision that a Money type can't represent without a rounding decision, and forcing an operation to return the same type when the domain answer is genuinely a different concept, e.g. Order.cancel(): Order when the real result is a CancelledOrder with different valid operations, produces an awkward, overly permissive type that lets you call ship() on something that was just cancelled.

go deeper

for a junior

Can recognize the basic pattern when shown Money.add(Money): Money and explain in plain terms why that's more convenient than getting a raw number back.

for a middle

Applies closure when designing simple value-object operations (add, combine, merge) and returns the domain type instead of a primitive, while accepting guidance on trickier cases like division.

for a senior

Judges case by case whether an operation's true result is the same concept as its inputs, and chooses an explicit different type (state-transition pattern) instead of forcing closure when that's a better fit.

for a principal

Sets library or type design conventions for when to apply closure across a domain model, recognizes the connection to immutability and algebraic structure, and can explain to a team why forced closure sometimes hides a missing type in the model.

## What closure means **Closure of operations** is a narrower, specific pattern within side-effect-free functions: design an operation so its return type is the same type as its operand(s), the way `Money.add(Money other): Money` returns another `Money` rather than a raw `BigDecimal`, or `Set<T>.union(Set<T>): Set<T>` returns another `Set<T>` rather than some intermediate collection type. Mechanically, applying it means checking, for each operation on a type, whether the natural result can be expressed as an instance of that same type or a type in the same closed family, and if so, returning that instead of unwrapping into a more primitive representation. ## Why it pays This exists for two compounding reasons. 1. **First, composability**: if `add` returns `Money`, you can chain further operations directly, such as `price.add(tax).add(shipping).subtract(discount)`, without any intermediate conversion step, the same way integer addition returns another integer you can keep adding to. If it returned `BigDecimal` instead, every chained call would need an explicit re-wrap back into `Money`, defeating the purpose of having a `Money` type at all. 2. **Second, encapsulation**: `Money` typically enforces invariants a raw number doesn't -- currency matching, rounding mode, maybe a minimum denomination -- and returning the domain type instead of a primitive means every consumer of the result automatically continues to benefit from those guarantees instead of having to re-derive or forget them. ## Where the pattern stops fitting The pattern doesn't always fit. - Some operations naturally lose information going back into the same type -- `Money.divide(3): Money` on a ten-dollar amount divided three ways can't return three exact `Money` values without a rounding rule, so the type either needs an explicit remainder-aware division method or accepts a rounding policy as part of the contract, which is more design work than a naive same-type return. - More importantly, not every operation's true result is conceptually the same kind of thing as its inputs, and forcing closure onto those cases produces an **overly permissive type** -- the cost of a badly-fitted closure is hidden illegal states that the type system can no longer prevent. ## Failure modes The clearest failure mode is exactly that: modeling `Order.cancel(): Order`, returning the same order marked cancelled so it 'looks closed' like `Money.add()`, means nothing in the type system stops a caller from later calling `cancelledOrder.ship()` -- the type still claims to be a full, live `Order` with every operation available, when semantically a cancelled order shouldn't support most of them. The bug that results, shipping something that was cancelled, only gets caught by a runtime check, if at all, rather than being impossible to write in the first place. Another failure mode appears with mutable receivers: a team notices `add()` is 'returning the same type' and decides to skip allocating a new instance, mutating the receiver instead and returning void -- which breaks the immutability that made chaining safe in the first place, since two variables that supposedly hold 'the price before tax' and 'the price after tax' can now silently become the same mutated object. ## Genuine closure versus a missing type - `Vector2D.add(Vector2D other): Vector2D`, implemented to allocate and return a new immutable `Vector2D` rather than mutating either operand, is the pattern working correctly: `a.add(b).add(c)` reads naturally, no operand is silently changed underneath a caller holding a reference to it, and the whole family of vector operations can be composed freely. - Contrast that with `Order.cancel()`: the correct application of Supple Design here isn't to force closure but to recognize the domain actually has two related-but-different concepts -- introduce a distinct `CancelledOrder` type, or a sealed hierarchy with `Order` as a sum type of `ActiveOrder`, `CancelledOrder`, and `ShippedOrder`, so the compiler, not a runtime check, prevents `ship()` from ever being called on something that's already cancelled. Recognizing which of these two situations you're in, genuine closure versus a missing type, is the actual skill the pattern requires.

  • Why is closure of operations described as a special, narrower case of side-effect-free functions?
    It's a naming/typing refinement on top of purity: a side-effect-free function just promises not to mutate state, while closure additionally constrains what type the pure result comes back as, specifically the same type as the operands, so the operation composes cleanly with itself and other operations on that type without an intermediate conversion step.
  • Give an example where forcing closure of operations would be a design mistake.
    Order.cancel(): Order looks closed, but a cancelled order isn't really the same concept as a live order -- it shouldn't accept addLineItem() or ship() anymore. Modeling it as a distinct CancelledOrder type, even if it costs an explicit state-transition step instead of a chainable same-type return, prevents illegal operations at compile time, which forced closure would have allowed.
  • How does closure of operations interact with immutability?
    It works best paired with immutable value types: Money.add() returning a new Money rather than mutating the receiver avoids aliasing bugs, since callers can trust that neither operand changed and the result is a fresh, independent value they can pass around freely.

Like arithmetic on integers: 2 + 3 gives you another integer you can keep adding to, not a 'sum-record' object you have to unwrap first. Closure of operations tries to give your domain types that same 'stays in the family' property.

saying these in an interview costs you the question

  • forcing every domain method to return the same type even when the result is conceptually different, e.g. cancel() returning Order
  • returning a raw primitive or library type like BigDecimal or String that discards the domain type's invariants
  • confusing closure of operations with just 'the method doesn't mutate anything'
  • claiming closure applies to every arithmetic-like operation without checking whether precision or representation actually survives, e.g. divide
  • not recognizing that closure is about the type of the RESULT, not about visibility or access modifiers

context