Project Structure

This guide explains how to organize a TinySystems module project for maintainability and scalability.

my-module/
+-- cmd/
|   +-- main.go                 # Entry point (cobra root command)
+-- components/
|   +-- component1/
|   |   +-- component.go        # Component implementation
|   |   +-- types.go            # Message and settings types
|   |   +-- component_test.go   # Tests
|   +-- component2/
|   |   +-- component.go
|   |   +-- types.go
|   |   +-- component_test.go
|   +-- shared/                 # Shared utilities (optional)
|       +-- utils.go
+-- pkg/                        # Reusable packages (optional)
|   +-- myutils/
|       +-- helpers.go
+-- Dockerfile
+-- go.mod
+-- go.sum
+-- Makefile                    # Build automation
+-- README.md

This is the layout of the official example-module template ("Use this template" on GitHub gives you a ready copy).

File Responsibilities

cmd/main.go

The entry point blank-imports all components (so their init() runs) and registers the SDK's commands on a cobra root command:

package main

import (
    "context"
    "fmt"
    "os"
    "os/signal"
    "syscall"

    "github.com/rs/zerolog"
    "github.com/spf13/cobra"
    "github.com/spf13/viper"
    "github.com/tiny-systems/module/cli"

    // Import all components to register them via init()
    _ "github.com/myorg/my-module/components/filter"
    _ "github.com/myorg/my-module/components/router"
    _ "github.com/myorg/my-module/components/transform"
)

var rootCmd = &cobra.Command{
    Use:   "server",
    Short: "my-module",
    Run: func(cmd *cobra.Command, args []string) {
        cmd.Help()
    },
}

func main() {
    zerolog.SetGlobalLevel(zerolog.InfoLevel)
    viper.AutomaticEnv()
    if viper.GetBool("debug") {
        zerolog.SetGlobalLevel(zerolog.DebugLevel)
    }

    ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
    defer stop()

    cli.RegisterCommands(rootCmd)
    if err := rootCmd.ExecuteContext(ctx); err != nil {
        fmt.Printf("command execute error: %v\n", err)
    }
}

components/*/component.go

Each component lives in its own package:

package router

import (
    "context"

    "github.com/tiny-systems/module/module"
    "github.com/tiny-systems/module/registry"
)

const ComponentName = "router"

type Component struct {
    settings Settings
}

// ... module.Component implementation ...

func init() {
    registry.Register(&Component{})
}

components/*/types.go

Keep type definitions separate for clarity. Struct tags stay on one line:

package router

// Port message types
type Input struct {
    Context    any         `json:"context" configurable:"true" required:"true" title:"Context"`
    Conditions []Condition `json:"conditions" required:"true" title:"Conditions" minItems:"1"`
}

type Condition struct {
    Route     string `json:"route" required:"true" title:"Route"`
    Condition bool   `json:"condition" required:"true" title:"Condition"`
}

// Settings
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"`
}

// Control port schema (if needed)
type Control struct {
    Status string `json:"status" readonly:"true" title:"Status"`
}

components/*/component_test.go

Test each component. A test handler is a module.Handler — it returns module.Result:

package router

import (
    "context"
    "testing"

    "github.com/tiny-systems/module/module"
)

func TestComponent_Handle(t *testing.T) {
    component := &Component{}
    component.settings = Settings{
        Routes: []string{"success", "failure"},
    }

    var capturedPort string
    var capturedData any

    handler := func(ctx context.Context, port string, data any) module.Result {
        capturedPort = port
        capturedData = data
        return module.Ok(nil)
    }
    _ = capturedData

    // Test routing to first matching condition
    input := Input{
        Context: map[string]string{"key": "value"},
        Conditions: []Condition{
            {Route: "success", Condition: true},
        },
    }

    result := component.Handle(context.Background(), handler, "input", input)

    if err := result.Err(); err != nil {
        t.Errorf("expected success, got error: %v", err)
    }
    if capturedPort != "out_success" {
        t.Errorf("expected out_success, got %s", capturedPort)
    }
}

Naming Conventions

Components

  • Package name: lowercase, single word (e.g., router, filter)
  • Component name: snake_case, used in the TinyNode spec (e.g., array_split lives in package split)
  • Const for name: ComponentName = "router"

Ports

  • Input ports: Descriptive names (in, input, request)
  • Output ports: out, response, or per-route names (out_success)
  • Settings port: Use v1alpha1.SettingsPort
  • Control port: Use v1alpha1.ControlPort
  • Reconcile port: Use v1alpha1.ReconcilePort

Types

  • Input types: InMessage, or suffix with purpose (Request)
  • Output types: OutMessage, Response
  • Settings: Named Settings
  • Control: Named Control (or ControlRunning/ControlStopped for stateful looks)

Multi-Component Module Example

Here's the structure of the common-module:

common-module/
+-- cmd/
|   +-- main.go
+-- components/
|   +-- router/         # Conditional routing (component: router)
|   +-- split/          # Array iteration (component: array_split)
|   +-- modify/         # Passthrough/remap helper (component: transform)
|   +-- async/          # Non-blocking hand-off
|   +-- ticker/         # Periodic emission
|   +-- cron/           # Scheduled emission
|   +-- delay/          # Timed delay
|   +-- signal/         # Manual trigger
|   +-- retry/          # Explicit retry supervisor
|   +-- debug/          # Message sink
|   +-- kv/             # Key-value store
|   +-- groupby/        # Array grouping (component: group_by)
+-- go.mod
+-- Dockerfile

Makefile

Automate common tasks:

.PHONY: build test run docker

MODULE_NAME := my-module
VERSION := 1.0.0

build:
    go build -o $(MODULE_NAME) ./cmd

test:
    go test ./... -v

run: build
    ./$(MODULE_NAME) run \
        --name=$(MODULE_NAME) \
        --version=$(VERSION) \
        --namespace=tinysystems

docker:
    docker build -t myregistry/$(MODULE_NAME):$(VERSION) .

push: docker
    docker push myregistry/$(MODULE_NAME):$(VERSION)

info: build
    ./$(MODULE_NAME) tools components-info

lint:
    golangci-lint run ./...

clean:
    rm -f $(MODULE_NAME)

Dockerfile

The standard multi-stage build (golang 1.25 builder, distroless static runtime, run as the container command):

FROM --platform=$BUILDPLATFORM golang:1.25 AS builder
ARG TARGETOS
ARG TARGETARCH
ARG VERSION

WORKDIR /manager

# Cache dependencies
COPY go.mod go.sum ./
RUN go mod download

# Build
COPY . .
RUN CGO_ENABLED=0 GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH:-amd64} \
    go build -ldflags="-X github.com/tiny-systems/module/cli.versionID=${VERSION}" \
    -o /bin/manager ./cmd

# Runtime stage
FROM gcr.io/distroless/static:nonroot
COPY --from=builder /bin/manager /manager
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
USER 65532:65532
CMD ["/manager", "run"]

Environment Configuration

Common environment variables:

# Kubernetes (usually auto-configured in cluster)
KUBERNETES_SERVICE_HOST=kubernetes.default.svc
KUBERNETES_SERVICE_PORT=443

# OpenTelemetry (optional)
OTLP_DSN=http://otel-collector:4317

# NATS JetStream for durable execution (optional)
TINY_NATS_URL=nats://nats:4222

# Pod identification (set by Kubernetes)
HOSTNAME=my-module-abc123

The gRPC server binds to :0 by default; the Helm chart sets --grpc-server-bind-address=:8483 in-cluster.

Go Module Dependencies

Typical go.mod:

module github.com/myorg/my-module

go 1.25

require (
    github.com/rs/zerolog v1.34.0
    github.com/spf13/cobra v1.10.1
    github.com/spf13/viper v1.21.0
    github.com/tiny-systems/module v0.13.59
)

The SDK pulls in all necessary Kubernetes and gRPC dependencies.

Next Steps