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.
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.