Conditional Router Component
A complete example of a routing component with multiple output paths, modeled on the real router component from common-module.
Overview
This component routes messages to different output ports based on per-message conditions. It demonstrates:
- Dynamic output ports derived from settings
- Condition evaluation on the edge (expressions), not in Go
- Default routing
- The mid-chain
contextpassthrough convention
The key design point: the router itself contains no comparison logic. Route names come from settings; each incoming message carries a conditions array whose booleans are computed by edge expressions. The first condition that is true wins.
Complete Implementation
package router
import (
"context"
"fmt"
"strings"
"github.com/tiny-systems/module/api/v1alpha1"
"github.com/tiny-systems/module/module"
"github.com/tiny-systems/module/registry"
)
const (
ComponentName = "router"
InPort = "input"
DefaultPort = "default"
)
// Settings drive which output ports exist
type Settings struct {
Routes []string `json:"routes" required:"true" title:"Routes" minItems:"1" uniqueItems:"true"`
EnableDefaultPort bool `json:"enableDefaultPort" required:"true" title:"Enable default port"`
}
type Context any
// Condition pairs a route name with a boolean computed on the edge
type Condition struct {
Route string `json:"route" required:"true" title:"Route"`
Condition bool `json:"condition" required:"true" title:"Condition"`
}
// InMessage is what arrives on the input port
type InMessage struct {
Context Context `json:"context" configurable:"true" required:"true" title:"Context" description:"Arbitrary message to be routed"`
Conditions []Condition `json:"conditions" required:"true" title:"Conditions" minItems:"1" uniqueItems:"true"`
}
// OutMessage keeps the routed payload UNDER a `context` key — the same shape
// every mid-chain component uses — so a downstream edge reads
// $.context.<field> consistently.
type OutMessage struct {
Context Context `json:"context" configurable:"true" title:"Context" description:"Passthrough — the routed message, unchanged"`
}
type Component struct {
settings Settings
}
func (t *Component) Instance() module.Component {
return &Component{
settings: Settings{Routes: []string{"A", "B"}},
}
}
func (t *Component) GetInfo() module.ComponentInfo {
return module.ComponentInfo{
Name: ComponentName,
Description: "Router",
Info: "Conditional message router. Configure routes via settings; output ports are named out_<lowercase(route)>. Routes context to the FIRST condition where condition=true. If none match: with enableDefaultPort=true routes to 'default', otherwise returns an error.",
Tags: []string{"SDK"},
}
}
// OnSettings receives Settings from the SettingsPort.
func (t *Component) OnSettings(_ context.Context, msg any) error {
in, ok := msg.(Settings)
if !ok {
return fmt.Errorf("invalid settings")
}
t.settings = in
return nil
}
// Handle routes business-port input to the matching output.
func (t *Component) Handle(ctx context.Context, handler module.Handler, port string, msg any) module.Result {
in, ok := msg.(InMessage)
if !ok {
return module.Fail(fmt.Errorf("invalid message on port %q", port))
}
for _, condition := range in.Conditions {
if !condition.Condition {
continue
}
if t.hasRoute(condition.Route) {
return handler(ctx, getPortNameFromRoute(condition.Route), OutMessage{Context: in.Context})
}
break
}
if t.settings.EnableDefaultPort {
return handler(ctx, DefaultPort, OutMessage{Context: in.Context})
}
return module.Fail(fmt.Errorf("no matching route: %v", in.Conditions))
}
// Ports derives one output port per configured route.
func (t *Component) Ports() []module.Port {
ports := []module.Port{
{
Name: v1alpha1.SettingsPort,
Label: "Settings",
Configuration: t.settings,
},
{
Position: module.Left,
Name: InPort,
Label: "IN",
Configuration: InMessage{},
},
}
for _, r := range t.settings.Routes {
ports = append(ports, module.Port{
Position: module.Right,
Name: getPortNameFromRoute(r),
Label: strings.ToUpper(r),
Source: true, // outputs are Source: true
Configuration: new(OutMessage),
})
}
if t.settings.EnableDefaultPort {
ports = append(ports, module.Port{
Position: module.Bottom,
Name: DefaultPort,
Label: "Default",
Source: true,
Configuration: new(OutMessage),
})
}
return ports
}
func (t *Component) hasRoute(name string) bool {
for _, r := range t.settings.Routes {
if strings.EqualFold(r, name) {
return true
}
}
return false
}
func getPortNameFromRoute(route string) string {
return fmt.Sprintf("out_%s", strings.ToLower(route))
}
var (
_ module.Component = (*Component)(nil)
_ module.SettingsHandler = (*Component)(nil)
)
func init() {
registry.Register((&Component{}).Instance())
}
The shipped common-module router additionally wraps route names in a RouteName type with a custom JSON schema so the UI shows a dropdown of configured routes; the version above keeps plain strings for clarity.
Usage Example
Settings Configuration
The _settings port configuration:
{
"routes": ["CRITICAL", "WARNING"],
"enableDefaultPort": true
}
This produces output ports out_critical, out_warning, and default.
Edge Configuration (Conditions)
Conditions are computed per message on the incoming edge with boolean-returning expressions (ajson JSONPath + comparisons, &&, ||, ternary):
{
"context": "{{$.context}}",
"conditions": [
{ "route": "CRITICAL", "condition": "{{$.context.severity == 'critical' || $.context.severity == 'error'}}" },
{ "route": "WARNING", "condition": "{{$.context.severity == 'warning'}}" }
]
}
Routing Behavior
Incoming severity | Emitted on |
|---|---|
critical or error | out_critical |
warning | out_warning |
| anything else | default (or an error if enableDefaultPort is false) |
Important: each out_<route> port emits { "context": ... } — a downstream edge reads $.context.<field>, not $.<field>.
Visual Flow
+---------------------+
| Router |
| |
Input --------->| routes: |
| CRITICAL |------> out_critical
| WARNING |------> out_warning
| |
| enableDefaultPort |------> default
+---------------------+
Key Patterns Demonstrated
1. Dynamic Output Ports
Ports() is re-evaluated after settings change, so the node's shape follows its configuration:
for _, r := range t.settings.Routes {
ports = append(ports, module.Port{
Name: getPortNameFromRoute(r),
Source: true,
Configuration: new(OutMessage),
Position: module.Right,
})
}
2. Conditions Belong on the Edge
The component only checks booleans. All comparison logic lives in edge expressions, which keeps the router generic and lets the platform validate the expressions against the upstream port schema.
3. First Match Wins
for _, condition := range in.Conditions {
if !condition.Condition {
continue
}
// route on the first true condition
}
Order your conditions from most to least specific.
4. Default Fallback
Prefer enableDefaultPort: true when unmatched messages should be ignored — leave default unwired to drop them silently. With the default port disabled, an unmatched message fails the Handle call and surfaces as an error to the caller.
5. Context Passthrough
The router forwards the payload unchanged under context. It never merges route metadata into the message — downstream knows which branch it is on by which port it is wired to.
Extension Ideas
- Multi-Match Broadcast: emit on every matching route instead of the first (remember to check each handler call's
Resultand stop on error) - Route Metadata: include the matched route name in
OutMessage - Percentage Split: weighted random routing for A/B tests