Combine values with Cats Semigroup and Monoid. Build a request-summary instance, test its laws, and understand ordering, empty inputs, averages, and retries.
Combine values with Cats Semigroup and Monoid. Build a request-summary instance, test its laws, and understand ordering, empty inputs, averages, and retries.
A useful aggregation needs more than a loop. It needs a clear rule for combining partial results, and a result for the case where there are no inputs. Cats gives those two responsibilities names: Semigroup and Monoid.
Consider a backend job that summarizes completed requests. Each batch produces a request count, a failure count, accumulated latency, and diagnostic messages. Combining two batches looks straightforward. The harder questions appear when a batch is empty, the grouping changes, or a retry delivers the same batch twice.
This third article in the Cats, Cats Effect & fs2 series follows Type classes, Eq, and Show. I will apply the same type class, instance, and consumer pattern to aggregation. The examples use Cats Core, immutable values, and ordinary collections.
A RequestSummary will hold four fields. completed counts all finished requests, including failures. failed counts the failed subset. totalLatencyMicros sums the durations of those completed requests. errors retains diagnostic messages in the order supplied.
The combination policy adds the three numeric fields and appends the right summary’s messages after the left summary’s messages. It has the same input and output type: two summaries become one summary. Start with the diagnostic part of that policy, using an ordinary collection operation:
val firstErrors = Vector("timeout")
val secondErrors = Vector("unavailable")
val appendedErrors = firstErrors ++ secondErrors
// Vector("timeout", "unavailable")The append produces a new vector. It does not change either input, read a clock, or update a shared counter. The complete summary implementation comes later. Its numeric fields use BigInt, so this example avoids fixed-width integer overflow when adding counts and durations. Memory and execution time remain finite resources; arbitrary-precision integers do not remove those limits.
I would keep the ordinary collection operation if only one local calculation needed it. A type class becomes useful when several consumers should share the same combination policy, or when an aggregation algorithm should work for other value types too.
A Semigroup[A] supplies combine(left: A, right: A): A. The operation must be associative: changing the parentheses around three inputs must leave the result unchanged. The Cats Semigroup guide describes this contract.
import cats.{Monoid, Semigroup}
import cats.syntax.all.*
val combinedText = Semigroup[String].combine("read", "write")
val combinedTextWithSyntax = "read" |+| "write"
// Both results are "readwrite".
val leftGrouped = ("a" |+| "b") |+| "c"
val rightGrouped = "a" |+| ("b" |+| "c")
// Both results are "abc". Regrouping preserves the result.
val forward = firstErrors |+| secondErrors
val reversed = secondErrors |+| firstErrors
// forward: Vector("timeout", "unavailable")
// reversed: Vector("unavailable", "timeout")Semigroup[String] retrieves the available instance for strings. Its combine concatenates them. Importing cats.syntax.all.* also enables |+|, an alternative way to call the chosen combination operation. The operator does not choose the policy; the instance does.
For an operation written as combine, associativity means that combine(combine(a, b), c) equals combine(a, combine(b, c)). For text, combining "orders/", "pending/", and "42" gives the same text under either grouping.
This matters when a consumer first summarizes adjacent chunks and then combines their summaries. The intermediate boundaries can move while the input sequence remains the same. An algorithm can depend on that property without knowing whether its values are text, counters, or request summaries.
A compatible method signature does not establish the law. Subtraction takes two integers and returns an integer, but (10 - 3) - 2 is 5, while 10 - (3 - 2) is 9. Packaging subtraction in a Semigroup instance would compile, but would break the contract that generic consumers expect.
Commutativity means that swapping the inputs leaves the result unchanged: combine(a, b) equals combine(b, a). A semigroup does not require this additional property.
String concatenation is associative and order-sensitive. "retry/" |+| "timeout" differs from "timeout" |+| "retry/". The diagnostic vector in the request summary has the same distinction. Grouping adjacent messages differently preserves their sequence; swapping batches can change it.
The numeric totals alone may look reorderable. The complete summary also contains ordered diagnostics, so the complete operation is not commutative. A distributed consumer that emits batches in completion order must decide whether that order is acceptable. Associativity does not restore the original event order.
Keep that distinction visible in reviews. “These summaries can be combined” does not by itself justify shuffling them, selecting an arbitrary first result, or changing the meaning of the diagnostic stream.
Suppose an aggregation receives no summaries. A semigroup explains how to combine two existing values, but supplies no starting value. A Monoid[A] extends that contract with empty: A, an identity for the same operation.
val emptyErrors = Monoid[Vector[String]].empty
val emptyText = Monoid[String].empty
val errorsWithEmpty = firstErrors |+| emptyErrors
// emptyErrors is Vector.empty[String]; emptyText is "".
// errorsWithEmpty is Vector("timeout").Both identity laws matter: combining empty before a value must preserve it, and combining empty after a value must preserve it. Empty text is an identity for concatenation. An empty vector is an identity for vector concatenation. A numeric zero is an identity for addition.
The word “empty” names an operational relationship. It does not mean any convenient business default. A summary containing one completed request cannot be the identity for request-count addition, because adding it changes the result. Likewise, a placeholder error message would change the diagnostic vector.
A monoid lets an empty input collection reduce to its identity. When a domain has associative combination but no valid identity value, preserve that distinction. A consumer can require a non-empty collection or represent the absent result with Option. Cats exposes Semigroup[A].combineAllOption for this purpose, while a monoid can return an A directly. The Monoid guide develops the empty-input case.
A function combining exactly two values needs a semigroup. A function reducing any number of values, including zero, can request a monoid:
def merge[A: Semigroup](left: A, right: A): A =
Semigroup[A].combine(left, right)
def mergeAll[A: Monoid](values: List[A]): A =
values.combineAll
val allErrors = mergeAll(List(firstErrors, secondErrors))
val noErrors = mergeAll(List.empty[Vector[String]])
// allErrors: Vector("timeout", "unavailable")
// noErrors: Vector.empty[String]
val maybeText = Semigroup[String].combineAllOption(List("read", "write"))
val missingText = Semigroup[String].combineAllOption(List.empty[String])
// maybeText: Some("readwrite"); missingText: NoneThe consumer knows the contract and receives its implementation through a contextual parameter or context bound. It does not contain separate branches for strings, numbers, and summaries. This is the same dependency-passing pattern introduced with Eq and Show.
Monoid[A] includes the semigroup capability, so its instance also satisfies consumers that only need associative combination. Requiring the smaller contract keeps the function’s dependency accurate. It also permits types for which a useful identity does not exist.
There is still no reason to make every aggregation generic. A domain-specific function can be clearer when it owns additional rules, such as rejecting a mixed currency or choosing a time window. Use a common contract when the common behavior is real.
The domain instance puts the earlier policy in one place. Numeric fields add, diagnostics append, and the identity has zero totals and no messages:
final case class RequestSummary(
completed: BigInt,
failed: BigInt,
totalLatencyMicros: BigInt,
errors: Vector[String]
):
require(completed >= 0, "completed must be nonnegative")
require(failed >= 0 && failed <= completed, "failed must be between zero and completed")
require(totalLatencyMicros >= 0, "total latency must be nonnegative")
require(completed > 0 || totalLatencyMicros == 0, "an empty summary cannot have latency")
object RequestSummary:
given Monoid[RequestSummary] with
def empty: RequestSummary =
RequestSummary(0, 0, 0, Vector.empty)
def combine(left: RequestSummary, right: RequestSummary): RequestSummary =
RequestSummary(
completed = left.completed + right.completed,
failed = left.failed + right.failed,
totalLatencyMicros = left.totalLatencyMicros + right.totalLatencyMicros,
errors = left.errors ++ right.errors
)The instance lives in RequestSummary‘s companion, where Scala can find the canonical combination for that domain type. A separate given Semigroup[RequestSummary] is unnecessary because the monoid already supplies that capability.
The constructor checks nonnegative counts and latency, requires failed <= completed, and rejects nonzero latency when no requests completed. Combining two valid summaries preserves those conditions: addition cannot make these nonnegative fields negative, and adding two valid failure bounds preserves the combined bound. A zero combined count implies that both input counts, and therefore both latency totals, were zero.
These checks do not establish the truth of an observation. The model cannot detect that a caller reported the wrong latency, counted one request twice, or mixed measurements from different units. Those are input and application-boundary responsibilities.
The errors field is an ordered diagnostic vector. The example does not require exactly one string per failed request. A real reporting path might retain several messages for a failure or only selected diagnostics. Choose that retention policy explicitly rather than deriving it from the type class.
Cats can now aggregate a collection of these domain values with combineAll. The empty collection has a defined result, and adjacent batch summaries use the same instance:
val first = RequestSummary(1, 1, 100, Vector("timeout"))
val second = RequestSummary(3, 1, 900, Vector("unavailable"))
val combined = List(first, second).combineAll
// RequestSummary(4, 2, 1000, Vector("timeout", "unavailable"))
val noRequests = List.empty[RequestSummary].combineAll
// RequestSummary(0, 0, 0, Vector.empty)
def averageLatencyMicros(summary: RequestSummary): Option[BigDecimal] =
Option.when(summary.completed > 0)(
BigDecimal(summary.totalLatencyMicros) / BigDecimal(summary.completed)
)
val combinedAverage = averageLatencyMicros(combined)
// Some(250)
val emptyAverage = averageLatencyMicros(noRequests)
// NoneThe important design choice is retaining total latency and request count. Suppose one batch contains one request with 100 microseconds of total latency. Another contains three requests with 900 microseconds of total latency. Their batch averages are 100 and 300 microseconds.
Averaging those two averages produces 200, giving the one-request batch the same weight as the three-request batch. Combining the original totals produces 1000 microseconds across four requests, so the overall average is 250 microseconds.
For this calculation, a useful partial result retains enough information to combine exactly. The display calculation happens afterward. With zero completed requests there is no average latency, so the example returns None. Reporting zero would confuse an absent measurement with an observed zero-duration request.
A decimal average can also need a rounding policy. The example uses Scala’s default BigDecimal calculation context; choose reporting precision and rounding explicitly when the consumer requires them. That presentation choice should not change the accumulated integer totals. This separation keeps exact aggregation distinct from a rounded metric shown to a reader or monitoring system.
The complete example checks both identity laws and associativity across a finite set of request summaries. It also checks empty input, preserved diagnostic order, and the difference between regrouping and swapping inputs:
def checkSummaryLaws(samples: List[RequestSummary]): Unit =
val monoid = Monoid[RequestSummary]
for x <- samples do
assert(monoid.combine(monoid.empty, x) == x)
assert(monoid.combine(x, monoid.empty) == x)
for y <- samples; z <- samples do
assert(
monoid.combine(monoid.combine(x, y), z) ==
monoid.combine(x, monoid.combine(y, z))
)These assertions are evidence for the tested values, not a proof over every possible summary. The field-by-field construction also explains the intended law: exact integer addition is associative, vector concatenation is associative, and each field has an identity. The combined record inherits those properties under its defined equality.
For an application instance, I would include representative empty, singleton, large-count, failure-heavy, and multi-message cases. Generated law checks can explore more combinations and report smaller counterexamples. Cats documents its law-testing integration with cats-laws, Discipline, and ScalaCheck; kernel contracts such as Monoid have their tests under cats.kernel.laws.discipline. See the law-testing guide.
Law checks also need an appropriate equality. An equality that ignores every field could make a broken aggregation appear valid. The example compares the entire immutable summary with case-class equality, including the diagnostic sequence.
Numeric representation matters. Ordinary binary floating-point addition can produce different results under different groupings. The boundary checks demonstrate a Double counterexample with large and small magnitudes. A familiar + operator is not sufficient evidence of exact associativity. The example uses BigInt for its exact integer totals and postpones division until reporting.
val unweightedAverage =
(averageLatencyMicros(first).get + averageLatencyMicros(second).get) / 2
// 200, but the combined average is 250. The batches have different counts.
val subtractionLeft = (10 - 3) - 2
val subtractionRight = 10 - (3 - 2)
// 5 and 9: subtraction is not associative.
val floatingLeft = (1e16 + -1e16) + 1.0
val floatingRight = 1e16 + (-1e16 + 1.0)
// 1.0 and 0.0: floating-point addition is not associative in general.
val replayed = combined |+| combined
// RequestSummary(8, 4, 2000, Vector("timeout", "unavailable", "timeout", "unavailable"))Retries need their own policy. Combining a request summary with itself doubles its counts and repeats its diagnostics. This operation is not idempotent. An at-least-once delivery path therefore needs a separate strategy for identifying duplicates when duplicate counting is unacceptable. A lawful monoid does not establish exactly-once processing.
Parallel execution needs an execution model. Associativity permits compatible regrouping of values. It does not create threads, schedule work, cancel a failed task, or make a database update atomic. The code here runs ordinary sequential collection operations. A concurrent or streaming implementation must preserve the order required by the chosen operation and handle its own failures.
Diagnostics have a retention cost. Appending immutable vectors keeps every retained message. A long-running aggregation can grow without a useful bound. Counts and totals may be sufficient for some paths; other paths may need a separately bounded diagnostic sample or a dedicated event sink. Any truncation policy changes the operation and needs its own law checks.
A type can support several policies. Numbers can be combined by addition or multiplication, with different identities. Requests might be summarized globally or grouped by endpoint. Keep alternative policies explicit through separately passed instances or distinct domain wrappers. Multiple competing default instances for the same type make the result harder to predict.
Expand the source below, save it as SemigroupMonoid.scala, and run scala run SemigroupMonoid.scala with Scala CLI. The file pins Scala 3.3.8 and Cats Core 2.13.0. These are the versions used for the examples, rather than a claim about the latest releases. No Cats Effect runtime is required.
//> using scala "3.3.8"
//> using dep "org.typelevel::cats-core:2.13.0"
import cats.{Monoid, Semigroup}
import cats.syntax.all.*
object SemigroupMonoid:
val firstErrors = Vector("timeout")
val secondErrors = Vector("unavailable")
val appendedErrors = firstErrors ++ secondErrors
// Vector("timeout", "unavailable")
val combinedText = Semigroup[String].combine("read", "write")
val combinedTextWithSyntax = "read" |+| "write"
// Both results are "readwrite".
val leftGrouped = ("a" |+| "b") |+| "c"
val rightGrouped = "a" |+| ("b" |+| "c")
// Both results are "abc". Regrouping preserves the result.
val forward = firstErrors |+| secondErrors
val reversed = secondErrors |+| firstErrors
// forward: Vector("timeout", "unavailable")
// reversed: Vector("unavailable", "timeout")
val emptyErrors = Monoid[Vector[String]].empty
val emptyText = Monoid[String].empty
val errorsWithEmpty = firstErrors |+| emptyErrors
// emptyErrors is Vector.empty[String]; emptyText is "".
// errorsWithEmpty is Vector("timeout").
def merge[A: Semigroup](left: A, right: A): A =
Semigroup[A].combine(left, right)
def mergeAll[A: Monoid](values: List[A]): A =
values.combineAll
val allErrors = mergeAll(List(firstErrors, secondErrors))
val noErrors = mergeAll(List.empty[Vector[String]])
// allErrors: Vector("timeout", "unavailable")
// noErrors: Vector.empty[String]
val maybeText = Semigroup[String].combineAllOption(List("read", "write"))
val missingText = Semigroup[String].combineAllOption(List.empty[String])
// maybeText: Some("readwrite"); missingText: None
final case class RequestSummary(
completed: BigInt,
failed: BigInt,
totalLatencyMicros: BigInt,
errors: Vector[String]
):
require(completed >= 0, "completed must be nonnegative")
require(failed >= 0 && failed <= completed, "failed must be between zero and completed")
require(totalLatencyMicros >= 0, "total latency must be nonnegative")
require(completed > 0 || totalLatencyMicros == 0, "an empty summary cannot have latency")
object RequestSummary:
given Monoid[RequestSummary] with
def empty: RequestSummary =
RequestSummary(0, 0, 0, Vector.empty)
def combine(left: RequestSummary, right: RequestSummary): RequestSummary =
RequestSummary(
completed = left.completed + right.completed,
failed = left.failed + right.failed,
totalLatencyMicros = left.totalLatencyMicros + right.totalLatencyMicros,
errors = left.errors ++ right.errors
)
val first = RequestSummary(1, 1, 100, Vector("timeout"))
val second = RequestSummary(3, 1, 900, Vector("unavailable"))
val combined = List(first, second).combineAll
// RequestSummary(4, 2, 1000, Vector("timeout", "unavailable"))
val noRequests = List.empty[RequestSummary].combineAll
// RequestSummary(0, 0, 0, Vector.empty)
def averageLatencyMicros(summary: RequestSummary): Option[BigDecimal] =
Option.when(summary.completed > 0)(
BigDecimal(summary.totalLatencyMicros) / BigDecimal(summary.completed)
)
val combinedAverage = averageLatencyMicros(combined)
// Some(250)
val emptyAverage = averageLatencyMicros(noRequests)
// None
def checkSummaryLaws(samples: List[RequestSummary]): Unit =
val monoid = Monoid[RequestSummary]
for x <- samples do
assert(monoid.combine(monoid.empty, x) == x)
assert(monoid.combine(x, monoid.empty) == x)
for y <- samples; z <- samples do
assert(
monoid.combine(monoid.combine(x, y), z) ==
monoid.combine(x, monoid.combine(y, z))
)
val unweightedAverage =
(averageLatencyMicros(first).get + averageLatencyMicros(second).get) / 2
// 200, but the combined average is 250. The batches have different counts.
val subtractionLeft = (10 - 3) - 2
val subtractionRight = 10 - (3 - 2)
// 5 and 9: subtraction is not associative.
val floatingLeft = (1e16 + -1e16) + 1.0
val floatingRight = 1e16 + (-1e16 + 1.0)
// 1.0 and 0.0: floating-point addition is not associative in general.
val replayed = combined |+| combined
// RequestSummary(8, 4, 2000, Vector("timeout", "unavailable", "timeout", "unavailable"))
def main(args: Array[String]): Unit =
assert(appendedErrors == Vector("timeout", "unavailable"))
assert(combinedText == "readwrite")
assert(combinedTextWithSyntax == combinedText)
assert(leftGrouped == "abc")
assert(leftGrouped == rightGrouped)
assert(forward == Vector("timeout", "unavailable"))
assert(reversed == Vector("unavailable", "timeout"))
assert(forward != reversed)
assert(emptyErrors == Vector.empty[String])
assert(emptyText == "")
assert(errorsWithEmpty == firstErrors)
assert((emptyErrors |+| firstErrors) == firstErrors)
assert(merge("read", "write") == combinedText)
assert(allErrors == forward)
assert(noErrors == Vector.empty[String])
assert(maybeText.contains("readwrite"))
assert(missingText.isEmpty)
assert(mergeAll(List(BigInt(1), BigInt(2), BigInt(3))) == BigInt(6))
assert(mergeAll(List.empty[BigInt]) == BigInt(0))
assert(combined == RequestSummary(4, 2, 1000, Vector("timeout", "unavailable")))
assert((first |+| second) == combined)
assert(noRequests == Monoid[RequestSummary].empty)
assert(combinedAverage.contains(BigDecimal(250)))
assert(emptyAverage.isEmpty)
assert(unweightedAverage == BigDecimal(200))
assert(combinedAverage.get != unweightedAverage)
assert((second |+| first).completed == combined.completed)
assert((second |+| first).errors == Vector("unavailable", "timeout"))
assert((second |+| first) != combined)
assert(replayed == RequestSummary(8, 4, 2000, Vector("timeout", "unavailable", "timeout", "unavailable")))
assert(replayed != combined)
assert(subtractionLeft == 5 && subtractionRight == 9)
assert(subtractionLeft != subtractionRight)
assert(floatingLeft == 1.0 && floatingRight == 0.0)
assert(floatingLeft != floatingRight)
val beyondLong = RequestSummary(BigInt(Long.MaxValue) + 1, 0, 0, Vector.empty)
assert((beyondLong |+| beyondLong).completed == (BigInt(Long.MaxValue) + 1) * 2)
assert(scala.util.Try(RequestSummary(-1, 0, 0, Vector.empty)).isFailure)
assert(scala.util.Try(RequestSummary(1, 2, 0, Vector.empty)).isFailure)
assert(scala.util.Try(RequestSummary(1, -1, 0, Vector.empty)).isFailure)
assert(scala.util.Try(RequestSummary(1, 0, -1, Vector.empty)).isFailure)
assert(scala.util.Try(RequestSummary(0, 0, 1, Vector.empty)).isFailure)
checkSummaryLaws(List(noRequests, first, second, combined))
println(s"Combined: $combined")
println(s"Average latency: ${combinedAverage.get} microseconds")
println(s"Unweighted average: $unweightedAverage microseconds")
println(s"Empty average: $emptyAverage")
println("All Semigroup and Monoid checks passed.")I start with three questions when reviewing an aggregation: what does combining two values mean, which regroupings preserve the answer, and what should happen with zero inputs? Semigroup answers the first two through an associative operation. Monoid adds the identity needed for the third.
The domain still decides what the values mean. Preserve the information needed for later calculations, validate the inputs, and make ordering and duplicate-handling requirements explicit. That is the practical value of the contract: consumers can reuse a precisely defined operation without silently inventing its behavior.
Continue from the Cats, Cats Effect & fs2 series page, revisit Functional programming and Cats, or browse the other Scala and functional programming articles.

Senior Technical Architect at HCLTech · working with Leonteq Security AG
I have 10+ years of experience building distributed systems and fintech platforms. I write about practical, non-obvious engineering details that official documentation often skips.
Scala, JVM, AI, and backend systems. I send practical articles when there is something worth reading.
Join the newsletter