Skip to content

VOL 09 / CH 04 / LESSON 03

4.3 Algebraic Abstraction and Side Effects: From Monoid to Monad ​

Monoid, Functor, and Monad are often presented as metaphors, resulting in learners remembering only "boxes" and "pipes," without understanding the specific operations they constrain. A more reliable approach is to examine three questions: what values are available, which combinations are permitted, and what laws must those combinations satisfy.

Monoid: The Smallest Structure That Can Be Parallelized ​

A Monoid is composed of the following:

  • A set of values;
  • A binary operation combine(a, b) closed over that set;
  • A unit element empty;
  • The combination operation satisfies the associative law.
text
combine(combine(a, b), c) = combine(a, combine(b, c))
combine(empty, a) = a
combine(a, empty) = a

Examples include:

ValueCombination OperationUnit Element
IntegersAddition0
IntegersMultiplication1
StringsConcatenation""
ListsList concatenationEmpty list
SetsUnionEmpty set

The same type of value can support multiple Monoids. For instance, integer addition and multiplication have different unit elements, so saying "integers form a Monoid" is incomplete, it must specify the combination operation.

The associative law enables systems to split data into chunks, aggregate locally, and then merge results:

text
Chunk A ──sum──┐
Chunk B ──sum──┼──sum──> Total sum
Chunk C ──sum──┘

Associativity permits regrouping while preserving element order. It does not permit arbitrary permutation: string concatenation is associative, but exchanging "a" and "b" changes the result. A distributed aggregate that also reorders inputs needs commutativity or an explicit ordering protocol.

Functor: Mapping in Context ​

Applying a regular function A -> B to a context F<A> yields F<B>, typically written as map:

text
map : (A -> B) -> F<A> -> F<B>

Java Optional.map demonstrates this pattern:

java
Optional<User> user = repository.findUser(id);
Optional<String> email = user.map(User::email);

The mapping transforms the internal value while preserving the "possibly absent value" context. To remain predictable, map must satisfy two core laws:

text
map(identity) = identity
map(f then g) = map(f) then map(g)

These laws have practical meaning: inserting an identity mapping during refactoring should not alter behavior; composing or splitting consecutive mappings should not change the outcome. If map secretly makes database calls or alters dependency invocation counts, these equivalence transformations will break.

Applicative: Combining Independent Contextual Computations ​

Suppose creating a user requires validating both name and email, and you want to return all errors in a single response:

text
Validated<Name>  +  Validated<Email>
             ↓ Combine
       Validated<User>

Since the validations are independent, they can be executed separately, and their successful results passed to a constructor. On failure, errors are accumulated. An applicative validation design can express this fixed structure and accumulate errors. Error accumulation is a choice of instance, not a guarantee of every Applicative; neither independent inputs nor the abstraction alone require parallel execution.

It differs from the next section in that the second computation does not depend on the value produced by the first.

Monad: Making the Next Step Depend on the Previous Value ​

The core operations of a monad can be expressed as:

text
pure    : A -> M<A>
flatMap : M<A> -> (A -> M<B>) -> M<B>

For example, after retrieving a user, you must obtain the user's organization ID before knowing which organization to query next:

java
Optional<Organization> organization =
    repository.findUser(userId)
        .flatMap(user -> repository.findOrganization(user.organizationId()));

If the user does not exist, subsequent queries are skipped; if the user exists, the function returns a new Optional<Organization>, flatMap avoids generating Optional<Optional<Organization>>.

This is more accurate than the common metaphor "a monad is a box that holds values": a monad provides a context-aware, dependency-driven ordering of computations. The meaning of ordering depends on the context:

  • Maybe / Optional: If one step is empty, subsequent steps halt;
  • Either / Result: If one step fails, the error is propagated;
  • List: Each step may produce multiple results, enabling combination;
  • State: State is passed from one step to the next;
  • IO: External interactions are represented as composable actions.

Formally, a monad must satisfy the left identity, right identity, and associativity laws. While engineering code doesn’t typically require manual verification of these laws, they explain why regrouping a chain while preserving its dependency order should preserve the outcome. They do not permit swapping dependent effects.

Don't Automatically Label Similar APIs as Monads ​

Java Optional.flatMap resembles a Monad's bind, but Java's API also includes null handling, object semantics, and other inherent conventions. Java Streams feature map and flatMap, yet these are lazy, single-consumption operation pipelines, stages that can be omitted without affecting the final result.

Thus, a safer characterization is that these APIs borrow functional composition patterns. Whether they constitute a formal instance of a specific pattern must be clearly defined in terms of types, operations, and equivalence relations. It's not valid to simply assert monadic behavior based on similar method names.

Save this as OptionalLawsDemo.java and run java OptionalLawsDemo.java with JDK 17 or later:

java
import java.util.Optional;
import java.util.function.Function;

class OptionalLawsDemo {
    public static void main(String[] args) {
        Function<String, String> f = value -> null;
        Function<String, String> g = value -> "fallback";
        Optional<String> input = Optional.of("x");
        System.out.println(input.map(f).map(g));
        System.out.println(input.map(f.andThen(g)));
        System.out.println(("a" + "b") + "c");
        System.out.println("a" + ("b" + "c"));
        System.out.println("b" + "a" + "c");
    }
}

The first two lines are Optional.empty and Optional[fallback]: Optional.map turns f’s null into absence, so splitting the mappings skips g while composing the functions calls it. Pure, non-null-returning functions avoid this particular counterexample. The remaining lines are abc, abc, and bac, showing the distinction between regrouping and reordering. Method names are useful clues; they are not a certificate of algebraic laws.

Put Errors Into Return Types ​

Exceptions diverge from normal return paths. For predictable business failures, explicit result types can be used:

java
sealed interface Result<T> {
    record Ok<T>(T value) implements Result<T> {}
    record Error<T>(String code, String message) implements Result<T> {}
}

Composite functions can decide whether to stop on the first error or accumulate multiple validation errors. The type exposes modeled business failures. Java implementations can still throw unchecked exceptions, so this declaration does not prove an exception-free API.

This doesn't mean every exception should be converted into a return value. Memory exhaustion or violations of internal invariants (unrecoverable runtime failures) are fundamentally different from issues like "invalid email format." Error modeling must distinguish between expected business branches and program defects.

Side Effects Are Not Eliminated by Abstraction ​

Functional languages still need to read and write files, access networks. The key is to separate the composition of actions from their actual execution, ideally, to concentrate side effects into well-defined boundaries.

In a typical Java service, a straightforward approach might look like this:

java
sealed interface CheckoutEffect {
    record SaveOrder(Order order) implements CheckoutEffect {}
    record PublishEvent(OrderPlaced event) implements CheckoutEffect {}
}

record CheckoutDecision(
    Order order,
    List<CheckoutEffect> effects
) {
    CheckoutDecision { effects = List.copyOf(effects); }
}

These are Java 17 declaration fragments; Order and OrderPlaced are domain types supplied by the application. Copying the effect list protects its membership, but the payload types must also be immutable for an immutable plan.

The pure core produces CheckoutDecision, while the shell interprets and executes effects. This allows rule testing to avoid actually writing to databases, and makes the ordering, retrying, and idempotency requirements of side effects clearly defined and testable.

It does not automatically resolve consistency across databases and messaging systems; the execution layer may still require transactional outboxes, idempotency keys, and other infrastructure-level guarantees. Abstraction can expose the boundaries of problems, but it cannot replace the need for infrastructure-level assurances.

The Order of Learning Abstraction ​

  1. First, become fluent with pure functions and immutable values;
  2. Use map, filter, and fold to describe common transformations;
  3. Identify combinable aggregate operations;
  4. Distinguish between independent and dependent computations;
  5. Finally, learn the formal patterns of Functor, Applicative, and Monad.

An abstraction should not be introduced into business code simply because it's functional, unless it makes error handling, composition, or boundary testing clearer.

Completion Check ​

Design a registration flow: validate name, email, and password, then look up a referrer, and finally generate pending persistence and notification actions.

  • Which validations are independent and can accumulate errors?
  • Which step must depend on the output of the previous one?
  • Are some operations purely computational, and which introduce side effects?
  • Does the composition of operations satisfy the associative law?
  • How can we avoid duplicate notifications when retrying actions?

Being able to answer these questions demonstrates true understanding of abstraction, remembering "a monad is a box" is far from sufficient.

References ​

Built with VitePress | Software Systems Atlas