DEV Community

Hayden Rear
Hayden Rear

Posted on

Decoupling Behavior and Data

-- written by ChatGPT - edited by me --

Intro

Extensibility of logic is always difficult. How do we add a particular case in an algebra? How do we handle the control flow? We can't use goto, and sometimes we end with callback hell with monadic styles. So what are we left with? We want a data-oriented programming design - this provides us with the ability to reason about the code easily, because of the fact that it's discrete. But then we always have the undefined nature of recursion, something Nasa thought is dangerous as it's hard to reason about stopping conditions. The AI is very good at math, and coding is getting there, but how can we make our code more logical, more algebraic, and get a recursive style for free, without the complexity?

At first I thought free monads another crazy idea from the academic side. But then, once you think about it within the context of what's been happening in databases, decoupling storage and compute, you start to realize it's more a natural extension of a paradigm shift that's happening. Once you understand what a free monad is, the intuition of it, you realize there are places where the ability to reason about and extend the code increases. It fits very nicely within the greater paradigm shift of code as data, and you get reusability at yet another level. In this case, maybe the experts are right!

Declarative Algebras, One-Step Execution, and the Hidden Free Monad

Describe first, act later.
Capture intent as data, postpone behavior to a pluggable interpreter—then watch extensibility emerge.


1. The principle

Across cloud warehouses, build pipelines, and micro-services, the winning pattern is:

  1. Capture intent as plain data – inspectable, serializable, versionable.
  2. Defer behavior to a separate phase – interpret, optimize, simulate, or replay whenever you like.

A fancy name for that pattern—one we won’t reveal until the very end—is the free monad.
But let’s get there step by step.


2. Build an extensible algebra

Start with zero side effects. Each constructor is frozen intent plus “what to do next.”

// Java 21 sealed algebra of document operations
sealed interface DocOp<A> permits Read, Transform, Store { }

record Read<A>(
    String path,
    java.util.function.Function<String,A> next           // carry on with file text
) implements DocOp<A> { }

record Transform<A>(
    java.util.function.Function<String,String> fn,       // pure transform
    java.util.function.Function<String,A> next           // carry on with new text
) implements DocOp<A> { }

record Store<A>(
    String dest, String content,
    A next                                               // nothing to pass along
) implements DocOp<A> { }
Enter fullscreen mode Exit fullscreen mode

Need a new feature tomorrow—say NotifySlack? Add one more record; the compiler makes you handle it explicitly.


3. Stitch steps declaratively

We need a container that chains these atoms without running them.
Here’s a micro-“script” type (a tiny free monad):

sealed interface Script<A> {
  /* A finished program */
  record Pure<A>(A value) implements Script<A> { }

  /* One pending step followed by the rest */
  record Step<X,A>(
      DocOp<X> op,
      java.util.function.Function<X, Script<A>> cont
  ) implements Script<A> { }

  static <A> Script<A> pure(A a) { return new Pure<>(a); }

  /* Declarative bind */
  default <B> Script<B> then(java.util.function.Function<A, Script<B>> f) {
    return switch (this) {
      case Pure<A>(var v) -> f.apply(v);
      case Step<X,A>(var op, var k) ->
        new Step<>(op, x -> k.apply(x).then(f));
    };
  }
}
Enter fullscreen mode Exit fullscreen mode

Write workflows as data:

Script<Void> build =
  new Script.Step<>(new Read<>("src/doc.txt", Script::pure), raw ->
  new Script.Step<>(new Transform<>(String::trim, Script::pure), clean ->
  new Script.Step<>(new Store<>("dist/doc.txt", clean, null), __ ->
  Script.pure(null))));
Enter fullscreen mode Exit fullscreen mode

Still no I/O!


4. One-step interpreter: jump, return, repeat

interface Engine<F> { <T> F run(Script<T> script); }

/* Production engine using async I/O */
class IOEngine implements Engine<java.util.concurrent.CompletableFuture<?>> {

  public <T> java.util.concurrent.CompletableFuture<T> run(Script<T> s) {
    while (true) {                               // ← single tail-rec loop
      switch (s) {
        case Script.Pure<T>(var v) ->            // ① finished
          return java.util.concurrent.CompletableFuture.completedFuture(v);

        case Script.Step<Object,T>(var op, var k) -> {
          switch (op) {                          // ② match the case
            case Read(var p, var nxt) -> {
              return java.nio.file.Files.readString(java.nio.file.Path.of(p))
                  .thenCompose(txt -> run(k.apply(nxt.apply(txt)))); // ③ jump
            }
            case Transform(var fn, var nxt) -> {
              s = k.apply(nxt.apply(fn.apply("")));
              break;                             // loop continues
            }
            case Store(var d, var c, var nxt) -> {
              return java.nio.file.Files.writeString(java.nio.file.Path.of(d), c)
                  .thenCompose(__ -> run(k.apply(nxt)));
            }
          }
        }
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode
  • Peel one Step.
  • Handle the matching constructor.
  • Return the next Script (or tail-recurse).

That short “jump-to-next-step” loop never changes—add new cases, plug them in, done.


5. Recursive composition for free

Because every step returns another script, scripts compose like Lego:

Script<String> readClean =
  new Script.Step<>(new Read<>("in.txt", Script::pure), raw ->
  new Script.Step<>(new Transform<>(String::trim, Script::pure), Script::pure));

Script<Void> saveTwice =
  readClean.then(clean ->
    new Script.Step<>(new Store<>("out.txt",    clean, null), __ ->
    new Script.Step<>(new Store<>("backup.txt", clean, null), __ ->
    Script.pure(null))));
Enter fullscreen mode Exit fullscreen mode

No matter how deep the graph, the interpreter still just peels one layer, does work, repeats.


6. Why step-at-a-time rocks

Benefit Comes from “handle one step, return the rest”
Infinite pausing You can pause, inspect, rewrite, or migrate mid-script.
Fine-grained billing/retry Each node is an observable unit of work.
Hot-swappable engines Same script runs on I/O, mocks, audit logs, …
Local reasoning & changes Add/adjust a constructor; compiler forces local updates.

7. The hidden reveal

If the structure feels familiar—a functor of single actions wrapped in a bindable container—you’ve secretly rebuilt the free monad for DocOp.

So the next time you separate intent from effect, remember:
that jump-to-next-step interpreter is the categorical machinery that makes modular, recursive, endlessly extensible software almost trivial.

Happy decoupling!

Top comments (0)