Architecture

Clearing Extension Points

How routing strategies, enrichers, middleware, connectors, and workflow listeners plug into the engine through fx option groups — without forking it.

Ductor is meant to be extended without editing the core. The mechanism is go.uber.org/fx option groups: a module contributes an implementation tagged with a group name, and the engine collects everything in that group at startup. Adding a strategy, a connector, or a pipeline stage is a matter of registering into the right group — no changes to the composition root, no engine fork.

Option groups as the seam

When many implementations of the same kind should be discovered and wired together, fx uses a value group. A provider tags its output with group:"<name>", and a consumer receives the whole slice. Ductor uses this pattern for every pluggable subsystem. The groups the engine collects include:

GroupWhat it contributes
routing_optionsOptions that configure the routing service
pipeline_stagesCustom stages inserted into the routing pipeline
routing_enrichersEnrich-stage contributors (attach data before selection)
routing_middlewareCross-cutting middleware around routing
connector_interceptorsInterceptors wrapping connector dispatch
workflow_lifecycle_listenersHooks on workflow run lifecycle events
effect_intent_handlersHandlers that drain effect-intent outbox entries
digest_fire_handlersHandlers invoked when a digest window fires
notification_provider_handlersNotification delivery providers
api_service_entriesConnect/gRPC service registrations

The seam is fan-in: independent modules each tag a contribution with the same group name, never referencing one another, and the consumer receives the whole slice at construction time. Adding an extension means adding a provider — not editing the consumer:

group: pipeline_stages group: pipeline_stages group: pipeline_stages slice module A fx value group module B module C routing pipeline

Because contribution is by group, the set of active extensions is just "whatever modules were included in this build" — which is exactly what modules/defaults/defaults.go assembles for the production binary.

Strategies

A recipient-selection Strategy is registered through the strategy registry (pkg/strategy/registry.go); built-ins live in pkg/strategy/builtin/. The engine calls Info() first to check compatibility, then Select() on the hot path. A strategy can opt into extra capability interfaces:

  • BatchStrategy — score many candidates at once.
  • ExplainStrategy — return why a candidate won.
  • HealthCheckStrategy — report readiness before use.

To ship a custom strategy you implement the interface and register it; the routing pipeline picks it up like any built-in. See Writing a Custom Strategy.

Remote and tenant-governed strategies

A strategy doesn't have to run in-process. A governed remote strategy deployment delegates selection to an external service over a Connect RPC — the engine holds a PluginService client and calls Select() on the hot path (pkg/strategy/remotegrpc/). This is the seam for tenant-supplied selection logic that must stay outside the core binary.

Two properties keep this surface safe:

  • Startup validation. Before a remote deployment is exposed through the live registry, the adapter opens the connection, runs a protocol handshake to negotiate a supported version, and performs an initial health check. A deployment that fails either never enters the routing path — it fails visibly at load rather than at first request. The adapter also carries per-call guards: a timeout, a circuit breaker, an in-flight cap, and payload/response size limits.
  • Tenant scoping. Deployments are keyed by tenant and environment (application/routing/remotestrategy/). A deployment's tenant_id, environment, and strategy_name must match its binding, and the derived registry key is tenant/environment-scoped by default — so one tenant's custom strategy can't shadow another's. A CertificationGate blocks uncertified deployments from entering authoritative routing; the production composition root wires the gate or refuses to boot.

See Governance for the certification and rollout controls around custom/remote runtimes.

Pipeline stages

The routing pipeline is itself extensible. A module contributes a stage into the pipeline_stages fx group and the engine folds every contribution into a stage registry (pkg/routing/pipeline/stage_registry.go) at startup — no fork of the engine. Each stage implements one of the role interfaces the pipeline iterates: Validator, Enricher, Filter, Selector, Assigner, or a PostRouteHook that runs after a successful assignment (pkg/routing/pipeline/stage.go).

Registration is two-tier, which is what lets a module override a standard-bundle stage deterministically:

  • RegisterDefault adds a standard-bundle stage. It is a no-op if a module-supplied stage with the same name already exists — a default may never demote an override.
  • Register adds a module stage, and replaces a default of the same name. Two non-default registrations of the same name collide.

A collision surfaces at fx wire time, not at first request: contributing two producers with the same stage Name() makes fx.New(...).Err() non-nil at boot (cmd/ductor/fx_routing_stages.go), consistent with the "every path either works or fails visibly" rule.

Connectors

A connector contributes a provider definition to the ProviderRegistry and one or more ActionSpecs to the ActionRegistry, and can wrap dispatch via the connector_interceptors group. Provider implementations live under infrastructure/connector/providers/. Adding one requires no change to the coordinator or the dispatch path — the registries are the whole contract.

Workflow archetypes

The step types in The DAG Workflow Model are built on an archetype abstraction with namespaced kinds. Core step kinds are core.*, first-party AI kinds are ai.*, and plugins register vendor.<name>.* kinds. An archetype implements the coordinator's switch points — schedule a ready node, apply an attempt result, apply a drained signal, apply a fired timer — plus optional capabilities (cancelable, replicable for cross-region, resettable). Kinds must be namespaced; an un-namespaced kind is rejected at fx wire-up, which keeps the extension surface disciplined.

In-process plugin lifecycle and inspection

The generic pkg/plugin registry manages in-process plugin objects. It is distinct from fx value groups, the strategy registry, and connector provider registries; use the interface for the subsystem you are extending.

Registry.StartAll starts plugins in registration order. If startup fails, it stops the plugins that successfully started in reverse order. The returned error preserves startup and rollback failures. Cleanup that fails remains owned by the registry: StopAll can retry it, without stopping already-cleaned plugins again. New registrations are rejected while the registry is started or cleanup is pending. A PluginChain likewise requires a successful Start before Process can run, and rejects processing after failed startup or after Stop.

Registry inspection captures an ordered snapshot of names and plugin references under the registry lock, then releases that lock before invoking plugin code:

MethodSnapshot behavior
ListCalls Info in registration order
ListByTypeCalls Info and filters by the reported plugin type
EachVisits entries in registration order and stops when the callback returns false
HealthCheckAllCalls each plugin's health check and retains its returned error under the registered name

These inspection callbacks may reenter the registry. Registrations added during an inspection appear on the next inspection; a removed plugin may still appear in the current snapshot. Registry mutation rules still apply. The snapshot contains references, not deep copies, so plugin implementations must synchronize their own mutable state.

Registry lifecycle callbacks also run outside its lock. A plugin's Start or Stop can inspect the registry; competing Register, Unregister, StartAll, and StopAll calls return ErrPluginLifecycleBusy while that lifecycle operation is running. IsStarted becomes true only after successful startup and becomes false before shutdown callbacks begin. Unregister keeps an entry visible until its cleanup succeeds, retaining it if cleanup fails.

Chain clones and resource ownership

PluginChain.Clone copies the configured chain without starting it. Clones share the registered plugin instances and their lifecycle ownership: the first active chain starts an instance, and the last active chain stops it. An unstarted clone does not retain resources. Stopping the original chain leaves an active clone able to process work, and failed clone startup releases only that clone's acquired references.

If final cleanup fails, the last owner retains its reference for a Stop retry. Other clones receive ErrPluginCleanupPending when they try to start against that resource. An overlapping physical start or stop returns ErrPluginLifecycleBusy. After successful cleanup, a stopped clone can start a fresh lifecycle.

Use Clone to preserve shared registration ownership. Registering plugin objects in independently constructed chains or registries creates separate ownership; supply independently owned instances for those registrations.

Chain calls and shutdown

Both Process and HealthCheck require a successfully started chain. Health checks on an idle clone return ErrPluginNotStarted for each configured plugin, even when another chain owns the shared instances. Processing and health checks hold execution references until their callbacks return. Each call uses a stable snapshot of its plugin sequence and configuration, including logging and metrics.

Stop immediately closes admission and sets IsStarted to false, then waits for active calls using the supplied context. If that wait expires, it returns ErrPluginStopFailed wrapping the context error. Resources remain available to admitted work, and new calls remain rejected. Once work finishes, retry Stop to release resources; Start and SetConfig reject the pending cleanup state. Failed plugin cleanup also retains ownership for a later retry.

Lifecycle, execution, health, and metadata callbacks run outside the chain's state lock. They can inspect the chain. Competing lifecycle operations return ErrPluginLifecycleBusy while startup, draining, or cleanup is in progress. Plugin hooks remain responsible for honoring their contexts. A callback that requests its own chain's shutdown must use a deadline: its execution reference cannot drain until that callback returns.

Live chain registration

Registration and removal accept a context and return an error:

PhaseRegisterRemove
TransformerAddTransformer(ctx, plugin, priority)RemoveTransformer(ctx, name)
EnricherAddEnricher(ctx, plugin, priority)RemoveEnricher(ctx, name)
FilterAddFilter(ctx, plugin, priority)RemoveFilter(ctx, name)

Names must be unique across the chain, and metadata must identify the matching phase with its required versions. Nil instances and invalid metadata are rejected. The registered name remains the identity used by inspection, health results, execution errors, and removal, even if later metadata changes. Higher priorities run first; equal priorities retain insertion order.

Before startup, registration stores configuration. On a running chain, it starts the candidate before publishing it. Failed startup or cancellation rolls back only the candidate. If that cleanup succeeds, the prior chain remains available. If cleanup fails, the candidate stays unpublished but owned, admission closes, and Stop must drain active work and finish cleanup before restart.

Live removal waits for admitted processing and health calls, then releases the target before removing its registration. Give it a deadline. If waiting expires, the registration and resources remain intact and the prior chain becomes available again. Other active clones continue to own shared resources.

If target cleanup fails, the registration remains visible and the chain becomes unavailable. Retry the same removal to finish cleanup and resume the remaining chain. Other mutations and restart return ErrPluginCleanupPending. Calling Stop instead cancels resumption and shuts down the whole chain; remove the unwanted registration after successful shutdown before starting again. Removing an unknown name returns ErrPluginNotFound.

Mutations exclude competing lifecycle changes and new calls with ErrPluginLifecycleBusy; admitted calls keep their captured sequence. Callbacks can inspect the chain. Additions affect only the receiving chain, while existing clones retain their own configured sequences and shared ownership handles. A registry inspection snapshot does not acquire lifecycle ownership.

Static plugin loading

pkg/plugin/loader.StaticLoader connects compiled-in module factories to the generic plugin lifecycle. Create one with NewStaticLoader, or use its zero value. A factory registers through this method:

RegisterStatic(name string, factory func(map[string]any) (plugin.Plugin, error)) error

The DNC, Jornaya, TrustedForm, and Stripe modules expose Register(loader) helpers that accept this loader directly. They register dnc-scrubber, jornaya, trustedform, and stripe-billing, respectively. Registration records a constructor; it does not activate a provider.

Each factory must construct a fresh, unstarted plugin. Acquire live resources in Plugin.Start, and give each independently managed registry or chain its own instance. Factories must perform bounded local work, clean up partial construction on error, and treat the supplied configuration as read-only. Callers must not mutate configuration maps during loading. Concurrent loads may invoke the same factory concurrently.

OperationBehavior
RegisterStaticRejects empty names, nil factories, and duplicate registrations
Load(ctx, name, config)Captures one factory and validates the constructed instance
LoadAll(ctx, configs)Captures the complete factory set once and constructs in sorted name order
NamesReturns a sorted, detached snapshot
Unregister and ClearChange subsequent loads without altering an already captured factory set

Factory and metadata callbacks run outside the registry lock. They may inspect or change registrations; those changes do not replace factories already captured by a load. LoadAll returns no partial batch if construction fails. Neither load method calls Start or Stop.

Both load methods reject canceled contexts before construction and check cancellation before returning results. A synchronous constructor must return before cancellation can be reported. Nil and typed-nil instances, missing required metadata, and names that differ from the registered name are rejected. Construction failures wrap ErrPluginLoadFailed and preserve their underlying errors for errors.Is and errors.As. Unknown names return ErrPluginNotFound; duplicate registrations return ErrPluginAlreadyRegistered.

Successful loading establishes a valid plugin object. Configure the module's credentials and dependencies, then let its lifecycle owner start it and check health before use. Static construction alone does not establish provider readiness.

Modules

modules/ holds optional feature modules (forecast, postback, retention, SLA, custom strategies, plugins). Each self-registers through the groups above, and modules/defaults/defaults.go bundles the standard set for the production binary. This is how a deployment tailors the engine: include the modules you want, and their contributions flow into the right groups automatically.

Extend at the seam, not the core

The layering rules still apply to extensions. A strategy or connector lives in its own package and contributes through a group; it does not reach into the coordinator or mutate run state. The dependency rules apply to extensions just as they do to the core.

Where to go next