Project Structure
This guide explains how to organize a TinySystems module project for maintainability and scalability.
Recommended Structure
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_splitlives in packagesplit) - 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(orControlRunning/ControlStoppedfor 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
- Component Model - Understand the component lifecycle
- Testing Components - Write comprehensive tests
- Building Modules - Prepare for deployment