Handling Messages
The Handle() method is where your component processes incoming messages on its business ports. Understanding message handling patterns is essential for building effective components.
Basic Message Handling
func (c *MyComponent) Handle(
ctx context.Context,
output module.Handler,
port string,
msg any,
) module.Result {
if port == "input" {
input, ok := msg.(InputMessage)
if !ok {
return module.Fail(fmt.Errorf("invalid message on port %q", port))
}
return output(ctx, "output", c.process(input))
}
return module.Fail(fmt.Errorf("unknown port %q", port))
}
The Handler Signature
type Handler func(ctx context.Context, port string, data any) module.Result
| Parameter | Description |
|---|---|
ctx | Context for cancellation and tracing |
port | Output port name to send to |
data | Data to send |
| Returns | module.Result — carries the downstream response or an error |
The Result Type
Every Handler call and every Handle invocation returns module.Result. Construct it with the package helpers — never zero-value it manually outside test code:
module.Ok(v) // success with payload (v may be nil: "delivered, no response data")
module.Fail(err) // failure (Fail(nil) is a successful no-op)
r.Err() // the wrapped error, or nil on success
r.Value() // the wrapped payload, or nil on failure
r.IsErr() // true when the result wraps an error
Why a typed result instead of any or error? The return must propagate up through blocking-I/O call chains — http-server hands a request to its downstream port and waits on the synchronous response flowing back. A dropped return means a lost response and a timed-out request. Result makes the return explicit at every call site.
System Ports Never Reach Handle
Handle is called for business ports only. Settings, control, reconcile, client and identity messages are dispatched through capability interfaces (OnSettings, OnControl, OnReconcile, ...) before anything reaches Handle. A switch port { case v1alpha1.SettingsPort: ... } inside Handle is dead code.
See System Ports and Settings and Configuration.
Type Assertions
Messages arrive already decoded into the port's Configuration type. Use the safe two-value assertion:
func (c *Component) Handle(ctx context.Context, output module.Handler, port string, msg any) module.Result {
input, ok := msg.(InputMessage)
if !ok {
return module.Fail(fmt.Errorf("unexpected message type: %T", msg))
}
return c.process(ctx, output, input)
}
Sending Output
Single Output
func (c *Transformer) Handle(ctx context.Context, output module.Handler, port string, msg any) module.Result {
in, ok := msg.(InMessage)
if !ok {
return module.Fail(fmt.Errorf("invalid message"))
}
// Chain the Result: the downstream response propagates back to the caller
return output(ctx, "out", OutMessage{Context: in.Context})
}
Multiple Outputs
Check .Err() on each emit before continuing:
func (c *Splitter) Handle(ctx context.Context, output module.Handler, port string, msg any) module.Result {
input := msg.(Input)
if res := output(ctx, "output_a", input.PartA); res.IsErr() {
return res
}
if res := output(ctx, "output_b", input.PartB); res.IsErr() {
return res
}
return module.Ok(nil)
}
Conditional Output
func (c *Router) Handle(ctx context.Context, output module.Handler, port string, msg any) module.Result {
input := msg.(Message)
switch input.Type {
case "urgent":
return output(ctx, "priority", input)
case "normal":
return output(ctx, "standard", input)
default:
return output(ctx, "other", input)
}
}
No Output
func (c *Logger) Handle(ctx context.Context, output module.Handler, port string, msg any) module.Result {
input := msg.(LogMessage)
log.Info().Str("level", input.Level).Msg(input.Message)
return module.Ok(nil) // no downstream emit
}
Blocking vs Non-Blocking
Default: Blocking
By default, output() blocks until the downstream flow completes, and its Result carries whatever flowed back:
func (c *Component) Handle(ctx context.Context, output module.Handler, port string, msg any) module.Result {
// Blocks until the entire downstream flow finishes
res := output(ctx, "output", msg)
if err := res.Err(); err != nil {
log.Error().Err(err).Msg("downstream failed")
}
return res
}
Time -------------------------------------------------------------->
Component A Component B Component C
| | |
| output(B) | |
| ===================+==================+ |
| | Handle() | |
| | =================+=+========+
| | | | Handle()
| | Result | | Result
| Result |=================+| |========+
| ===================+ | |
v v v
Background Emits
For fire-and-forget work from goroutines (tickers, watchers, cron loops), do not stash the output handler passed to Handle — implement EmitterAware or embed module.Base and use the injected long-lived handler:
type Component struct {
module.Base
}
func (c *Component) startLoop() {
go func() {
for range time.Tick(time.Second) {
if err := c.Emit(context.Background(), "out", payload).Err(); err != nil {
log.Warn().Err(err).Msg("downstream error")
}
}
}()
}
The injected handler stays valid for the runner's lifetime; calling it after the component is destroyed is a safe no-op.
Error Handling
Return Failures
func (c *Component) Handle(ctx context.Context, output module.Handler, port string, msg any) module.Result {
input := msg.(Input)
result, err := c.process(input)
if err != nil {
// The error propagates back through the flow
return module.Fail(fmt.Errorf("processing failed: %w", err))
}
return output(ctx, "output", result)
}
Mark Retryability
The SDK's error contract makes retryability part of the error itself. Wrap transient failures with module.Retryable; permanent ones can be marked explicitly with module.Permanent (plain errors default to not-retryable):
if resp.StatusCode >= 500 {
return module.Fail(module.Retryable(fmt.Errorf("upstream 5xx: %s", body)))
}
if resp.StatusCode == 400 {
return module.Fail(module.Permanent(fmt.Errorf("bad request: %s", body)))
}
The scheduler and the retry component both consult module.ShouldRetry(err); only errors explicitly marked retryable are re-attempted.
Error Port Pattern
Emit the canonical module.ErrorMessage shape via module.NewError, which derives Retryable from the error:
func (c *Component) Handle(ctx context.Context, output module.Handler, port string, msg any) module.Result {
input := msg.(Input)
result, err := c.process(input)
if err != nil {
// Route to the error port instead of failing the hop.
// NewError carries {context, error, retryable}.
return output(ctx, "error", module.NewError(input, err))
}
return output(ctx, "success", result)
}
Prefer this over hand-rolled error structs — the retry component and the platform understand ErrorMessage by construction.
Context Usage
Cancellation
func (c *LongProcessor) Handle(ctx context.Context, output module.Handler, port string, msg any) module.Result {
items := msg.(Input).Items
for _, item := range items {
select {
case <-ctx.Done():
return module.Fail(ctx.Err()) // graceful shutdown
default:
c.processItem(item)
}
}
return module.Ok(nil)
}
Leader Check
Multi-replica modules gate side-effecting work on leadership (mostly relevant inside OnControl/OnReconcile, but available anywhere a context flows):
import "github.com/tiny-systems/module/pkg/utils"
if !utils.IsLeader(ctx) {
return nil
}
Timeout
func (c *TimedProcessor) Handle(ctx context.Context, output module.Handler, port string, msg any) module.Result {
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
result, err := c.processWithContext(ctx, msg)
if err != nil {
if errors.Is(ctx.Err(), context.DeadlineExceeded) {
return module.Fail(fmt.Errorf("processing timed out"))
}
return module.Fail(err)
}
return output(ctx, "output", result)
}
State Management
Settings
Settings arrive through SettingsHandler.OnSettings, not Handle:
type Processor struct {
settings Settings
}
func (p *Processor) OnSettings(_ context.Context, msg any) error {
in, ok := msg.(Settings)
if !ok {
return fmt.Errorf("invalid settings")
}
p.settings = in
return nil
}
func (p *Processor) Handle(ctx context.Context, output module.Handler, port string, msg any) module.Result {
// settings were applied before any business message arrives
return p.processWithSettings(ctx, output, msg, p.settings)
}
Accumulating State
type Aggregator struct {
items []Item
mu sync.Mutex
}
func (a *Aggregator) Handle(ctx context.Context, output module.Handler, port string, msg any) module.Result {
switch port {
case "item":
a.mu.Lock()
a.items = append(a.items, msg.(Item))
a.mu.Unlock()
return module.Ok(nil)
case "flush":
a.mu.Lock()
items := a.items
a.items = nil
a.mu.Unlock()
return output(ctx, "batch", Batch{Items: items})
}
return module.Fail(fmt.Errorf("unknown port %q", port))
}
In-memory state does not survive pod restarts. For durable state use the State backend (via module.Stateful or module.Base) — see Component Patterns.
Common Patterns
Pass-Through with Modification
func (c *Enricher) Handle(ctx context.Context, output module.Handler, port string, msg any) module.Result {
input := msg.(Message)
input.Timestamp = time.Now()
return output(ctx, "output", input)
}
Fan-Out
func (c *Broadcaster) Handle(ctx context.Context, output module.Handler, port string, msg any) module.Result {
for _, outPort := range []string{"out1", "out2", "out3"} {
if res := output(ctx, outPort, msg); res.IsErr() {
return res
}
}
return module.Ok(nil)
}
Fan-In (via multiple input ports)
func (c *Merger) Handle(ctx context.Context, output module.Handler, port string, msg any) module.Result {
switch port {
case "input_a", "input_b", "input_c":
return output(ctx, "merged", msg)
}
return module.Fail(fmt.Errorf("unknown port %q", port))
}
Next Steps
- Settings and Configuration - Configure components
- Control Ports - UI interaction
- Error Handling - Error patterns