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
- Create
components/mycomponent/mycomponent.gowith theComponentstruct,GetInfo,Ports,Handle,Instance, and aninit()that callsregistry.Register. - Blank-import it in
cmd/main.go:
import (
_ "github.com/my-org/my-module/components/echo"
_ "github.com/my-org/my-module/components/mycomponent"
)
- Verify:
go run ./cmd tools components-infolists 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);Handleis a plain function call to test.
Next Steps
- Local Testing - Run and test the module
- Development Commands - Command reference
- Building Modules - Build for production