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 accumulationAccumulator<List<String>, Integer> a = Accumulator.of(42, List.of("computed answer"));a.value(); // 42a.accumulated(); // ["computed answer"]
// pure — value with empty accumulation (caller provides the empty)Accumulator<List<String>, Integer> p = Accumulator.pure(42, List.of());p.value(); // 42p.accumulated(); // []
// tell — record without producing a valueAccumulator<List<String>, Integer> t = Accumulator.tell(List.of("pre-check passed")) .flatMap(__ -> Accumulator.of(42, List.of("value computed")), concat);
t.value(); // 42t.accumulated(); // ["pre-check passed", "value computed"]Summary:
| Factory | Value | Accumulated |
|---|---|---|
Accumulator.of(value, log) | provided | provided |
Accumulator.pure(value, empty) | provided | empty (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:
- Passes the current value to
f. - Gets back the next
Accumulator<E, B>. - 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(); // 25result.accumulated(); // ["step 1", "step 2", "step 3"]
// With NonEmptyList — mirrors the Validated error accumulation patternAccumulator<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(); // 3nel.accumulated().toList(); // ["a", "b", "c"]The merge function determines how accumulations combine. Common choices:
| Accumulation type | Merge function |
|---|---|
List<T> | list concatenation (new list + addAll) |
NonEmptyList<T> | NonEmptyList::concat |
int / long | Integer::sum / Long::sum |
String | String::concat or (a, b) -> a + "\n" + b |
map and mapAccumulated
// map — transform the value, accumulation unchangedAccumulator<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 unchangedAccumulator<Integer, Integer> countOnly = Accumulator.of(42, List.of("a", "b", "c")).mapAccumulated(List::size);
countOnly.value(); // 42countOnly.accumulated(); // 3Use 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 failureloaded.value(); // Try<Config> — caller decides what to do with it| Method / Factory | Returns | Use 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() | boolean | Distinguish of/pure results from tell results |
value() | @Nullable A | Extract the value; null for tell results |
accumulated() | E | Extract 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.85Every 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
| Scenario | Right tool |
|---|---|
| Record log entries alongside a computation | Accumulator<List<String>, T> |
| Count steps or operations without a counter field | Accumulator<Integer, T> |
| Thread domain audit events through a pipeline | Accumulator<NonEmptyList<AuditEvent>, T> |
| Collect warnings without aborting the computation | Accumulator<List<Warning>, T> |
| A step can fail — caller needs error handling | Result<T, E> instead |
| Multiple independent validations accumulating errors | Validated<E, A> instead |
Accumulator vs Result vs Validated
Accumulator<E, A> | Result<V, E> | Validated<E, A> | |
|---|---|---|---|
| Always succeeds? | Yes | No — can be Err | No — can be Invalid |
| Accumulates per step? | Yes — always | No — stops at first error | Yes — collects all errors |
| Accumulation purpose | Side-channel (log, trace) | Error reporting | Validation errors |
| Composition | flatMap + merge | flatMap (short-circuit) | combine (parallel) |