DEV Community

Sergey Khalilov
Sergey Khalilov

Posted on AI-assisted

How I built my own Airflow/Temporal (no)

My name is Sergey. For 20+ years, I've been hitting the same wall: business logic in projects grows too complex, and after a while, even the author can't explain how it works.

Let me be clear: the title is a joke, but it's not lying. Airflow and Temporal are fantastic tools, but they are in a different weight class. They need schedulers, servers, distributed workers, and durable state. I needed a tool for a scenario that lives for milliseconds inside a single process-on the server or in the browser. Dragging a grand piano just to play two notes felt a bit irrational.

The Basic Scenario

A basic scenario: get user input, validate, submit, show the result, handle errors. A classic in-out flow. Sounds simple. But in a real project, it turns into a knot of conditions, checks, and error handlers scattered across dozens of classes, helpers, and files.

The developer is afraid to touch someone else's code ("it might break and I'll get blamed"). A new hire spends a week figuring out how it works ("if it works, don't touch it"). The documentation goes stale and describes things that no longer exist. The client has no idea what they are paying for.

In theory, all of this is fixed by layers, services, and event buses. But in practice, the human factor (and AI is not a magic bullet) and tight deadlines take their toll.

Standard Solutions and the AI Hype

On one hand, the onboarding time for a new developer with AI has noticeably decreased. On the other hand, it's the same old story: we used to spend weeks of engineering time, now we're burning tokens like there's no tomorrow. And the core problem remains. Neural networks often try to fix things they don't understand, breaking the architecture. The context is smeared across hundreds of files, and the model just can't see the big picture. There is so much noise in the code that extracting the meaning sometimes takes more time and money than digging up and re-reading the original specs.

People work with people and build products for people. No AI eliminates the need to explain the logic to a colleague or a product manager. Process is more important than tooling. The standard approach with AI hasn't changed anything-it just sped up (or complicated) the digging.

How I Solved It for Myself

I threw away the debugger and went to draw trees. Lots of trees-beautiful and different.

There are plenty of diagramming tools without me. FigJam, Miro, Enterprise Architect with BPMN 2.0-all of them can draw processes that the business will understand and approve. But a pretty picture in Miro doesn't become working code. There is always a human translating arrows into if and try/catch. And every time the diagram changes, this work has to be redone.

Low-code and node-based tools are closer, but they have their own issues. React Flow and AntV X6 are visualization libraries. The graph is built imperatively in code (nodes, edges, positions) and lives separately from the business logic. It's beautiful, but the structure becomes another layer you have to maintain alongside the code. Node-RED and Blockly are separate environments where logic lives in a sandbox, and you have to figure out how to get it into production later.

I wanted the graph to be a working config for the runtime, not just a picture for a presentation. So that the same structure is understandable to a human on a diagram and executed as code. Without a second source of truth.

That's how the slapflow library was born. It lets you describe a business process as a graph: step > condition > next step or error handling. The key here is that the steps remain plain functions-small, understandable, and easy to test. slapflow just glues these steps together. The graph is the data.

How It Plays With Familiar Approaches

slapflow doesn't try to replace Repository, Atomic Design, DDD, or FSD-it gives them an explicit place in the graph. Here's how it looks on the frontend and backend.

Backend: Repository and Unit of Work.

// An action is a plain function. The runtime doesn't know what's inside-
// a DB, HTTP, or S3. It only knows it's a function.
'order.create': async ({ context, input }) => {
  const order = await context.db.transaction(async (tx) => {
    const created = await tx.orders.create(input)
    await tx.stock.reserve(created.items)
    return created
  })
  return { orderId: order.id }
}
Enter fullscreen mode Exit fullscreen mode

If stock.reserve fails, tx rolls back everything, and the throw goes to the strategy's catch branch. slapflow doesn't manage transactions-it manages what to do if something goes wrong and where to go next.

Backend: Saga with compensations.

config: {
  strategies: {
    'order.place': {
      fn: 'order.create',
      then: [{ strategy: 'payment.charge' }],
      catch: [
        {
          strategy: 'payment.refund',
          when: ['truthy', '$data.payment.charged'],
        },
        {
          strategy: 'order.markFailed',
          when: ['falsy', '$data.payment.charged'],
        },
      ],
    },
    'payment.charge': {
      fn: 'payment.charge',
      catch: [{ strategy: 'order.cancel' }],
    },
  },
}
Enter fullscreen mode Exit fullscreen mode

Classic saga: order > payment > refund if something breaks. The catch branches here are not "exception handling"-they are a full compensation graph. It reads like a diagram on a whiteboard.

Backend: Idempotency and circuit breaker.

events: {
  'payment.charge': {
    entrypoint: 'payment.charge',
    options: {
      concurrency: { mode: 'drop', key: ({ idempotencyKey }) => idempotencyKey },
    },
  },
},
config: {
  types: { idempotencyKey: 'string' },
  strategies: {
    'catalog.fetch': {
      fn: 'catalog.fetchUpstream',
      then: [
        { strategy: 'catalog.serveStale', when: ['in', '$context.circuit.state', ['open', 'half-open']] },
      ],
      catch: [
        { strategy: 'catalog.serveStale', when: ['eq', '$data.error.kind', 'network'] },
      ],
    },
  },
}
Enter fullscreen mode Exit fullscreen mode

drop discards a repeated request with the same idempotencyKey while the first one is still running-no Redis needed, the pool lives in memory. The circuit breaker state lives in the context, when decides whether to go upstream or serve stale cache. Patterns remain patterns-they just find a place in the graph.

Frontend: Atomic Design without leaking logic into components.

// molecules/AddToCartButton.tsx
export function AddToCartButton({ productId }: { productId: string }) {
  // invoke - is an illustrative call to the entrypoint; the exact method name
  // depends on how you wrap the runtime in your app.
  const { invoke } = useRuntime()
  return (
    <Button onClick={() => invoke('cart.add', { productId })}>
      Add to cart
    </Button>
  )
}
Enter fullscreen mode Exit fullscreen mode

The molecule triggers the action but doesn't know what's inside: stock validation, auth check, optimistic store update, error rollback. All of this is in the cart.add graph. Atomic Design stays in place: atoms are buttons and inputs, and the graph tree is the skeleton of the scenario into which components are inserted as blocks.

Frontend: Optimistic update as try/catch.

config: {
  strategies: {
    'cart.add': {
      fn: 'cart.addOptimistic', // updates store immediately
      then: [{ strategy: 'cart.syncServer' }],
      catch: [
        { strategy: 'cart.rollback', when: ['truthy', '$data.optimistic.applied'] },
        { strategy: 'cart.showError', when: ['falsy', '$data.optimistic.applied'] },
      ],
    },
  },
}
Enter fullscreen mode Exit fullscreen mode

cart.addOptimistic adds the item to the store immediately-the user sees the result instantly. If cart.syncServer fails, the catch branch rolls back the local state. If the optimistic update itself fails, we show an error. Optimistic UI is essentially try/catch, and slapflow makes it explicit.

Frontend: FSD and layered architecture.

In Feature-Sliced Design, scenarios live in the features layer (add to cart, log in, search). This is exactly what slapflow describes with a graph. Domain actions remain in entities, the scenario is in features, and widgets/pages just trigger the entrypoint.

src/
  entities/
    cart/
      model/
        actions.ts        // slapflow-actions: cart.addOptimistic, cart.rollback
        flow.ts           // slapflow-strategies for cart.add
      ui/
        AddToCartButton.tsx
  features/
    add-to-cart/
      model/
        flow.ts           // entrypoint + bindings for the scenario
      index.ts            // public API of the feature
  widgets/
    product-card/
      ui/
        ProductCard.tsx   // triggers invoke('cart.add', ...)
Enter fullscreen mode Exit fullscreen mode

The widget has no idea what's under the hood-a graph, a call stack, or one long async function. If your project has a processes layer (e.g., "checkout" that pulls together cart, payment, delivery, and notifications), it's a great place for a composite graph.

Other patterns naturally fit here too: CQRS (queries and commands as different entrypoints with their own concurrency), feature flags via a simple when at the strategy entry, rate limiting via a worker pool with backpressure, and retry with backoff.

slapflow doesn't replace these patterns-it gives them a place in the graph. You still write plain functions with repositories, transactions, and API clients. But the connection between functions is data, not code hiding deep inside .then().

What It Gives You in Practice

Steps are reused. The same catalog.fetch can be called from three scenarios with different guard conditions, and you don't have to duplicate logic. The diagram is visible: you can draw it and show it to a product manager or a client-"here we check stock, here we retry, here we show an error." It's a language everyone understands without reading code. Error handling becomes explicit: no need to catch everything in one place; for each step, you can describe what to do on failure. And it's easier for neural networks to work with-a structured graph and small functions mean less noise, fewer tokens, and cheaper analysis.

Technical Details

slapflow is a library for JS runtimes (Node.js, Bun, browser). It weighs less than 20 KB gzipped, has exactly one dependency (objwalk). It has five concurrency modes (including a worker pool with backpressure), built-in execution tracing, and two levels of runtime protection: types (input field validation) and when (guard conditions on the object shape).

For visualization, there is slapflow-studio-a canvas editor that draws strategies as nodes and then/catch as edges. Honestly, it's a raw alpha for now, but you can already look at your graph with your eyes.

What slapflow Doesn't Do

It doesn't replace Airflow or Temporal. If you need process crash resilience, restart recovery, distributed workers, or pauses for days with human intervention-you need exactly those tools. And it doesn't remove the need to write code-it gives it a clear structure.

Links

The project is open-source, no monetization:

How Do You Solve This Problem?

How do you explain business logic to non-coders? Do you draw diagrams in Miro that no one updates after the first sprint? Do you write Confluence pages that are already lying a month later? Or do you honestly live with the fact that "only John knows how this works, but John is on vacation"?

I'd also love to hear: has AI helped you understand a legacy project, or did you drown in the noise too?

Happy to hear constructive (or at least funny) feedback!

Top comments (0)