Accumulator<E, A>

Runnable example: AccumulatorSample.java

What is Accumulator<E, A>?

Accumulator<E, A> pairs a computed value A with a side-channel accumulation E (log entries, metrics, warnings, audit trail). It is the functional alternative to mutable global state for cross-cutting concerns:

  • Instead of calling logger.info(...) inside a method body, return the log entries as part of the result.
  • Instead of incrementing a shared counter, accumulate a count alongside the value.
  • Instead of writing to an audit log as a side effect, thread audit events through the return type.

The key invariant: accumulation always continues. Unlike Result or Try, there is no failure path — every step contributes to both the value and the accumulation. This makes Accumulator unsuitable for error handling (use Result for that) and ideal for scenarios where every step always produces something, but you also want a trace of what happened along the way.

This type is sometimes called the Writer monad in functional programming literature.

Factories

BinaryOperator<List<String>> concat = (a, b) -> {
var merged = new ArrayList<>(a);
merged.addAll(b);
return merged;
};
// of — value + initial accumulation
Accumulator<List<String>, Integer> a = Accumulator.of(42, List.of("computed answer"));
a.value(); // 42
a.accumulated(); // ["computed answer"]
// pure — value with empty accumulation (caller provides the empty)
Accumulator<List<String>, Integer> p = Accumulator.pure(42, List.of());
p.value(); // 42
p.accumulated(); // []
// tell — record without producing a value
Accumulator<List<String>, Integer> t =
Accumulator.tell(List.of("pre-check passed"))
.flatMap(__ -> Accumulator.of(42, List.of("value computed")), concat);
t.value(); // 42
t.accumulated(); // ["pre-check passed", "value computed"]

Summary:

FactoryValueAccumulated
Accumulator.of(value, log)providedprovided
Accumulator.pure(value, empty)providedempty (identity)
Accumulator.tell(log)null (Void)provided

For pure, the caller provides the empty / identity value because Java does not have type classes. Common choices: List.of(), 0, "".

For tell, the value is always null. Call hasValue() to distinguish tell results from of/pure results. Do not call map on a tell result — use flatMap to produce a real value first.

flatMap — chain and merge

flatMap is the primary composition combinator. It:

  1. Passes the current value to f.
  2. Gets back the next Accumulator<E, B>.
  3. Merges the two accumulations using the provided BinaryOperator<E>.
BinaryOperator<List<String>> concat = (a, b) -> {
var merged = new ArrayList<>(a);
merged.addAll(b);
return merged;
};
Accumulator<List<String>, Integer> result =
Accumulator.of(10, List.of("step 1"))
.flatMap(v -> Accumulator.of(v * 2, List.of("step 2")), concat)
.flatMap(v -> Accumulator.of(v + 5, List.of("step 3")), concat);
result.value(); // 25
result.accumulated(); // ["step 1", "step 2", "step 3"]
// With NonEmptyList — mirrors the Validated error accumulation pattern
Accumulator<NonEmptyList<String>, Integer> nel =
Accumulator.of(1, NonEmptyList.of("a", List.of()))
.flatMap(v -> Accumulator.of(v + 1, NonEmptyList.of("b", List.of())), NonEmptyList::concat)
.flatMap(v -> Accumulator.of(v + 1, NonEmptyList.of("c", List.of())), NonEmptyList::concat);
nel.value(); // 3
nel.accumulated().toList(); // ["a", "b", "c"]

The merge function determines how accumulations combine. Common choices:

Accumulation typeMerge function
List<T>list concatenation (new list + addAll)
NonEmptyList<T>NonEmptyList::concat
int / longInteger::sum / Long::sum
StringString::concat or (a, b) -> a + "\n" + b

map and mapAccumulated

// map — transform the value, accumulation unchanged
Accumulator<List<String>, String> str =
Accumulator.of(42, List.of("step 1")).map(Object::toString);
str.value(); // "42"
str.accumulated(); // ["step 1"]
// mapAccumulated — transform the accumulation, value unchanged
Accumulator<Integer, Integer> countOnly =
Accumulator.of(42, List.of("a", "b", "c")).mapAccumulated(List::size);
countOnly.value(); // 42
countOnly.accumulated(); // 3

Use map for pure value transformations where the accumulation is unchanged. Use mapAccumulated to convert between accumulation types — for example, to count log entries (List::size) or to convert raw strings to structured event objects.

Interoperability

BinaryOperator<List<String>> concat = (a, b) -> {
var merged = new ArrayList<>(a);
merged.addAll(b);
return merged;
};
// ---- combine — two independent accumulators ----
Accumulator<List<String>, String> userAcc = fetchUser(id); // ("Alice", ["user loaded"])
Accumulator<List<String>, Integer> scoreAcc = fetchScore(id); // (98, ["score loaded"])
Accumulator<List<String>, String> dashboard =
userAcc.combine(scoreAcc, concat, (name, score) -> name + " — score: " + score);
dashboard.value(); // "Alice — score: 98"
dashboard.accumulated(); // ["user loaded", "score loaded"]
// ---- sequence — fold a list ----
List<Accumulator<List<String>, Integer>> steps = List.of(
Accumulator.of(10, List.of("step A")),
Accumulator.of(20, List.of("step B")),
Accumulator.of(30, List.of("step C"))
);
Accumulator<List<String>, List<Integer>> all = Accumulator.sequence(steps, concat, List.of());
all.value(); // [10, 20, 30]
all.accumulated(); // ["step A", "step B", "step C"]
// ---- toOption — discard accumulation ----
Accumulator.of(42, List.of("log")).toOption(); // Some(42)
Accumulator.tell(List.of("entry")).toOption(); // None
// ---- liftOption — log present / absent paths ----
Accumulator<List<String>, Option<String>> acc = Accumulator.liftOption(
userRepo.findName(id),
name -> List.of("found: " + name), // called when Some
List.of("not found") // used when None
);
// Some("Alice") → ("Some(Alice)", ["found: Alice"])
// None → ("None", ["not found"])
// ---- liftTry — log success / failure ----
Accumulator<List<String>, Try<Config>> loaded = Accumulator.liftTry(
Try.of(() -> ConfigLoader.load(path)),
cfg -> List.of("config loaded from " + path),
ex -> List.of("config load failed: " + ex.getMessage())
);
loaded.accumulated(); // always set — success or failure
loaded.value(); // Try<Config> — caller decides what to do with it
Method / FactoryReturnsUse case
combine(other, merge, f)Accumulator<E, C>Combine two independent accumulators; both logs merged
sequence(list, merge, empty)Accumulator<E, List<A>>Fold a list of accumulators into one
toOption()Option<A>Extract value as Option; None for tell results; accumulation discarded
liftOption(opt, someLog, noneLog)Accumulator<E, Option<A>>Log the presence or absence of an Option value
liftTry(t, successLog, failureLog)Accumulator<E, Try<A>>Log the success or failure of a Try; the Try itself is the value
toResult()Result<A, E>Ok(value) when has value; Err(accumulated) for tell results
toTuple2()Tuple2<E, A>Structural decomposition; _1 is accumulated, _2 is value
hasValue()booleanDistinguish of/pure results from tell results
value()@Nullable AExtract the value; null for tell results
accumulated()EExtract the accumulation; always non-null

Real-world example

Pricing pipeline that threads an audit log through each transformation step:

import java.math.BigDecimal;
import java.math.RoundingMode;
record Order(String id, BigDecimal basePrice) {}
record PricedOrder(String id, BigDecimal price, String currency) {}
BinaryOperator<List<String>> concat = (a, b) -> {
var merged = new ArrayList<>(a);
merged.addAll(b);
return merged;
};
Accumulator<List<String>, PricedOrder> priced =
Accumulator.of(new Order("ord-7", BigDecimal.valueOf(100.0)), List.of("order loaded"))
.flatMap(o -> {
BigDecimal discounted = o.basePrice().multiply(BigDecimal.valueOf(0.85));
return Accumulator.of(
new Order(o.id(), discounted),
List.of("15% discount applied → " + discounted)
);
}, concat)
.flatMap(o -> {
BigDecimal taxed = o.basePrice()
.multiply(BigDecimal.valueOf(1.21))
.setScale(2, RoundingMode.HALF_UP);
return Accumulator.of(
new PricedOrder(o.id(), taxed, "USD"),
List.of("21% VAT applied → " + taxed)
);
}, concat);
System.out.println(priced.value());
// PricedOrder[id=ord-7, price=102.85, currency=USD]
priced.accumulated().forEach(e -> System.out.println(" - " + e));
// - order loaded
// - 15% discount applied → 85.00
// - 21% VAT applied → 102.85

Every method in the pipeline is a pure function: no logger injection, no shared state, no side effects. The audit log is assembled as a first-class value alongside the result. The caller decides what to do with it — print it, persist it, or discard it.

When to use Accumulator

ScenarioRight tool
Record log entries alongside a computationAccumulator<List<String>, T>
Count steps or operations without a counter fieldAccumulator<Integer, T>
Thread domain audit events through a pipelineAccumulator<NonEmptyList<AuditEvent>, T>
Collect warnings without aborting the computationAccumulator<List<Warning>, T>
A step can fail — caller needs error handlingResult<T, E> instead
Multiple independent validations accumulating errorsValidated<E, A> instead

Accumulator vs Result vs Validated

Accumulator<E, A>Result<V, E>Validated<E, A>
Always succeeds?YesNo — can be ErrNo — can be Invalid
Accumulates per step?Yes — alwaysNo — stops at first errorYes — collects all errors
Accumulation purposeSide-channel (log, trace)Error reportingValidation errors
CompositionflatMap + mergeflatMap (short-circuit)combine (parallel)