Module Scaffold

There is no scaffolding command. A new module starts from the example-module template repository: open it on GitHub, click Use this template, and clone your copy.

git clone https://github.com/my-org/my-module
cd my-module
go mod edit -module github.com/my-org/my-module

What the Template Contains

my-module/
+-- cmd/
|   +-- main.go              # Entry point: cobra root + cli.RegisterCommands
+-- components/
|   +-- echo/
|       +-- echo.go          # Example component, self-registers via init()
+-- .github/workflows/
|   +-- release.yml          # Tag push -> build image -> push to GHCR
+-- Dockerfile               # golang:1.25 builder -> distroless static:nonroot
+-- release.sh               # Tag/bump helper
+-- go.mod                   # Depends on github.com/tiny-systems/module (the SDK)
+-- README.md

No Makefile, no Helm chart, no .tinysystems.yaml — the module is just a Go program plus a Dockerfile; deployment uses the shared operator chart.

cmd/main.go

The entry point builds a cobra root command, registers the SDK's commands on it, and blank-imports every component package so their init() functions run:

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/example-module/components/echo" // registers the component
    "github.com/tiny-systems/module/cli"
)

var rootCmd = &cobra.Command{
    Use:   "server",
    Short: "tiny-system's example 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)
    }
}

There is no module.NewWithComponents and no cli.Run() — registration is decentralized: each component package calls registry.Register in its own init(), and the SDK CLI (run, tools ..., pre-install, pre-delete) picks up whatever is registered.

components/<name>/<name>.go

Each component lives in its own package and self-registers:

package echo

import (
    "context"
    "fmt"

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

const (
    ComponentName        = "echo"
    InPort        string = "in"
    OutPort       string = "out"
)

type Component struct{}

func (t *Component) Instance() module.Component { return &Component{} }

func (t *Component) GetInfo() module.ComponentInfo {
    return module.ComponentInfo{
        Name:        ComponentName,
        Description: "Echo",
        Info:        "Sends the same message as it receives",
        Tags:        []string{"Echo", "Demo"},
    }
}

func (t *Component) Handle(ctx context.Context, handler module.Handler, port string, msg interface{}) module.Result {
    if in, ok := msg.(InMessage); ok {
        return handler(ctx, OutPort, OutMessage{Context: in.Context})
    }
    return module.Fail(fmt.Errorf("invalid message"))
}

var _ module.Component = (*Component)(nil)

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

Handle returns module.Result (built with module.Ok / module.Fail), and chaining the handler(...) result up the call stack is what makes blocking I/O (synchronous responses flowing back) work.

Adding a Component

  1. Create components/mycomponent/mycomponent.go with the Component struct, GetInfo, Ports, Handle, Instance, and an init() that calls registry.Register.
  2. Blank-import it in cmd/main.go:
import (
    _ "github.com/my-org/my-module/components/echo"
    _ "github.com/my-org/my-module/components/mycomponent"
)
  1. Verify: go run ./cmd tools components-info lists it.

Dockerfile

The template's Dockerfile builds the binary and makes it the image entrypoint:

FROM --platform=$BUILDPLATFORM golang:1.25 AS builder
ARG TARGETOS
ARG TARGETARCH
ARG VERSION
WORKDIR /manager
COPY go.mod go.sum ./
RUN go mod download
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

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"]

Best Practices

  • One package per component under components/; keep port constants and message types in the same file or a sibling.
  • Add a _ module.Component = (*Component)(nil) assertion so interface drift fails at compile time.
  • Ship tests next to each component (echo_test.go); Handle is a plain function call to test.

Next Steps