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.

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