Skip to main content
Aether is built around one organizing principle: the engine is a pure scheduler, not an executor. It reads workflow documents, maintains the phase state machine, determines which tasks are ready to run, and dispatches them through a broker — but it never contains a single line of business logic. Every infrastructure concern is expressed as a Go interface, injected at construction time, and enforced at the compiler level by the internal/ package boundary. Understanding this separation is the foundation for understanding every other design decision in Aether.

The engine as pure scheduler

The engine’s job is narrow and precisely bounded:
  1. Determine readiness — for a dag template, a task is ready when all its declared dependencies have reached a terminal phase. For a loop template, the next iteration is ready when the previous one completes and the loop condition still holds.
  2. Dispatch — call broker.TaskBroker.Dispatch with a TaskAssignment that carries all resolved inputs, secrets, and metadata the worker needs.
  3. React — when OnTaskStarted or OnTaskCompleted fires, update phase state, fire lifecycle hooks, and call advanceScope to re-evaluate the graph.
The engine never reads files, never makes network calls, never evaluates expressions directly (that is delegated to expr.Evaluator), and never writes to a log. All of its observable effects pass through the injected interfaces.
PhaseSkipped and PhaseCancelled are set exclusively by the engine. Executors return an integer ExecCode; the engine maps it to a Phase. Users can influence the mapping via phaseConditions expressions, but they cannot set engine-reserved phases directly.

Hexagonal architecture

Aether’s architecture is hexagonal (also called ports-and-adapters). The core engine lives at the center; all infrastructure lives at the edges behind interface ports. Dependencies flow inward only — implementations depend on the engine’s interface definitions, never the reverse.
Each port is a Go interface in its own package. The package contains only the interface definition and any supporting types — no implementation. Implementations are provided by the caller through functional options (option.go) and are never imported by the core engine package.

The ten extension interfaces

Required interfaces

These four must be provided at construction time. aether.New returns an error if any is absent.

Optional interfaces

These are provided through functional options. The engine behaves identically without them, with features simply unavailable. Two additional optional interfaces extend engine capabilities further:

Wiring example

The minimal required wiring, taken directly from the playground’s buildEngine function:
Adding optional interfaces:
WithExecutor and WithExecutorRegistry are two ways to register the same thing. Use WithExecutor to register plugins one at a time; use WithExecutorRegistry when you already have a pre-built *executor.Registry that you share with the broker or worker layer.

The scope-tree scheduling model

Every workflow execution forms a tree of TaskRun records connected by ParentRunID. This tree is the “scope tree.” Understanding it explains how any level of nesting schedules correctly with the same algorithm. Root scope. When a workflow is submitted, the engine creates a single root TaskRun for the entrypoint template. If the entrypoint is a dag, that TaskRun becomes the parent for all tasks in the DAG. Child scopes. When a dag task references another template (which may itself be a dag, task, or loop), a new child TaskRun is created with ParentRunID set to the parent DAG’s TaskRun. This nesting can be arbitrarily deep (up to maxNestedDepth, default 3, max 10). advanceScope. After any task completes, the engine calls advanceScope(parentRunID). This function:
  1. Loads all TaskRun records sharing the given parentRunID
  2. Finds tasks whose dependencies are all terminal — these are newly ready
  3. Dispatches each ready task to the broker
  4. If all tasks in the scope are terminal, the scope itself is finalized
  5. Walks upward to the parent’s parentRunID and repeats
This recursive walk means a deeply nested DAG inside a loop inside another DAG all converges through the same advanceScope function. There are no special cases for nesting depth.
When fetch-data completes, advanceScope(entrypoint-dag) finds that transform-data’s only dependency is now Succeeded, dispatches it, then returns. When transform-data completes, the same function dispatches notify. When notify completes, all tasks in entrypoint-dag’s scope are terminal, so entrypoint-dag itself is finalized and the workflow completes.

Fat task assignments

Workers never query the store.Store. This is a deliberate constraint, not an oversight. When the engine dispatches a task, it builds a broker.TaskAssignment that carries everything the worker needs: resolved input parameters, executor type and schema, task and template names, retry count, timeout, and secret references. The worker receives this struct from the broker, calls executor.Plugin.Execute, and reports the result back through broker.CompleteTask. It never needs to look up the workflow or any sibling task’s state. This design eliminates a class of distributed coupling problems. Workers can run in isolated processes, in separate data centers, or without any network access to the engine’s state store. They are simple, stateless, and independently testable.

Phase ownership

The engine is the sole writer of Phase values on WorkflowRun and TaskRun records. The phase state machine is:
Executors return an ExecCode integer. The engine maps ExecCode to Phase: Users can influence the mapping with phaseConditions expressions on a task declaration. For example, a task can treat ExecCodeFailed as Succeeded when a specific output condition holds. However, PhaseSkipped (set when a when condition evaluates to false) and PhaseCancelled (set by engine.Cancel) are exclusively engine semantics. Executors cannot return these phases.

The internal/ boundary

All scheduling algorithms, binding logic, validation, retry/timeout mechanics, and DAG traversal live under internal/. The Go compiler enforces this boundary: no code outside the github.com/BabySid/aether module can import internal/ packages. This means implementation details can never leak into the public API, and callers can never depend on internal behavior. Outside of internal/, every package (except the top-level aether package itself) is a pure interface layer — it defines a contract and supporting types, nothing else. This makes each interface package a stable, independently versioned contract. Key internal/ subsystems:

Engine public API surface

Engine is the only struct exposed to callers. Its API is intentionally small: Everything else is opt-in through functional options. There are no global singletons, no init() side effects, and no hidden configuration files.

Executor plugin

Implement the executor.Plugin interface for your own task execution logic

Store interface

Build a persistent store.Store backed by your database of choice

Broker interface

Implement broker.TaskBroker for distributed or queue-based task dispatch

Other interfaces

expr.Evaluator, hook.Notifier, secret.Provider, timeout.Watcher, and more