Files
goplt/docs/playbook.md
0x1d 6a17236474 docs: add implementation plan, ADRs, and task tracking system
- Add comprehensive 8-phase implementation plan (docs/plan.md)
- Add 28 Architecture Decision Records (docs/adr/) covering all phases
- Add task tracking system with 283+ task files (docs/stories/)
- Add task generator script for automated task file creation
- Add reference playbooks and requirements documentation

This commit establishes the complete planning foundation for the Go
Platform implementation, documenting all architectural decisions and
providing detailed task breakdown for Phases 0-8.
2025-11-04 22:05:37 +01:00

26 KiB
Raw Blame History

GoPlatform Boilerplate Playbook

“Pluginfriendly SaaS/Enterprise Platform Go Edition”

1 ARCHITECTURAL IMPERATIVES (Goflavoured)

Principle Gospecific rationale Enforcement Technique
Clean / Hexagonal Architecture Gos packagelevel visibility (internal/) naturally creates a boundary between core and plugins. Keep all domain code in internal/domain, expose only interfaces in pkg/.
Dependency Injection (DI) via Constructors Go avoids reflectionheavy containers; compiletime wiring is preferred. Use ubergo/fx (runtime graph) or ubergo/dig for optional runtime DI. For a lighter weight solution, use plain constructor injection with a small registry.
Modular Monolith → Microserviceready A single binary is cheap in Go; later you can extract modules into separate services without breaking APIs. Each module lives in its own Go module (go.mod) under ./modules/*. The core loads them via the Go plugin system or static registration (preferred for CI stability).
Pluginfirst design Gos plugin package allows runtime loading of compiled .so files (Linux/macOS). Provide an IModule interface and a loader that discovers *.so files (or compiledin modules for CI).
APIFirst (OpenAPI + gin/gorilla) Guarantees languageagnostic contracts. Generate server stubs from an openapi.yaml stored in api/.
SecuritybyDesign Gos static typing makes it easy to keep auth data out of the request flow. Central middleware for JWT verification + contextbased user propagation.
Observability (OpenTelemetry) The Go ecosystem ships firstclass OTEL SDK. Instrument HTTP, DB, queues, and custom events automatically.
ConfigurationasCode Viper + Cobra give hierarchical config and flag parsing. Load defaults → file → env → secret manager (AWS Secrets Manager / Vault).
Testing & CI go test is fast; Testcontainers via testcontainers-go can spin up DB, Redis, Kafka. CI pipeline runs unit, integration, and contract tests on each PR.
Semantic Versioning & Compatibility Go modules already enforce version constraints. Core declares minimal required versions in go.mod and uses replace for local dev.

2 CORE KERNEL (What every Goplatform must ship)

Module Public Interfaces (exported from pkg/) Recommended Packages Brief Implementation Sketch
Config type ConfigProvider interface { Get(key string) any; Unmarshal(v any) error } github.com/spf13/viper Load defaults (config/default.yaml), then env overrides, then optional secretstore.
Logger type Logger interface { Debug(msg string, fields ...Field); Info(...); Error(...); With(fields ...Field) Logger } go.uber.org/zap (or zerolog) Global logger is created in cmd/main.go; exported via pkg/logger.
DI / Service Registry type Container interface { Provide(constructor any) error; Invoke(fn any) error } go.uber.org/dig (or fx for lifecycle) Core creates a dig.New() container, registers core services, then calls container.Invoke(app.Start).
Health & Metrics type HealthChecker interface { Check(ctx context.Context) error } github.com/prometheus/client_golang/prometheus, github.com/heptiolabs/healthcheck Expose /healthz, /ready, /metrics.
Error Bus type ErrorPublisher interface { Publish(err error) } Simple channelbased implementation + optional Sentry (github.com/getsentry/sentry-go) Core registers a singleton ErrorBus.
Auth (JWT + OIDC) type Authenticator interface { GenerateToken(userID string, roles []string) (string, error); VerifyToken(token string) (*TokenClaims, error) } github.com/golang-jwt/jwt/v5, github.com/coreos/go-oidc Token claims embed sub, roles, tenant_id. Middleware adds User to context.Context.
Authorization (RBAC/ABAC) type Authorizer interface { Authorize(ctx context.Context, perm Permission) error } Custom DSL, github.com/casbin/casbin/v2 (optional) Permission format: "module.resource.action"; core ships a simple inmemory resolver and a casbin adapter.
Audit type Auditor interface { Record(ctx context.Context, act AuditAction) error } Write to appendonly table (Postgres) or Elastic via olivere/elastic Audits include actorID, action, targetID, metadata.
Event Bus type EventBus interface { Publish(ctx context.Context, ev Event) error; Subscribe(topic string, handler EventHandler) } github.com/segmentio/kafka-go (for production) + inprocess fallback Core ships an inprocess bus used by tests and a Kafka bus for real deployments.
Persistence (Repository) type UserRepo interface { FindByID(id string) (*User, error); Create(u *User) error; … } entgo.io/ent (codegen ORM) or gorm.io/gorm Core provides an EntClient wrapper that implements all core repos.
Scheduler / Background Jobs type Scheduler interface { Cron(spec string, job JobFunc) error; Enqueue(q string, payload any) error } github.com/robfig/cron/v3, github.com/hibiken/asynq (Redisbacked) Expose a JobRegistry where modules can register periodic jobs.
Notification type Notifier interface { Send(ctx context.Context, n Notification) error } github.com/go-mail/mail (SMTP), github.com/aws/aws-sdk-go-v2/service/ses, github.com/IBM/sarama (for push) Core supplies an EmailNotifier and a WebhookNotifier.
Multitenancy (optional) type TenantResolver interface { Resolve(ctx context.Context) (tenantID string, err error) } Header/ subdomain parser + JWT claim scanner Tenant ID is stored in request context and automatically added to SQL queries via Ents Client interceptor.

All public interfaces live under pkg/ so that plugins can import them without pulling in implementation details. The concrete implementations stay in internal/ (or separate go.mod modules) and are registered with the container during bootstrap.


3 MODULE (PLUGIN) FRAMEWORK

3.1 Interface that every module must implement

// pkg/module/module.go
package module

import (
    "context"
    "go.uber.org/fx"
)

// IModule is the contract a feature plugin must fulfil.
type IModule interface {
    // Name returns a unique, humanreadable identifier.
    Name() string
    // Init registers the module's services, routes, jobs, and permissions.
    // The fx.Options returned are merged into the app's lifecycle.
    Init() fx.Option
    // Migrations returns a slice of Ent migration functions (or raw SQL) that
    // the core will run when the platform starts.
    Migrations() []func(*ent.Client) error
}

3.2 Registration Mechanics

Two ways to get a module into the platform:

Approach When to use Pros Cons
Static registration each module imports core and calls module.Register(MyBlogModule{}) in its own init() Development, CI (no .so needed) Simpler, works on Windows; compiletime type safety Requires recompiling the binary for new modules
Runtime plugin loading compile each module as a go build -buildmode=plugin -o blog.so ./modules/blog Production SaaS where clients drop new modules, or separate microservice extraction Hotswap without rebuild Only works on Linux/macOS; plugins must be compiled with same Go version & same go.mod replace graph; debugging harder

Static registration example

// internal/registry/registry.go
package registry

import (
    "sync"
    "github.com/yourorg/platform/pkg/module"
)

var (
    mu      sync.Mutex
    modules = make(map[string]module.IModule)
)

func Register(m module.IModule) {
    mu.Lock()
    defer mu.Unlock()
    if _, ok := modules[m.Name()]; ok {
        panic("module already registered: " + m.Name())
    }
    modules[m.Name()] = m
}

func All() []module.IModule {
    mu.Lock()
    defer mu.Unlock()
    out := make([]module.IModule, 0, len(modules))
    for _, m := range modules {
        out = append(out, m)
    }
    return out
}

Plugin loader skeleton

// internal/pluginloader/loader.go
package pluginloader

import (
    "plugin"
    "github.com/yourorg/platform/pkg/module"
)

func Load(path string) (module.IModule, error) {
    p, err := plugin.Open(path)
    if err != nil {
        return nil, err
    }
    sym, err := p.Lookup("Module")
    if err != nil {
        return nil, err
    }
    mod, ok := sym.(module.IModule)
    if !ok {
        return nil, fmt.Errorf("invalid module type")
    }
    return mod, nil
}

Tip: Ship a tiny CLI (platformctl modules list) that scans ./plugins/*.so, loads each via Load, and prints Name(). This is a great sanity check for ops.

3.3 Permissions DSL (compiletime safety)

// pkg/perm/perm.go
package perm

type Permission string

func (p Permission) String() string { return string(p) }

var (
    // Core permissions
    SystemHealthCheck Permission = "system.health.check"

    // Blog module  generated by a small go:generate script
    BlogPostCreate Permission = "blog.post.create"
    BlogPostRead   Permission = "blog.post.read"
    BlogPostUpdate Permission = "blog.post.update"
    BlogPostDelete Permission = "blog.post.delete"
)

A codegen tool (go generate ./...) can scan each modules module.yaml for declared actions and emit a single perm.go file, guaranteeing no duplicate strings.


4 SAMPLE FEATURE MODULE Blog

modules/
└─ blog/
   ├─ go.mod               # (module github.com/yourorg/blog)
   ├─ module.yaml
   ├─ internal/
   │   ├─ api/
   │   │   └─ handler.go
   │   ├─ domain/
   │   │   ├─ post.go
   │   │   └─ post_repo.go
   │   └─ service/
   │       └─ post_service.go
   └─ pkg/
       └─ module.go

4.1 module.yaml

name: blog
version: 0.1.0
dependencies:
  - core >= 1.3.0
permissions:
  - blog.post.create
  - blog.post.read
  - blog.post.update
  - blog.post.delete
routes:
  - method: POST
    path: /api/v1/blog/posts
    permission: blog.post.create
  - method: GET
    path: /api/v1/blog/posts/:id
    permission: blog.post.read

4.2 Go implementation

// pkg/module.go
package blog

import (
    "github.com/yourorg/platform/pkg/module"
    "go.uber.org/fx"
)

type BlogModule struct{}

func (b BlogModule) Name() string { return "blog" }

func (b BlogModule) Init() fx.Option {
    return fx.Options(
        // Register repository implementation
        fx.Provide(NewPostRepo),

        // Register service layer
        fx.Provide(NewPostService),

        // Register HTTP handlers (using Gin)
        fx.Invoke(RegisterHandlers),

        // Register permissions (optional  just for documentation)
        fx.Invoke(RegisterPermissions),
    )
}

func (b BlogModule) Migrations() []func(*ent.Client) error {
    // Ent migration generated in internal/ent/migrate
    return []func(*ent.Client) error{
        func(c *ent.Client) error { return c.Schema.Create(context.Background()) },
    }
}

// Export a variable for the plugin loader
var Module BlogModule

Handler registration (Gin example)

// internal/api/handler.go
package api

import (
    "github.com/gin-gonic/gin"
    "github.com/yourorg/blog/internal/service"
    "github.com/yourorg/platform/pkg/perm"
    "github.com/yourorg/platform/pkg/auth"
)

func RegisterHandlers(r *gin.Engine, svc *service.PostService, authz auth.Authorizer) {
    grp := r.Group("/api/v1/blog")
    grp.Use(auth.AuthMiddleware()) // verifies JWT, injects user in context

    // POST /posts
    grp.POST("/posts", func(c *gin.Context) {
        if err := authz.Authorize(c.Request.Context(), perm.BlogPostCreate); err != nil {
            c.JSON(403, gin.H{"error": "forbidden"})
            return
        }
        // decode request, call svc.Create, return 201…
    })
    // GET /posts/:id (similar)
}

Repository using Ent

// internal/domain/post_repo.go
package domain

import (
    "context"
    "github.com/yourorg/platform/internal/ent"
    "github.com/yourorg/platform/internal/ent/post"
)

type PostRepo struct{ client *ent.Client }

func NewPostRepo(client *ent.Client) *PostRepo { return &PostRepo{client} }

func (r *PostRepo) Create(ctx context.Context, p *Post) (*Post, error) {
    entPost, err := r.client.Post.
        Create().
        SetTitle(p.Title).
        SetContent(p.Content).
        SetAuthorID(p.AuthorID).
        Save(ctx)
    if err != nil {
        return nil, err
    }
    return fromEnt(entPost), nil
}

Result: Adding a new feature is just a matter of creating a new folder under modules/, implementing module.IModule, registering routes, permissions and migrations. The core automatically wires everything together.


5 INFRASTRUCTURE ADAPTERS (swapable, perenvironment)

Concern Implementation (Go) Where it lives
Database entgo.io/ent (codegen) internal/infra/ent/
Cache github.com/go-redis/redis/v9 internal/infra/cache/
Message Queue github.com/segmentio/kafka-go (Kafka) or github.com/hibiken/asynq (Redis) internal/infra/bus/
Blob Storage github.com/aws/aws-sdk-go-v2/service/s3 (or GCS) internal/infra/blob/
Email github.com/go-mail/mail (SMTP) internal/infra/email/
SMS / Push Twilio SDK, Firebase Cloud Messaging internal/infra/notify/
Secret Store AWS Secrets Manager (aws-sdk-go-v2) or HashiCorp Vault (github.com/hashicorp/vault/api) internal/infra/secret/

All adapters expose an interface in pkg/infra/… and are registered in the DI container as singletons.


6 OBSERVABILITY STACK

Layer Library What it does
Tracing go.opentelemetry.io/otel, go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp Autoinstrument HTTP, DB (Ent plugin), Kafka, Redis
Metrics github.com/prometheus/client_golang/prometheus Counter/Histogram per request, DB latency, job execution
Logging go.uber.org/zap (structured JSON) Global logger, requestscoped fields (request_id, user_id, tenant_id)
Error Reporting github.com/getsentry/sentry-go (optional) Capture panics & errors, link to trace ID
Dashboard Grafana + Prometheus + Loki (logs) Provide readymade dashboards in ops/ folder

Instrumentation example (HTTP)

import (
    "net/http"
    "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
)

func main() {
    r := gin.New()
    // Wrap the entire router with OTEL middleware
    wrapped := otelhttp.NewHandler(r, "http-server")
    http.ListenAndServe(":8080", wrapped)
}

Metrics middleware

func PromMetrics() gin.HandlerFunc {
    return func(c *gin.Context) {
        start := time.Now()
        c.Next()
        duration := time.Since(start).Seconds()
        method := c.Request.Method
        path := c.FullPath()
        status := fmt.Sprintf("%d", c.Writer.Status())
        requestDuration.WithLabelValues(method, path, status).Observe(duration)
    }
}

7 CONFIGURATION & ENVIRONMENT

config/
├─ default.yaml          # baseline values
├─ development.yaml
├─ production.yaml
└─ secrets/              # gitignored, loaded via secret manager

Bootstrap

func LoadConfig() (*Config, error) {
    v := viper.New()
    v.SetConfigName("default")
    v.AddConfigPath("./config")
    if err := v.ReadInConfig(); err != nil { return nil, err }

    env := v.GetString("environment") // dev / prod / test
    v.SetConfigName(env)
    v.MergeInConfig() // overrides defaults

    v.AutomaticEnv() // env vars win
    // optional: secret manager overlay
    return &Config{v}, nil
}

All services receive a *Config via DI.


8 CI / CD PIPELINE (GitHub Actions)

name: CI

on:
  push:
    branches: [main]
  pull_request:

jobs:
  build:
    runs-on: ubuntu-latest
    env:
      GO111MODULE: on
    steps:
      - uses: actions/checkout@v4
      - name: Set up Go
        uses: actions/setup-go@v5
        with:
          go-version: '1.22'
      - name: Cache Go modules
        uses: actions/cache@v4
        with:
          path: |
            ~/.cache/go-build
            ~/go/pkg/mod
          key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
      - name: Install Tools
        run: |
          go install github.com/vektra/mockery/v2@latest
          go install github.com/golang/mock/mockgen@latest
      - name: Lint
        run: |
          go install golang.org/x/lint/golint@latest
          golint ./...
      - name: Unit Tests
        run: |
          go test ./... -cover -race -short
      - name: Integration Tests (Docker Compose)
        run: |
          docker compose -f ./docker-compose.test.yml up -d
          go test ./... -tags=integration -count=1
          docker compose -f ./docker-compose.test.yml down
      - name: Build Binaries
        run: |
          go build -ldflags="-X main.version=${{ github.sha }}" -o bin/platform ./cmd/platform
      - name: Publish Docker Image
        uses: docker/build-push-action@v5
        with:
          context: .
          push: ${{ github.ref == 'refs/heads/main' }}
          tags: ghcr.io/yourorg/platform:${{ github.sha }}

Key points

  • go test ./... -tags=integration runs tests that spin up Postgres, Redis, Kafka via Testcontainers (github.com/testcontainers/testcontainers-go).
  • Linting via golint or staticcheck.
  • Docker image built from the compiled binary (multistage: golang:1.22-alpinescratch or distroless).
  • Semanticrelease can be added on top (semantic-release action) to tag releases automatically.

9 TESTING STRATEGY

Test type Tools Typical coverage
Unit testing, github.com/stretchr/testify, github.com/golang/mock Individual services, repositories (use inmemory DB or mocks).
Integration testcontainers-go for Postgres, Redis, Kafka; real Ent client Endtoend request → DB → event bus flow.
Contract pact-go or OpenAPI validator middleware (github.com/getkin/kin-openapi) Guarantees that modules do not break the published API.
Load / Stress k6 or vegeta scripts in perf/ Verify that auth middleware adds < 2ms latency per request.
Security gosec, zap for secret detection, OWASP ZAP for API scan Detect hardcoded secrets, SQL injection risk.

Example integration test skeleton

func TestCreatePost_Integration(t *testing.T) {
    ctx := context.Background()
    // Spin up a PostgreSQL container
    pg, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
        ContainerRequest: testcontainers.ContainerRequest{
            Image:        "postgres:15-alpine",
            Env:          map[string]string{"POSTGRES_PASSWORD": "secret", "POSTGRES_DB": "test"},
            ExposedPorts: []string{"5432/tcp"},
            WaitingFor:   wait.ForListeningPort("5432/tcp"),
        },
        Started: true,
    })
    require.NoError(t, err)
    defer pg.Terminate(ctx)

    // Build DSN from container host/port
    host, _ := pg.Endpoint(ctx)
    dsn := fmt.Sprintf("postgres://postgres:secret@%s/test?sslmode=disable", host)

    // Initialize Ent client against that DSN
    client, err := ent.Open("postgres", dsn)
    require.NoError(t, err)
    defer client.Close()
    // Run schema migration
    err = client.Schema.Create(ctx)
    require.NoError(t, err)

    // Build the whole app using fx, injecting the test client
    app := fx.New(
        core.ProvideAll(),
        fx.Provide(func() *ent.Client { return client }),
        blog.Module.Init(),
        // ...
    )
    // Start the app, issue a HTTP POST request through httptest.Server,
    // assert 201 and DB row existence.
}

10 COMMON PITFALLS & SOLUTIONS (Gocentric)

Pitfall Symptom Remedy
Circular imports (core ↔ module) import cycle not allowed build error Keep interfaces only in pkg/. Core implements, modules depend on the interface only.
Toobig binary (all modules compiled in) Long build times, memory pressure on CI Use Go plugins for truly optional modules; keep core binary minimal.
Version mismatch with plugins Panic: plugin was built with a different version of package X Enforce a single replace directive for core in each modules go.mod (e.g., replace github.com/yourorg/platform => ../../platform).
Context leakage (request data not passed) Logs missing user_id, permission checks use zerovalue user Always store user/tenant info in context.Context via middleware; provide helper auth.FromContext(ctx).
Ent migrations outofsync Startup fails with “column does not exist” Run go generate ./... (ent codegen) and ent/migrate automatically at boot after all module migrations are collected.
Hardcoded permission strings Typos go unnoticed → 403 bugs Use the generated perm package (see §4.1) and reference constants everywhere.
Blocking I/O in request path 500ms latency spikes Offload longrunning work to asynq jobs or Kafka consumers; keep request handlers thin.
Overexposed HTTP handlers Missing auth middleware → open endpoint Wrap the router with a global security middleware that checks a whitelist and enforces Authorizer for everything else.
Memory leaks with goroutine workers Leak after many module reloads Use fx.Lifecycle to start/stop background workers; always cancel contexts on Stop.
Testing with real DB slows CI Pipeline > 10min Use testcontainers in parallel jobs, cache Docker images, or use inmemory SQLite for unit tests and only run DBheavy tests in a dedicated job.

11 QUICKSTART STEPS (What to code first)

  1. Bootstrap repo
    mkdir platform && cd platform
    go mod init github.com/yourorg/platform
    mkdir cmd internal pkg config modules
    touch cmd/main.go
    
  2. Create core container (internal/di/container.go) using dig. Register Config, Logger, DB, EventBus, Authenticator, Authorizer.
  3. Add Auth middleware (pkg/auth/middleware.go) that extracts JWT, validates claims, injects User into context.
  4. Implement Permission DSL (pkg/perm/perm.go) and a simple inmemory resolver (pkg/perm/resolver.go).
  5. Write module interface (pkg/module/module.go) and a static registry (internal/registry/registry.go).
  6. Add first plugin copy the Blog module skeleton from §4.
    cd modules/blog && go mod init github.com/yourorg/blog && go run ../.. (build & test).
  7. Wire everything in cmd/main.go using fx.New(...):
    app := fx.New(
        core.Module,               // registers core services
        fx.Invoke(registry.RegisterAllModules), // loads static modules
        fx.Invoke(func(lc fx.Lifecycle, r *gin.Engine) {
            lc.Append(fx.Hook{
                OnStart: func(context.Context) error {
                    go r.Run(":8080")
                    return nil
                },
                OnStop: func(ctx context.Context) error { return nil },
            })
        }),
    )
    app.Run()
    
  8. Run integration test (go test ./... -tags=integration) to sanitycheck DB + routes.
  9. Add CI workflow (copy the .github/workflows/ci.yml from §8) and push.
  10. Publish first Docker image (docker build -t ghcr.io/yourorg/platform:dev .).

After step10 you have a complete, productiongrade scaffolding that:

  • authenticates users via JWT (with optional OIDC),
  • authorizes actions through a compiletimechecked permission DSL,
  • logs/metrics/traces out of the box,
  • lets any team drop a new *.so or static module under modules/ to add resources,
  • ships with a working CI pipeline and a readytorun Docker image.

12 REFERENCE IMPLEMENTATION (public)

If you prefer to start from a real opensource baseline, check out the following community projects that already adopt most of the ideas above:

Repo Highlights
github.com/gomicroservices/cleanarch Cleanarchitecture skeleton, fx DI, Ent ORM, JWT auth
github.com/ory/hydra Full OIDC provider (good for auth reference)
github.com/segmentio/kafka-go examples Eventbus integration
github.com/ThreeDotsLabs/watermill Pub/sub abstraction usable as bus wrapper
github.com/hibiken/asynq Background job framework (Redis based)

Fork one, strip the business logic, and rename the packages to match your github.com/yourorg/platform namespace.


13 FINAL CHECKLIST (before you ship)

  • Core modules compiled & registered in internal/di.
  • module.IModule interface and static registry in place.
  • JWT auth + middleware + User context helper.
  • Permission constants generated & compiled in pkg/perm.
  • Ent schema + migration runner that aggregates all module migrations.
  • OpenTelemetry tracer/provider wired at process start.
  • Prometheus metrics endpoint (/metrics).
  • Health endpoints (/healthz, /ready).
  • Dockerfile (multistage) and docker-compose.yml for dev.
  • GitHub Actions pipeline (CI) passes all tests.
  • Sample plugin (Blog) builds, loads, registers routes, and passes integration test.
  • Documentation: README.md, docs/architecture.md, docs/extension-points.md.

Congratulations! You now have a robust, extensible Go platform boilerplate that can be the foundation for any SaaS, internal toolset, or microservice ecosystem you wish to build. Happy coding! 🚀