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. The rationale for including it in the library is documented in ADR-020 — Accumulator<E, A> (Writer monad) — rationale for inclusion.
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"]
// ── Accumulator → other types (accumulation discarded) ───────────────────────
Accumulator<List<String>, Integer> acc = Accumulator.of(42, List.of("log"));
acc.toOption(); // Some(42)acc.toOptional(); // Optional.of(42)acc.stream(); // Stream.of(42)acc.toResult(); // Ok(42)acc.toEither(); // right(42)acc.toTuple2(); // Tuple2(["log"], 42) — _1=accumulated, _2=value
Accumulator.tell(List.of("entry")).toOption(); // NoneAccumulator.tell(List.of("entry")).toOptional(); // Optional.empty()Accumulator.tell(List.of("entry")).toResult(); // Err(["entry"])Accumulator.tell(List.of("entry")).toEither(); // left(["entry"])
// ── liftOption — log present / absent paths ───────────────────────────────────
Accumulator<List<String>, Option<String>> liftedOpt = 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>> liftedTry = Accumulator.liftTry( Try.of(() -> ConfigLoader.load(path)), cfg -> List.of("config loaded from " + path), ex -> List.of("config load failed: " + ex.getMessage()));liftedTry.accumulated(); // always set — success or failureliftedTry.value(); // Try<Config> — caller decides what to do with it
// ── liftResult — log ok / error paths ────────────────────────────────────────
Accumulator<List<String>, Result<Config, String>> liftedResult = Accumulator.liftResult( configService.load(path), cfg -> List.of("config loaded"), err -> List.of("config error: " + err));liftedResult.accumulated(); // always set — ok or errorliftedResult.value(); // Result<Config, String> — caller handles it
// ── liftEither — log right / left paths ──────────────────────────────────────
Accumulator<List<String>, Either<String, Config>> liftedEither = Accumulator.liftEither( configParser.parse(input), cfg -> List.of("parsed successfully"), err -> List.of("parse failed: " + err));liftedEither.accumulated(); // always set — right or leftliftedEither.value(); // Either<String, Config> — caller handles 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 |
toOptional() | Optional<A> | JDK Optional bridge; empty() for tell results; accumulation discarded |
stream() | Stream<A> | Single-element stream; empty for tell results; accumulation discarded |
toResult() | Result<A, E> | Ok(value) when has value; Err(accumulated) for tell results |
toEither() | Either<E, A> | right(value) when has value; left(accumulated) for tell results |
toTuple2() | Tuple2<E, @Nullable A> | Structural decomposition; _1 is accumulated, _2 is value (null for tell results) |
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 |
liftResult(r, okLog, errLog) | Accumulator<E, Result<V,Err>> | Log the ok or error branch of a Result; the Result itself is the value |
liftEither(e, rightLog, leftLog) | Accumulator<E, Either<L,R>> | Log the right or left branch of an Either; the Either itself is the 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) |