Skip to content
← All insights
Functional Scala16 min read

Referential transparency and immutability in Scala

What referential transparency and immutability mean in working Scala code, the reasoning and concurrency advantages they provide, and where controlled state remains the better engineering choice.

Referential transparency is a substitution test

An expression is referentially transparent when replacing it with the value it produces does not change the program’s observable behaviour. The definition is stricter than “this function usually returns the same answer”: evaluation must not perform a hidden write, read changing external state, throw unexpectedly or depend on when it runs.

This gives a practical review technique. Name an expression, use the name twice and ask whether replacing both uses with the original expression changes the result. If it does, timing or state is part of the operation even when the method signature does not show it.

Referential transparency applies to expressions. Immutability applies to values that do not change after construction. They support each other, but they are not synonyms: a function can allocate a new immutable value while still reading the clock, and a pure calculation can inspect an explicitly supplied mutable snapshot without mutating it.

Apply the substitution testscala 2.13
def total(net: BigDecimal, taxRate: BigDecimal): BigDecimal =
  net + (net * taxRate)

val amount = total(BigDecimal(100), BigDecimal("0.2"))
(amount, amount)

// Replacing amount with total(...) twice changes no behaviour.
(total(BigDecimal(100), BigDecimal("0.2")),
 total(BigDecimal(100), BigDecimal("0.2")))

Hidden inputs break local reasoning

A method that reads the current time, random generator, environment or mutable singleton has more inputs than its parameters admit. A caller cannot explain its result from the signature alone, and a test must manipulate ambient state or accept nondeterminism.

Pass a stable value or a narrow capability into the operation. The outer application still reads the real clock or configuration, but the domain decision receives the value it needs explicitly. This keeps effects at an identifiable boundary rather than pretending they do not exist.

Not every dependency needs a trait. Supplying an Instant, function or immutable configuration value is often clearer than creating an interface solely for one test.

Move time to the boundary of the decisionscala 2.13
def hasExpired(expiry: Instant, now: Instant): Boolean =
  !expiry.isAfter(now)

def check(account: Account, clock: Clock): AccountStatus = {
  val now = clock.instant()
  if (hasExpired(account.expiry, now)) Expired else Active
}

// The outer operation reads time once. The rule remains pure.

Immutable values make change explicit

An immutable object does not alter after construction. A transition produces a new value, allowing the old and new states to be compared, logged or used independently. Case classes and Scala’s immutable collections make this style concise through copy, updated, map and fold.

The val keyword prevents a reference from being reassigned; it does not make everything reachable through that reference immutable. A val containing an Array, mutable Buffer or Java object can still observe changes. Review the complete object graph when stability matters.

Persistent collections share unchanged internal structure, so producing an updated Map or Vector does not normally copy every element. The API presents values while the implementation reuses safe structure.

Return the next account statescala 2.13
final case class Account(
  id: AccountId,
  balance: BigDecimal,
  tags: Set[String]
)

def credit(account: Account, amount: BigDecimal): Account =
  account.copy(balance = account.balance + amount)

val before = Account(id, BigDecimal(20), Set("new"))
val after  = credit(before, BigDecimal(15))

before.balance // 20
after.balance  // 35

The first advantage is local reasoning

Pure expressions can be understood from their inputs and result without reconstructing a sequence of hidden mutations. Refactoring a calculation into smaller functions is safer because evaluation order matters only where data dependencies require it.

This becomes valuable in long-lived services where business rules survive several frameworks and delivery teams. A pure transition can be exercised from a service, migration or test without constructing Play requests, repositories or dependency-injection graphs.

Local reasoning does not guarantee a good domain model. A pure function with ten Boolean parameters can still be confusing. Types, naming and ownership remain important; purity removes one source of surprise rather than every design problem.

Tests become smaller and more diagnostic

A pure function test supplies values and asserts a value. It does not need a mock for time, a database cleanup step or an eventually block. Property-based tests can generate many inputs because each case is isolated from the previous one.

Immutability also prevents tests from accidentally sharing a modified fixture. A base value can be copied into several scenarios without one test changing what another test observes.

The limitation is equally important: a unit test for pure logic cannot prove JSON decoding, MongoDB queries, HTTP status codes or deployment wiring. Keep integration tests for those real boundaries instead of treating functional design as a replacement for integration evidence.

Test a transition as valuesscala 2.13 / ScalaTest
"credit" should {
  "increase the balance without changing the input" in {
    val before = Account(id, BigDecimal(20), Set("new"))

    val after = credit(before, BigDecimal(15))

    after.balance shouldBe BigDecimal(35)
    before.balance shouldBe BigDecimal(20)
  }
}

Immutable state reduces concurrency hazards

A value that cannot change can be shared between threads without locks protecting its fields. Functions transforming that value do not race over partially applied updates. This removes a large class of accidental concurrency defects.

It does not make the complete application thread-safe. Two requests can read the same account snapshot, independently calculate updates and then overwrite one another in a database. Atomic writes, optimistic locking or transactions still need to protect shared external state.

Functional effect types provide safe coordination primitives such as Ref when state must change inside one process. The advantage comes from making the state transition explicit and controlled, not from denying that stateful applications exist.

Make an in-process transition atomicscala / Cats Effect
import cats.effect.{IO, Ref}

def credit(
  balances: Ref[IO, Map[AccountId, BigDecimal]],
  id: AccountId,
  amount: BigDecimal
): IO[Unit] =
  balances.update { current =>
    current.updated(id, current.getOrElse(id, BigDecimal(0)) + amount)
  }

// Ref owns the mutation boundary; the update function transforms
// one immutable Map value into the next.

Effects can remain values until the application runs them

Cats Effect IO can describe a console write, database call or asynchronous workflow as a value. Constructing the IO does not execute that work, so the description can be composed with map and flatMap before the runtime interprets it at the application edge.

This preserves referential reasoning about program descriptions. Reusing an IO value describes running the same action again when interpreted; eager effects performed while constructing that IO still escape the model.

Scala Future has different timing. Creating a Future normally schedules its body immediately. Replacing a val holding one Future with the Future expression in two places can schedule the work twice, so similar map and flatMap syntax does not give both types the same evaluation semantics.

Construction timing changes substitutionscala / Cats Effect
val readId: IO[UUID] = IO(UUID.randomUUID())

val twoReads: IO[(UUID, UUID)] = for {
  first  <- readId
  second <- readId
} yield (first, second)

// readId is a reusable description. Each execution performs the read.

val eager: Future[UUID] = Future(UUID.randomUUID())
// Reusing eager observes one scheduled computation; replacing it with
// Future(...) twice schedules two computations.

Immutability has costs and controlled mutation can be better

Creating new values can increase allocation and garbage collection in a hot loop. Persistent collections are efficient for general application work, but an Array or mutable builder can be substantially faster for a measured low-level operation.

Some algorithms are naturally stateful: parsers, caches, counters, connection pools and incremental buffers all maintain changing state. Wrapping them in layers of immutable copying can obscure the operation and make performance worse without improving the public contract.

Use mutation inside a small owned scope when measurement or the algorithm justifies it. Do not publish the mutable object or let unrelated callers share it. A function can use a local builder internally and still present a referentially transparent interface if callers cannot observe the intermediate mutation.

Contain mutation behind a value-returning boundaryscala 2.13
def normalise(values: Iterable[String]): Vector[String] = {
  val builder = Vector.newBuilder[String]

  values.foreach { raw =>
    val value = raw.trim.toLowerCase
    if (value.nonEmpty) builder += value
  }

  builder.result()
}

// The builder is local. Callers observe only the returned Vector.

Boundary effects should be explicit, not eliminated

A production service must read requests, query databases, publish responses, write logs and observe time. The goal is not a program without effects; it is a program where effects have clear owners and pure decisions are not unnecessarily entangled with them.

In a Play application, a controller can validate transport input, a repository can own MongoDB interaction and an application service can compose them. Domain calculations between those boundaries can remain ordinary functions over immutable values.

Adopt the style where it improves a concrete property: safer refactoring, deterministic tests, controlled concurrency or clearer failure handling. Avoid turning every value into an effect, copying large structures without evidence or introducing abstract F types where one concrete runtime is easier to own. Referential transparency is a reasoning tool, not a purity score.

  • Can the result be explained from the declared inputs?
  • Is changing state owned by one clear boundary?
  • Would an immutable snapshot simplify concurrency or tests?
  • Is mutation local and unobservable where performance requires it?
  • Are database and HTTP behaviours still integration-tested?
  • Does the abstraction make application execution easier to understand?