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:
| Group | What it contributes |
|---|---|
routing_options | Options that configure the routing service |
pipeline_stages | Custom stages inserted into the routing pipeline |
routing_enrichers | Enrich-stage contributors (attach data before selection) |
routing_middleware | Cross-cutting middleware around routing |
connector_interceptors | Interceptors wrapping connector dispatch |
workflow_lifecycle_listeners | Hooks on workflow run lifecycle events |
effect_intent_handlers | Handlers that drain effect-intent outbox entries |
digest_fire_handlers | Handlers invoked when a digest window fires |
notification_provider_handlers | Notification delivery providers |
api_service_entries | Connect/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:
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'stenant_id,environment, andstrategy_namemust 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. ACertificationGateblocks 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:
RegisterDefaultadds 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.Registeradds 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:
| Method | Snapshot behavior |
|---|---|
List | Calls Info in registration order |
ListByType | Calls Info and filters by the reported plugin type |
Each | Visits entries in registration order and stops when the callback returns false |
HealthCheckAll | Calls 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:
| Phase | Register | Remove |
|---|---|---|
| Transformer | AddTransformer(ctx, plugin, priority) | RemoveTransformer(ctx, name) |
| Enricher | AddEnricher(ctx, plugin, priority) | RemoveEnricher(ctx, name) |
| Filter | AddFilter(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)) errorThe 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.
| Operation | Behavior |
|---|---|
RegisterStatic | Rejects 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 |
Names | Returns a sorted, detached snapshot |
Unregister and Clear | Change 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
- Layered Architecture — the rules extensions must respect.
- The Routing Pipeline — the stages you can extend.
- Connectors — the registry contract for providers.