Kleisli for data engineers: the category trick that makes pipelines compose
What breaks most data pipelines is not the algorithm, it’s the glue (compositions). We chain effectful steps-read, transform, write-sprinkled with logging, retries, and configs. Then at 2 AM, one-step returns None/Left/an Exception and the whole thing craters. The code felt composable, but the functions didn’t actually compose.
This is exactly the corner category theory calls the Kleisli category of a monad: wrap the effectful arrow (A => F[B]) so composition becomes legal again. If that refresher helps, skim the Typelevel page on Kleisli
(it shows the (>=>) law in two short paragraphs) and jump back.
Promise: by the end of this post you’ll take any
A => F[B]and make it compose predictably, testably, and safely-across Cats Effect or ZIO-using Kleisli.
This is the missing trick in day‑to‑day data engineering: turn effectful functions into first‑class, chainable stages the compiler can reason about. Step: give me an A, I work inside box F, and I hand you back a B. That arrow is A => F[B], and Kleisli is the wrapper that lets those arrows click together.
Why this matters
- Effectful edges are everywhere in pipelines: IO, retries, metrics, tracing.
- Plain functions
A => Bcompose. Effectful onesA => F[B]do not-unless you lift them into the right abstraction. - We want composition that is:
- type‑safe (compiler checks wiring),
- effect‑polymorphic (CE or ZIO with the same user code),
- test‑friendly (swap F or run in memory), ️- observable (trace each stage),
- clear to read and reason about.
Kleisli gives you exactly this: a value representing an effectful function, with operators that compose like normal functions.
What we’ll build (no prior cats knowledge assumed)
- A mental model for Kleisli in one screenful.
- How a production‑style builder wires stages with Kleisli.
- A small, runnable pipeline sketch.
- Practical patterns: tracing, retries, testing, and when not to use it.
Prereqs: basic Scala. I’ll explain Cats/Kleisli as we go. We’ll use Cats Effect’s IO for examples (Scala 2.13/3). If you prefer ZIO, the ideas transfer-replace IO with your effect.
If you want a structured video course later, Rock the JVM has excellent material on Cats and Cats Effect you can take at your own pace.
References:
- Cats - Kleisli docs (short and precise): https://typelevel.org/cats/datatypes/kleisli.html
- Rock the JVM - Cats course: https://rockthejvm.com/courses/cats/
- Rock the JVM - Cats Effect course: https://rockthejvm.com/courses/cats-effect/
Part 1 - Kleisli: the mental model
A => F[B] so effectful functions compose like regular ones. If your pipeline has IO at the edges, you need Kleisli. Use andThen to chain stages left-to-right.A => B composes. A => F[B] doesn’t (the types don’t line up). Pipelines live at those effectful edges: read: Unit => F[In], transform: In => F[Out], write: Out => F[Unit].
Two pieces to keep in mind:
F[_]just means “some effect type” (e.g.,IO,Future,Task). Read it as: “a computation that may do I/O and eventually produce aB”.Kleisli[F, A, B]is a tiny wrapper around a functionA => F[B]. With this wrapper, we getandThen,map,flatMap, etc. so effectful functions compose like normal ones.
| |
You now have one value (pipeline) you can run, test, time, trace, and reuse-no ad‑hoc glue scattered across methods.
What the F[_] ? 😉
The same pipeline should run on different runtimes (Cats Effect IO, ZIO Task, etc.). F[_] is just “some effect”. Keep functions generic and request only the capabilities you need (map/flatMap, timing, concurrency) via typeclasses, then choose the runtime at the boundary. Deep dive coming soon: /effect-polymorphism
.
| |
flowchart LR NF["normalizeF[F]: Kleisli[F, In, Out]"] --> IOv["normalizeF[IO]: Kleisli[IO, In, Out]"] NF --> ZIOv["normalizeF[Task]: Kleisli[Task, In, Out]"]
sequenceDiagram
participant Dev as "Developer"
participant Comp as "Compiler"
participant Lib as "Generic code: normalizeF[F]"
participant IO as "Runtime: IO"
participant Task as "Runtime: Task"
Dev->>Comp: compile
Comp->>Lib: typecheck F[_] constraints
alt choose IO
Comp->>IO: instantiate normalizeF[IO]
Dev->>IO: run Kleisli with IO
else choose Task
Comp->>Task: instantiate normalizeF[Task]
Dev->>Task: run Kleisli with Task
end
Part 2 - Build a small pipeline end‑to‑end
Let’s wire a realistic round‑trip: read → validate → transform → write. No framework, just Kleisli and Cats Effect.
flowchart LR A["Source: Unit => IO[Raw]"] --> B["Parse+Validate: Raw => IO[User]"] B --> C["Transform: User => IO[User]"] C --> D["Sink: User => IO[Unit]"]
| |
Prefer a shorter operator? Cats (and Scalaz years before it) exposes the classic “fish” >=> as syntax sugar for andThen, plus >>>/<<< via import cats.syntax.compose._. Same composition, different paint-use whatever keeps the flow readable.
| |
What’s happening here?
- Each stage is a tiny, named value with a single responsibility.
- The type tells you exactly what goes in and what comes out.
- Errors are typed at the edges (here we used exceptions for brevity; you can prefer
Either/Validated). - Composing
andThenkeeps the flow obvious.
Tip: if you want multiple failures collected (e.g., data quality), use ValidatedNel inside the stage and only fail the pipeline at the final decision point.
Part 2.5 - A mini builder (type‑safe and small)
Let’s “lift‑and‑shape” a production pattern into a small, self‑contained builder that composes a typed Kleisli as you add stages.
| |
What this gives you
- Typed endpoints with
SchemaConformsevidence at the boundaries. - A builder that only exposes
buildwhen the output type isUnit(after the sink) via anOut =:= Unitconstraint. - A composed
Kleisli[F, In, Unit]the whole time - noAny, no unsafe casts in your domain code.
Try it quickly
| |
Compile‑fail demo (comment out the given evidence above) and the source/sink calls that require it will not typecheck..
Part 3 - Add observability and retries without changing business logic
You can wrap a Kleisli to add logging/tracing/retries in one place.
| |
Because everything is just values, you can layer concerns without touching the business code.
Part 4 - Practical patterns with Kleisli
- Tracing per stage: wrap with a small decorator (as above). In prod, swap
printlnwith your logger or tracing span. - Typed failures vs aggregated validations: use
Either/EitherTfor fail‑fast steps andValidatedNelto collect multiple issues before a sink. - Retry/backoff at edges: compose a
withRetrywrapper around the few stages that hit flaky systems. - Testing: run stages as plain functions with test inputs; for
IO, prefercats-effecttesting utilities (e.g.,TestControl) instead ofunsafeRunSync. - Observability: since stages are values, you can add metrics once and reuse the wrapped stage wherever needed.
Small before/after to internalize “effects at edges”:
| |
How other teams already do this
Look at the way the Cats docs teach Reader (aka Kleisli) as a pipeline of small Jobs: they model each topic of work as a Kleisli stage and wire the stages with traverse, exactly the way we want to stitch Spark transforms.
Community answers echo the same advice: if you already have A => F[B] slices in prod, wrap them in Kleisli once and use andThen instead of hand-rolled glue.
Flink’s own documentation describes a pipeline as a chain of operators flowing from source to sink. Map each operator to a Kleisli, and you get a mental model that works for both the Table API and your Scala glue code—one stage per arrow, all composed left to right.
A quick note on why not just pick one effect
Most teams standardise on one runtime-Cats Effect IO, ZIO Task, whatever ships with the platform-and that’s fine. The point of Kleisli isn’t to flip runtimes every sprint. It’s to tame everything that sits in F: audit logging, notifications, lineage, retries, metrics. Those are the things that make pipelines noisy. When each stage is an A => F[B], you can wrap that arrow once with withAudit, withNotifications, withRetries, and the business code stays untouched. Composition stays readable because the glue is a value, not scattered side effects.
Part 5 - When not to reach for Kleisli
A => B functions. Don’t wrap everything just because you can.- Inside a single pure transform: just write
A => B. - When your steps aren’t effectful: function composition is simpler.
- When the abstraction leaks too many types: prefer small helpers and introduce Kleisli only at module boundaries.
Rule of thumb: use Kleisli where IO begins and ends-sources, sinks, boundaries, service calls, and stage glue. Keep inner transforms as plain A => B.
Appendix - Kleisli cheat sheet
| |
References
- Typelevel Cats - Kleisli and composition: https://typelevel.org/cats/datatypes/kleisli.html
- Typelevel Cats - Traverse docs on building pipelines: https://typelevel.org/cats/typeclasses/traverse.html
- Rock the JVM - Scala with Cats course: https://rockthejvm.com/courses/cats/
- Rock the JVM - Cats Effect course: https://rockthejvm.com/courses/cats-effect/
- “Kleisli composition” explainer (theory and (>=>) laws): https://xenon.stanford.edu/~hwatheod/monads.html
- Stack Overflow - When to reach for Kleisli in production pipelines: https://stackoverflow.com/questions/2828588/how-to-compose-functions-that-return-monads
- Apache Flink docs - How operator chains define a pipeline: https://nightlies.apache.org/flink/flink-docs-release-1.18/docs/concepts/programming-model/
- Background post on compile‑time contracts: /compile-time-data-contracts
- FlowForge docs (production builder wiring tracing/lineage with Kleisli): https://github.com/vim89/flowforge/blob/main/docs/start-here.md
TL;DR
- Kleisli turns
A => F[B]back into a composable building block. - A Kleisli is a named step; steps click together like an assembly line.
- Avoid nested
flatMaps - read pipelines left → right withandThen. - Use Kleisli at IO boundaries; keep transforms pure and tiny.
Bonus - Type‑aligned composition
Sometimes you want to accumulate stages of different shapes (Kleisli[F, A, B], then Kleisli[F, B, C], etc.) and end up with one pipeline. The most type‑safe approach is to compose into a single Kleisli[F, In, Out] value as you add stages (exactly what our mini‑builder does) - not a list of erased functions.
If you need an intermediate collection, use a type‑aligned sequence where each element’s output matches the next element’s input. In code, prefer composing immediately:
| |
No Any, no casts - just type‑checked composition.
Minimal typestate builder with typed endpoints
Below is a compact sketch that borrows a proven layout: typed sources/sinks guarded by compile‑time contract evidence and a transform in the middle.
| |
This is purposely compact to fit a post. The ideas mirror what you’d do in production: use Kleisli for composition, use phantom types to force a correct sequence, and gate typed endpoints with SchemaConforms evidence so schema drift is a compile‑time error.
classDiagram
class Builder~F,In,Out~ {
- k: Kleisli~F,In,Out~
+ addTransform()
+ build(): Kleisli~F,In,Out~
}
class Kleisli~F,A,B~
Builder --> Kleisli
Part 6 - Instrumentation hooks that don’t leak into business code
Per‑stage logs/metrics/lineage shouldn’t be sprinkled into every function. Add them from the outside by wrapping stages with a small decorator that emits Start/Complete/Fail events and timing. Keep it optional so the same pipeline runs with or without it.
| |
Use it where you need it:
| |
Result: per‑stage observability with no code changes in your transforms.
sequenceDiagram
actor Dev
participant Pipe as Pipeline
participant Emit as Emitter
participant Stage as "Stage.execute"
Dev->>Pipe: run()
Pipe->>Emit: start(stage)
Pipe->>Stage: Kleisli.run(a)
alt success
Stage-->>Pipe: result
Pipe->>Emit: success(stage, tookMs)
else failure
Stage-->>Pipe: error
Pipe->>Emit: failure(stage, err)
end
Part 7 - Typed endpoints (very briefly)
If you model sources/sinks with typed contracts, the “are we compatible?” question can be answered at compile time, and the failure shows up before deploy. That topic is bigger than this post; see the dedicated article for a deep dive and examples: /compile-time-data-contracts .
Part 8 - Error model, idempotency, and exactly‑once behavior at the edges
Pipelines fail in the real world; we want actionable errors and safe retries. Use a small error ADT for domain failures; prefer Either/Validated in business logic, and reserve exceptions for hard boundaries. Make sinks idempotent (upsert on a key, or keep a ledger of hashes) so retries don’t duplicate work.
| |
This keeps failures explicit and retries safe.
Part 9 - Parallelism where it actually helps
Some steps fan out naturally (read partitions, transform per partition) and then rejoin. Parallelize over input collections rather than inside a single small stage.
| |
Be selective: parallelism is great for independent items; it’s noise for single‑record transforms.
Part 10 - Resource safety (files, DBs, clients)
Leaking file handles or DB sessions will ruin your day. Use Resource (Cats Effect) to acquire/use/release safely; wrap it in Kleisli.
| |
You get correctness by construction: even on errors, resources close.
Appendix B - sbt skeleton to copy‑paste
A runnable starter accelerates learning.
| |
| |
| |
Take‑home checklist
- Contracts first: write types for producer/consumer and pick a policy.
- Typed edges: require schema evidence at your source/sink boundaries.
- Kleisli everywhere at edges: treat stages as
A => F[B]values you can compose. - Keep transforms pure: IO at the boundaries only; make sinks idempotent.
- Observability from the outside: wrap stages with timing and events; avoid logging inside transforms.
- Fast feedback: add at least one compile‑fail test to prove the policy gate.
