Component Model

Components are the building blocks of TinySystems modules. Understanding the component model is essential for building effective modules.

What is a Component?

A component is a reusable unit of logic that:

  • Receives messages on input ports
  • Processes data according to its logic
  • Sends results to output ports
  • Maintains optional internal state
                    +------------------------------------+
                    |           COMPONENT                 |
                    |                                     |
    Input Port ---->|   +-------------------------+      |----> Output Port
                    |   |      Handle()           |      |
    Input Port ---->|   |                         |      |----> Output Port
                    |   |   - Process message     |      |
                    |   |   - Apply logic         |      |
                    |   |   - Call output()       |      |
                    |   +-------------------------+      |
                    |                                     |
                    |   +-------------------------+      |
  System ports ---->|   | Capability interfaces   |      |
  (_settings, ...)  |   | OnSettings, OnControl,  |      |
                    |   | OnReconcile, ...        |      |
                    |   +-------------------------+      |
                    +------------------------------------+

Business ports are delivered to Handle(). System ports (_settings, _control, _reconcile, _identity, _client) never reach Handle() — the framework dispatches them to typed capability interfaces instead (see Component Lifecycle).

Component Interface

Every component implements this interface:

type Component interface {
    // Metadata about the component
    GetInfo() ComponentInfo

    // Process incoming messages on business ports
    Handle(ctx context.Context, output Handler, port string, message any) Result

    // Define available ports
    Ports() []Port

    // Factory to create new instances
    Instance() Component
}

GetInfo()

Returns component metadata displayed in the UI:

func (c *MyComponent) GetInfo() module.ComponentInfo {
    return module.ComponentInfo{
        Name:        "my-component",      // Unique identifier
        Description: "Brief description", // Short summary
        Info:        "Detailed info...",  // Extended documentation
        Tags:        []string{"utility", "transform"},
    }
}

Handle()

The core processing function. Called for every message arriving on a business port:

func (c *MyComponent) Handle(
    ctx context.Context,      // Request context (deadlines, tracing)
    output module.Handler,    // Callback to send output
    port string,              // Which port received the message
    message any,              // The message data (typed)
) module.Result {             // Ok(...) for success, Fail(err) for failure
    switch port {
    case "input":
        result := process(message)
        // output() also returns Result — chain it up the call stack so
        // blocking-I/O callers receive the synchronous response
        return output(ctx, "output", result)
    }
    return module.Fail(fmt.Errorf("unknown port: %s", port))
}

Both Handle and the injected output handler return module.Result — a value carrying either a payload or an error. Construct it with module.Ok(v) / module.Fail(err); inspect it with res.Err() / res.Value(). Never silently drop a Result from output() — the return must propagate back to blocking callers (an http_server waiting on its response, for example).

Ports()

Defines the component's interface:

func (c *MyComponent) Ports() []module.Port {
    return []module.Port{
        {
            Name:          "input",
            Label:         "Input",
            Position:      module.Left,
            Source:        false,           // Input port
            Configuration: InputType{},
        },
        {
            Name:          "output",
            Label:         "Output",
            Position:      module.Right,
            Source:        true,            // Output port
            Configuration: OutputType{},
        },
    }
}

Instance()

Factory method creating new component instances:

func (c *MyComponent) Instance() module.Component {
    return &MyComponent{
        settings: Settings{
            DefaultValue: "default",
        },
    }
}

Important: Always return a new instance with default values. Don't return c itself.

Component Lifecycle

System ports are dispatched exclusively through typed capability interfaces — there is no fallback to Handle(). A component opts into each callback by implementing the corresponding interface:

CapabilityCallbackPurpose
IdentityAwareOnIdentity(id v1alpha1.NodeIdentity)Node name, namespace, flow, project
ClientAwareOnClient(client module.K8sClient)Kubernetes client access
NATSAwareOnNATS(js jetstream.JetStream)JetStream handle (nil without TINY_NATS_URL)
StatefulOnState(s module.State)Persistent state backend
EmitterAwareOnEmitter(emit module.Handler)Long-lived handler for emits from goroutines
ReconcileHandlerOnReconcile(ctx, node v1alpha1.TinyNode) errorReact to TinyNode reconciles, restore state
SettingsHandlerOnSettings(ctx, settings any) errorApply user-provided settings
ControlHandlerOnControl(ctx, control any) errorDashboard widget interaction

The framework enforces this dispatch order on a fresh runner:

1. REGISTRATION (at module startup)
   registry.Register(&MyComponent{})

2. DISCOVERY (TinyNode created)
   Scheduler calls Instance(); new component instance created

3. LIFECYCLE CALLBACKS (framework-enforced order)
   OnIdentity  — node knows who it is
   OnClient    — K8s client wired up
   OnNATS      — JetStream handle wired up
   OnState     — state backend wired up (first update only)
   OnEmitter   — long-lived emit handler wired up (first update only)
   OnReconcile — restore from metadata, react to spec
   OnSettings  — apply settings (always wins over reconcile-restored state)

4. RUNNING (message processing)
   Handle(ctx, output, "input", message) — business ports only

5. RECONCILIATION
   OnReconcile fires again on every TinyNode reconcile.
   OnSettings re-fires only when the configured value changes.

6. DESTRUCTION (TinyNode deleted)
   Context cancelled; optional Destroyer.OnDestroy(metadata) runs
   for cleanup when the node is actually deleted.

Because OnReconcile is guaranteed to fire before OnSettings, settings always win over reconcile-restored state — no guard flags needed.

Embedding module.Base

Most components should embed module.Base, which satisfies Stateful, IdentityAware, ClientAware, and EmitterAware and stashes the injected dependencies behind accessors:

type MyComponent struct {
    module.Base
    settings Settings
}

func (c *MyComponent) OnSettings(ctx context.Context, settings any) error {
    s, ok := settings.(Settings)
    if !ok {
        return fmt.Errorf("expected Settings, got %T", settings)
    }
    c.settings = s
    return nil
}

func (c *MyComponent) Handle(ctx context.Context, output module.Handler, port string, msg any) module.Result {
    // c.State(), c.Identity(), c.Client(), c.Emit(...) available via Base
    return output(ctx, "output", process(msg, c.settings))
}

Base does not implement ReconcileHandler or SettingsHandler — those are always component-specific. If you override OnState, OnIdentity, or OnClient, call the embedded Base method too so the accessors keep working.

Stateless vs Stateful Components

Stateless Components

Most components should be stateless - they only use:

  • Incoming message data
  • Settings (via OnSettings)
type StatelessComponent struct {
    settings Settings  // Only store settings
}

func (c *StatelessComponent) OnSettings(ctx context.Context, settings any) error {
    c.settings = settings.(Settings)
    return nil
}

func (c *StatelessComponent) Handle(ctx context.Context, output module.Handler, port string, msg any) module.Result {
    // All data comes from msg or c.settings
    input := msg.(Input)
    result := transform(input, c.settings)
    return output(ctx, "output", result)
}

Stateful Components

Some components need to maintain state:

  • Accumulators, counters
  • Connection pools
  • Running timers
type StatefulComponent struct {
    module.Base
    settings   Settings
    counter    int64
    cancelFunc context.CancelFunc
    mu         sync.Mutex  // Protect concurrent access
}

func (c *StatefulComponent) Handle(ctx context.Context, output module.Handler, port string, msg any) module.Result {
    c.mu.Lock()
    defer c.mu.Unlock()

    c.counter++
    // Use c.counter in processing
    return module.Ok(nil)
}

Important: Stateful components require careful handling for:

  • Thread safety (use mutexes)
  • Resource cleanup (respect context cancellation)
  • Multi-replica scenarios (see Scalability)

For state that must survive restarts and replicas, prefer the injected module.State backend (c.State() via Base) over raw fields — it persists through node metadata and converges across replicas.

Component State Sharing

For multi-replica scenarios, share state via TinyNode metadata. Read it in OnReconcile; write it by emitting a patch callback to the reconcile port:

func (c *MyComponent) OnReconcile(ctx context.Context, node v1alpha1.TinyNode) error {
    sharedValue := node.Status.Metadata["my-key"]
    // Use shared value
    return nil
}

func (c *MyComponent) Handle(ctx context.Context, output module.Handler, port string, msg any) module.Result {
    // Update shared state (leader only)
    if utils.IsLeader(ctx) {
        if res := output(ctx, v1alpha1.ReconcilePort, func(node *v1alpha1.TinyNode) error {
            node.Status.Metadata["my-key"] = "new-value"
            return nil
        }); res.Err() != nil {
            return res
        }
    }
    return module.Ok(nil)
}

The module.State backend (available via Base) wraps this pattern behind Get/Set/Delete/List. See CR-Based State Propagation for details.

Best Practices

1. Keep Components Focused

Each component should do one thing well:

// Good: Single responsibility
type JSONParser struct{}    // Only parses JSON
type HTTPClient struct{}    // Only makes HTTP requests

// Bad: Multiple responsibilities
type DoEverything struct{}  // Parses, requests, transforms, logs...

2. Use Typed Messages

Always define typed structs for ports:

// Good: Typed messages
type Input struct {
    UserID string `json:"userId"`
    Action string `json:"action"`
}

// Bad: Untyped
func (c *Component) Handle(..., msg any) module.Result {
    data := msg.(map[string]interface{})  // Fragile
}

3. Handle All Business Ports

System ports never reach Handle(), so a port switch only needs your business ports. Don't ignore unknown ones:

func (c *Component) Handle(ctx context.Context, output module.Handler, port string, msg any) module.Result {
    switch port {
    case "input":
        // Handle input
        return output(ctx, "output", process(msg))
    default:
        return module.Fail(fmt.Errorf("unknown port: %s", port))
    }
}

4. Respect Context

Always check context for cancellation:

func (c *Component) Handle(ctx context.Context, output module.Handler, port string, msg any) module.Result {
    for _, item := range items {
        select {
        case <-ctx.Done():
            return module.Fail(ctx.Err())  // Stop processing
        default:
            process(item)
        }
    }
    return module.Ok(nil)
}

Next Steps