Accumulator<E, A> (Writer monad) — rationale for inclusion
Context
Some computations need to produce a value alongside a side-channel accumulation (audit log, metrics events, warning messages) without resorting to mutable state or global logging. The standard Java approach uses mutable lists or thread-local logging, both of which are invisible at API boundaries and lost on virtual threads or async boundaries.
Decision
Include Accumulator<E, A> as an immutable record pairing a computed value A
with a side-channel accumulation E. This is the Writer monad pattern from
functional programming.
The type parameter order is <E, A> where E is the accumulation type and A
is the value type. The record components are (@Nullable A value, E accumulated).
Accumulation is always explicit and composable through the API surface:
Accumulator.of(value, accumulated)— create with a value and an initial entry.Accumulator.pure(value, empty)— create with a value and an identity accumulation. The identity must be supplied by the caller because Java does not have type classes; common choices areList.of(),0, or"".Accumulator.tell(accumulated)— record a side-channel entry without producing a meaningful value; thevalue()component isnull(Void).flatMap(f, merge)— chain a next step: appliesfto the current value to get the nextAccumulator, then merges both accumulations withmerge(BinaryOperator<E>). This is the primary composition combinator.combine(other, merge, f)— combine two independently computed accumulators: merges their accumulations and appliesfto both values.sequence(list, merge, empty)— fold aList<Accumulator<E, A>>into a singleAccumulator<E, List<A>>by merging all accumulations left-to-right.map(f)— transform the value without touching the accumulation.mapAccumulated(f)— transform the accumulation without touching the value.
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 the natural choice for tracing what
happened, not just whether it succeeded.
Accumulator integrates with NonEmptyList<E> via NonEmptyList::concat as the
merge function, guaranteeing that at least one log entry is always present.
For Option, Try, Result, and Either, the static liftOption,
liftTry, liftResult, and liftEither helpers record a log entry regardless
of which branch was taken while preserving the wrapped value as the accumulator’s
value.
Consequences
Positive:
- Side-channel data (logs, warnings, events) remains a pure value alongside the result — no global mutable state, no thread-local context, and no loss on virtual-thread or async boundaries.
- Composable:
flatMapchains steps and merges accumulations;combinemerges parallel computations; both are explicit and testable. - The side-channel is visible in the function return type — callers can see that a function produces log entries without reading its implementation.
- Interoperates with the rest of the library:
toOption(),toResult(),toEither(), andtoTuple2()bridge to other types at pipeline boundaries.
Negative / tradeoffs:
- Less familiar than traditional logging — teams must adopt the pattern
deliberately, and the merge function must be threaded through every
flatMapcall. - Does not handle failures: a step that can fail should return
Accumulator<E, Result<A, Err>>(or useliftResult) rather than trying to express both concerns insideAccumulatoralone. tell()produces anullvalue, which requires callers to usehasValue()orflatMaprather thanmap— a sharp edge when the origin of an accumulator is not statically known.
Alternatives considered
- Mutable list passed as parameter: works but breaks referential transparency and complicates testing and concurrent use.
- MDC / thread-local logging: invisible in the type system; lost on virtual threads or async boundaries.
Result<A, List<Warning>>: conflates non-fatal warnings with fatal errors;Accumulatorseparates the two tracks — the value is always present, and warnings are always accumulated, regardless of success or failure.- Returning a custom pair type per use case: each domain would need its own
ad-hoc wrapper;
Accumulator<E, A>is the general solution that works for anyEand any merge strategy.