26 KiB
Go‑Platform Boilerplate Play‑book
“Plug‑in‑friendly SaaS/Enterprise Platform – Go Edition”
1️⃣ ARCHITECTURAL IMPERATIVES (Go‑flavoured)
| Principle | Go‑specific rationale | Enforcement Technique |
|---|---|---|
| Clean / Hexagonal Architecture | Go’s package‑level visibility (internal/) naturally creates a boundary between core and plug‑ins. |
Keep all domain code in internal/domain, expose only interfaces in pkg/. |
| Dependency Injection (DI) via Constructors | Go avoids reflection‑heavy containers; compile‑time wiring is preferred. | Use uber‑go/fx (runtime graph) or uber‑go/dig for optional runtime DI. For a lighter weight solution, use plain constructor injection with a small registry. |
| Modular Monolith → Micro‑service‑ready | 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). |
| Plugin‑first design | Go’s plugin package allows runtime loading of compiled .so files (Linux/macOS). |
Provide an IModule interface and a loader that discovers *.so files (or compiled‑in modules for CI). |
| API‑First (OpenAPI + gin/gorilla) | Guarantees language‑agnostic contracts. | Generate server stubs from an openapi.yaml stored in api/. |
| Security‑by‑Design | Go’s static typing makes it easy to keep auth data out of the request flow. | Central middleware for JWT verification + context‑based user propagation. |
| Observability (OpenTelemetry) | The Go ecosystem ships first‑class OTEL SDK. | Instrument HTTP, DB, queues, and custom events automatically. |
| Configuration‑as‑Code | 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 Go‑platform 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 secret‑store. |
| 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 channel‐based 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 in‑memory resolver and a casbin adapter. |
| Audit | type Auditor interface { Record(ctx context.Context, act AuditAction) error } |
Write to append‑only 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) + in‑process fallback |
Core ships an in‑process 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 (code‑gen 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 (Redis‑backed) |
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/ sub‑domain parser + JWT claim scanner | Tenant ID is stored in request context and automatically added to SQL queries via Ent’s Client interceptor. |
All public interfaces live under pkg/ so that plug‑ins 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 plug‑in must fulfil.
type IModule interface {
// Name returns a unique, human‑readable 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; compile‑time 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 micro‑service extraction | Hot‑swap 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 viaLoad, and printsName(). This is a great sanity check for ops.
3.3 Permissions DSL (compile‑time 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 code‑gen tool (go generate ./...) can scan each module’s 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/, implementingmodule.IModule, registering routes, permissions and migrations. The core automatically wires everything together.
5️⃣ INFRASTRUCTURE ADAPTERS (swap‑able, per‑environment)
| Concern | Implementation (Go) | Where it lives |
|---|---|---|
| Database | entgo.io/ent (code‑gen) |
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/ |
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 |
Auto‑instrument 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, request‑scoped 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 ready‑made 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/ # git‑ignored, 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=integrationruns tests that spin up Postgres, Redis, Kafka via Testcontainers (github.com/testcontainers/testcontainers-go).- Linting via
golintorstaticcheck. - Docker image built from the compiled binary (multi‑stage:
golang:1.22-alpine→scratchordistroless). - Semantic‑release can be added on top (
semantic-releaseaction) 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 in‑memory DB or mocks). |
| Integration | testcontainers-go for Postgres, Redis, Kafka; real Ent client |
End‑to‑end 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 < 2 ms latency per request. |
| Security | gosec, zap for secret detection, OWASP ZAP for API scan |
Detect hard‑coded 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 (Go‑centric)
| 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. |
| Too‑big 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 module’s go.mod (e.g., replace github.com/yourorg/platform => ../../platform). |
| Context leakage (request data not passed) | Logs missing user_id, permission checks use zero‑value user |
Always store user/tenant info in context.Context via middleware; provide helper auth.FromContext(ctx). |
| Ent migrations out‑of‑sync | Startup fails with “column does not exist” | Run go generate ./... (ent codegen) and ent/migrate automatically at boot after all module migrations are collected. |
| Hard‑coded 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 | 500 ms latency spikes | Off‑load long‑running work to asynq jobs or Kafka consumers; keep request handlers thin. |
| Over‑exposed 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 > 10 min | Use testcontainers in parallel jobs, cache Docker images, or use in‑memory SQLite for unit tests and only run DB‑heavy tests in a dedicated job. |
11️⃣ QUICK‑START STEPS (What to code first)
- Bootstrap repo
mkdir platform && cd platform go mod init github.com/yourorg/platform mkdir cmd internal pkg config modules touch cmd/main.go - Create core container (
internal/di/container.go) usingdig. Register Config, Logger, DB, EventBus, Authenticator, Authorizer. - Add Auth middleware (
pkg/auth/middleware.go) that extracts JWT, validates claims, injectsUserinto context. - Implement Permission DSL (
pkg/perm/perm.go) and a simple in‑memory resolver (pkg/perm/resolver.go). - Write
moduleinterface (pkg/module/module.go) and a static registry (internal/registry/registry.go). - Add first plug‑in – copy the Blog module skeleton from §4.
cd modules/blog && go mod init github.com/yourorg/blog && go run ../..(build & test). - Wire everything in
cmd/main.gousingfx.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() - Run integration test (
go test ./... -tags=integration) to sanity‑check DB + routes. - Add CI workflow (copy the
.github/workflows/ci.ymlfrom §8) and push. - Publish first Docker image (
docker build -t ghcr.io/yourorg/platform:dev .).
After step 10 you have a complete, production‑grade scaffolding that:
- authenticates users via JWT (with optional OIDC),
- authorizes actions through a compile‑time‑checked permission DSL,
- logs/metrics/traces out of the box,
- lets any team drop a new
*.soor static module undermodules/to add resources, - ships with a working CI pipeline and a ready‑to‑run Docker image.
12️⃣ REFERENCE IMPLEMENTATION (public)
If you prefer to start from a real open‑source baseline, check out the following community projects that already adopt most of the ideas above:
| Repo | Highlights |
|---|---|
github.com/go‑microservices/clean‑arch |
Clean‑architecture skeleton, fx DI, Ent ORM, JWT auth |
github.com/ory/hydra |
Full OIDC provider (good for auth reference) |
github.com/segmentio/kafka-go examples |
Event‑bus 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.IModuleinterface and static registry in place.- JWT auth + middleware +
Usercontext 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 (multi‑stage) and
docker-compose.ymlfor dev. - GitHub Actions pipeline (CI) passes all tests.
- Sample plug‑in (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 micro‑service ecosystem you wish to build. Happy coding! 🚀