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:
| Capability | Callback | Purpose |
|---|---|---|
IdentityAware | OnIdentity(id v1alpha1.NodeIdentity) | Node name, namespace, flow, project |
ClientAware | OnClient(client module.K8sClient) | Kubernetes client access |
NATSAware | OnNATS(js jetstream.JetStream) | JetStream handle (nil without TINY_NATS_URL) |
Stateful | OnState(s module.State) | Persistent state backend |
EmitterAware | OnEmitter(emit module.Handler) | Long-lived handler for emits from goroutines |
ReconcileHandler | OnReconcile(ctx, node v1alpha1.TinyNode) error | React to TinyNode reconciles, restore state |
SettingsHandler | OnSettings(ctx, settings any) error | Apply user-provided settings |
ControlHandler | OnControl(ctx, control any) error | Dashboard 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
- Ports and Messages - Deep dive into port system
- Message Flow - Understanding execution flow
- Component Patterns - Common implementation patterns