Skip to content

Transactions

A transaction runs a block of code with a consistent view of your flags. Inside it you can:

  • override flags, without touching the provider;
  • cache each flag, so every read in the block sees the same value;
  • record every flag the block read, for an audit trail or a test assertion.

A transaction block: an overridden flag is served without calling the provider; the first read of another flag calls the provider and is cached; the repeat read is served from the cache; every read is recorded and returned in the TransactionResult.

A first transaction

val run: ZIO[FeatureFlags, FeatureFlagError, TransactionResult[(Boolean, Int)]] =
  FeatureFlags.transaction(overrides = Map("feature-a" -> true, "max-items" -> 50)) {
    for
      a <- FeatureFlags.boolean("feature-a", default = false) // true: overridden
      n <- FeatureFlags.int("max-items", default = 10)        // 50: overridden
    yield (a, n)
  }

val report: ZIO[FeatureFlags, FeatureFlagError, String] =
  run.map { tx =>
    s"result=${tx.result} flags=${tx.flagCount} overridden=${tx.overrideCount}"
    // result=(true,50) flags=2 overridden=2
  }

transaction returns a TransactionResult holding the block's result and what it read. Keys without an override are evaluated by the provider as usual:

// Keys without an override go to the provider as usual
val mixed: ZIO[FeatureFlags, FeatureFlagError, TransactionResult[(Boolean, Boolean)]] =
  FeatureFlags.transaction(Map("feature-a" -> true)) {
    FeatureFlags.boolean("feature-a", false) <*> FeatureFlags.boolean("feature-b", false)
  }

Parameters

Parameter Default Meaning
overrides Map.empty flag key → value to serve instead of asking the provider
context EvaluationContext.empty context for every evaluation in the block
cacheEvaluations true serve repeated reads of a key from the transaction
nested NestedPolicy.Fail what to do inside another transaction; see nesting

Overrides

An override is served as is: the provider is not asked, and the result has reason Cached. Overrides work even while the provider is not ready, which makes them a way to force known values in tests or during an outage.

The override must match the type the flag is read as; otherwise the read fails with OverrideTypeMismatch:

// "count" is read as an Int, but the override is a String: the read fails with OverrideTypeMismatch
val mismatch: ZIO[FeatureFlags, FeatureFlagError, TransactionResult[Int]] =
  FeatureFlags.transaction(Map("count" -> "not-a-number")) {
    FeatureFlags.int("count", 0)
  }

A custom flag type can be overridden with its value or with the value the provider would store:

enum Phase derives FlagType:
  case Off, DualWrite, ShardOnly

// A custom type can be overridden by its value, or by what the provider would store; both give Phase.DualWrite
val byValue: ZIO[FeatureFlags, FeatureFlagError, TransactionResult[Phase]] =
  FeatureFlags.transaction(Map("rollout-phase" -> Phase.DualWrite)) {
    FeatureFlags.value[Phase]("rollout-phase", Phase.Off)
  }

val byWireValue: ZIO[FeatureFlags, FeatureFlagError, TransactionResult[Phase]] =
  FeatureFlags.transaction(Map("rollout-phase" -> "DualWrite")) {
    FeatureFlags.value[Phase]("rollout-phase", Phase.Off)
  }

Caching

With cacheEvaluations = true (the default), the first read of a key calls the provider and the next reads of that key in the block are served from the transaction with reason Cached. The block then sees one value per flag even if the flag changes meanwhile.

// The first read goes to the provider; the next two are served from the transaction (reason = Cached)
val cached: ZIO[FeatureFlags, FeatureFlagError, TransactionResult[List[FlagResolution[Boolean]]]] =
  FeatureFlags.transaction() {
    ZIO.foreach(List(1, 2, 3))(_ => FeatureFlags.booleanDetails("feature", false))
  }

// Every read goes to the provider, so a change between reads is visible
val uncached: ZIO[FeatureFlags, FeatureFlagError, TransactionResult[(Boolean, Boolean)]] =
  FeatureFlags.transaction(cacheEvaluations = false) {
    FeatureFlags.boolean("feature", false) <*> FeatureFlags.boolean("feature", false)
  }
  • The cache is keyed by flag key only. A second read with a different context gets the cached value.
  • An answer that carries an error code (such as FLAG_NOT_FOUND) is recorded but never served from the cache; the next read asks the provider again.
  • A key read again as another type is served from the cache when the cached value converts (an Int read as a Long), and from the provider otherwise.

Transaction context

// Context for every evaluation in the block. It ranks just above the global context, so the client context,
// `withContext` and the call's own context all win over it.
val withTxContext: ZIO[FeatureFlags, FeatureFlagError, TransactionResult[Boolean]] =
  FeatureFlags.transaction(context = EvaluationContext("user-123").withAttribute("plan", "premium")) {
    FeatureFlags.boolean("premium-feature", false)
  }

In the merge order the transaction context ranks just above the global context: the client context, a context source, withContext and the call's own context all win over it.

What a transaction returns

def describe(tx: TransactionResult[?]): String =
  val providerKeys = tx.providerEvaluatedKeys // read from the provider
  val overridden   = tx.overriddenFlags       // served from an override
  val values       = tx.toValueMap            // key -> value, for every flag read
  val details      = tx.getEvaluation("feature-b").map(e => s"${e.value} at ${e.timestamp}")
  s"provider=$providerKeys overridden=$overridden values=$values feature-b=${details.getOrElse("not read")}"
TransactionResult member Meaning
result the block's result
evaluatedFlags key → FlagEvaluation, for every flag read
overriddenFlags keys served from an override
allFlagKeys every key read
providerEvaluatedKeys keys read from the provider
flagCount, overrideCount how many of each
getEvaluation(key) that key's FlagEvaluation, if read
wasEvaluated(key), wasOverridden(key) membership checks
toValueMap key → value
map(f) transform result

A FlagEvaluation holds key, value, the full resolution, wasOverridden (and its opposite, wasEvaluated) and a timestamp. A cached re-read does not add a second entry.

Errors

On Scala 3, transaction fails with your block's error or a FeatureFlagError (a union type). transactionEither keeps them apart, with your error as Left and the transaction's as Right. Use it on Scala 2.13, where transaction's error type is Any.

// transactionEither keeps your error (Left) apart from the transaction's own (Right), on Scala 2.13 too
val either: ZIO[FeatureFlags, Either[String, FeatureFlagError], TransactionResult[Boolean]] =
  FeatureFlags.transactionEither() {
    FeatureFlags.boolean("feature", false).orElseFail("could not read the flag")
  }

Nesting

By default, opening a transaction inside another fails with NestedTransactionNotAllowed before the inner block runs. With nested = NestedPolicy.Reuse, the inner call joins the outer transaction instead. That makes a transaction safe to use as middleware around code that may open its own.

val inner: ZIO[FeatureFlags, FeatureFlagError, TransactionResult[Boolean]] =
  FeatureFlags.transaction(nested = NestedPolicy.Reuse) {
    FeatureFlags.boolean("feature-a", false)
  }

// With the default, NestedPolicy.Fail, the inner call would fail with NestedTransactionNotAllowed.
// With Reuse it joins the outer transaction: it sees the outer overrides and its own arguments are ignored.
val outer: ZIO[FeatureFlags, FeatureFlagError, TransactionResult[Boolean]] =
  FeatureFlags.transaction(Map("feature-a" -> true)) {
    inner.map(_.result) // true, from the outer override
  }

Warning

Under Reuse the inner call's overrides, context and cacheEvaluations are ignored, and its result describes the outer transaction so far.

Is there a transaction?

// For an audit record: None outside a transaction, Some(flags) inside one (possibly empty)
val auditFlags: ZIO[FeatureFlags, Nothing, Option[Set[String]]] =
  FeatureFlags.transactionEvaluations.map(_.map(_.keySet))

val state: ZIO[FeatureFlags, FeatureFlagError, (Boolean, Option[Set[String]])] =
  for
    inside <- FeatureFlags.inTransaction // false here
    tx <- FeatureFlags.transaction() {
      FeatureFlags.boolean("feature", false) *> auditFlags // Some(Set("feature"))
    }
  yield (inside, tx.result)
Method Outside a transaction Inside one
inTransaction false true
transactionEvaluations None Some(flags read so far)
currentEvaluatedFlags Map.empty flags read so far

For an audit record prefer transactionEvaluations: it cannot confuse "no transaction" with "no flags read".

Fibers

A transaction belongs to the fiber that opened it and to the fibers it forks. Forked fibers use its overrides and cache, and their reads are recorded in it. Other fibers are not affected.

// Fibers forked inside the block share the transaction: their reads use its overrides and are recorded in it
val parallel: ZIO[FeatureFlags, FeatureFlagError, TransactionResult[List[Boolean]]] =
  FeatureFlags.transaction(Map("feature-a" -> true)) {
    ZIO.foreachPar(List("feature-a", "feature-b", "feature-c"))(key => FeatureFlags.boolean(key, false))
  }