Go Backend Project Setup — Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Bootstrap the Go CMS backend with project structure, database connection, migrations (audit + accounts), and a running health endpoint.
Architecture: Clean Architecture with feature-based packages under internal/. Chi router, Jet for DB access (code-gen comes later), pgx via stdlib adapter, golang-migrate with embedded SQL files. Manual wiring in main.go.
Tech Stack: Go 1.24+, Chi v5, pgx v5, golang-migrate v4, slog, envconfig, validator/v10, PostgreSQL 18
File Map
Abschnitt betitelt „File Map“cms/backend/ # Go module root├── cmd/│ └── api/│ ├── main.go # Entrypoint: config, DB, migrations, router, graceful shutdown│ └── config.go # Config struct (envconfig)├── internal/│ ├── database/│ │ ├── database.go # DB connection (pgx via stdlib)│ │ └── migrations.go # Run migrations (golang-migrate)│ └── shared/│ └── httputil/│ └── response.go # JSON response helpers├── migrations/│ ├── embed.go # go:embed for SQL files│ ├── 000001_audit_infrastructure.up.sql│ ├── 000001_audit_infrastructure.down.sql│ ├── 000002_accounts_and_sessions.up.sql│ └── 000002_accounts_and_sessions.down.sql├── docker-compose.yml # PostgreSQL + MinIO + MailHog (keep existing)├── go.mod├── go.sum├── Makefile├── .golangci.yml├── .editorconfig└── CLAUDE.md # Updated for GoTask 1: Clean up old Java backend and init Go module
Abschnitt betitelt „Task 1: Clean up old Java backend and init Go module“Files:
-
Delete:
cms/backend/src/,cms/backend/pom.xml,cms/backend/.mvn/,cms/backend/dev -
Keep:
cms/backend/docker-compose.yml -
Create:
cms/backend/go.mod -
Create:
cms/backend/.editorconfig -
Create:
cms/backend/.golangci.yml -
Step 1: Delete old Java files
cd ~/ProjectsClaude/github/traffino/business/cms/backendrm -rf src/ pom.xml .mvn/ dev- Step 2: Initialize Go module
cd ~/ProjectsClaude/github/traffino/business/cms/backendgo mod init github.com/traffino/cms- Step 3: Create directory structure
cd ~/ProjectsClaude/github/traffino/business/cms/backendmkdir -p cmd/api internal/shared/httputil migrations- Step 4: Create
.editorconfig
root = true
[*]charset = utf-8end_of_line = lfinsert_final_newline = truetrim_trailing_whitespace = trueindent_style = tab
[*.go]indent_style = tabmax_line_length = 120
[*.{yaml,yml,json,toml}]indent_style = spaceindent_size = 2
[*.sql]indent_style = spaceindent_size = 4
[*.md]indent_style = spaceindent_size = 2trim_trailing_whitespace = false
[Makefile]indent_style = tab
[go.sum]indent_style = off
[gen/**]indent_style = off- Step 5: Create
.golangci.yml
linters: enable: - goimports - gofmt - govet - errcheck - staticcheck - unused - gosimple - ineffassign - typecheck
linters-settings: goimports: local-prefixes: github.com/traffino/cms
run: timeout: 5m- Step 6: Commit
git add -A cms/backend/git commit -m "chore(backend): remove Java backend, init Go module"Task 2: Makefile and Docker-Compose
Abschnitt betitelt „Task 2: Makefile and Docker-Compose“Files:
-
Create:
cms/backend/Makefile -
Modify:
cms/backend/docker-compose.yml(keep as-is, already works) -
Step 1: Create Makefile
.PHONY: run build test test-v lint generate migrate-up migrate-down migrate-create clean up down reset-db
DATABASE_URL ?= postgres://traffino_user:traffino_password@localhost:5433/traffino?sslmode=disable
# Runrun: go run ./cmd/api
# Buildbuild: go build -o bin/api ./cmd/api
# Testtest: go test -race -count=1 ./...
test-v: go test -race -count=1 -v ./...
# Lintlint: golangci-lint run ./...
# Jet Code Generationgenerate: go generate ./...
# Migrations (CLI)migrate-up: migrate -path migrations -database "$(DATABASE_URL)" up
migrate-down: migrate -path migrations -database "$(DATABASE_URL)" down 1
migrate-create: migrate create -ext sql -dir migrations -seq $(name)
# Dockerup: docker compose up -d
down: docker compose down
reset-db: docker compose down -v docker compose up -d traffino-database
# Cleanupclean: rm -rf bin/ gen/- Step 2: Verify docker-compose.yml is unchanged
The existing docker-compose.yml already defines PostgreSQL (port 5433), MinIO, and MailHog. No changes needed.
- Step 3: Commit
git add cms/backend/Makefilegit commit -m "chore(backend): add Makefile with standard targets"Task 3: Migration — Audit Infrastructure
Abschnitt betitelt „Task 3: Migration — Audit Infrastructure“Port the existing Java/Flyway migration V1_0__audit_infrastructure.sql to golang-migrate format.
Files:
-
Create:
cms/backend/migrations/000001_audit_infrastructure.up.sql -
Create:
cms/backend/migrations/000001_audit_infrastructure.down.sql -
Step 1: Create up migration
Copy the content from the existing V1_0__audit_infrastructure.sql exactly — it contains:
btree_gistextensiontraffinoschemaaudit_deletionstableauditing()trigger functionversioning()trigger function (temporal tables)create_base_triggers()helper function
The SQL is identical to the existing Java migration. Copy it as-is.
- Step 2: Create down migration
DROP FUNCTION IF EXISTS create_base_triggers(TEXT);DROP FUNCTION IF EXISTS versioning();DROP FUNCTION IF EXISTS auditing();DROP TABLE IF EXISTS traffino.audit_deletions;DROP SCHEMA IF EXISTS traffino CASCADE;DROP EXTENSION IF EXISTS btree_gist;- Step 3: Commit
git add cms/backend/migrations/git commit -m "feat(backend): add audit infrastructure migration"Task 4: Migration — Accounts and Sessions
Abschnitt betitelt „Task 4: Migration — Accounts and Sessions“Port the existing V1_1__accounts_and_sessions.sql. Simplified for Stufe 1: keep tenants, sites, accounts, sessions, login_attempts, templates, template_fields, pages, page_contents, storage, messages, analytics_events. Drop file_shares, email_templates, geolocations (not in Stufe 1).
Files:
-
Create:
cms/backend/migrations/000002_accounts_and_sessions.up.sql -
Create:
cms/backend/migrations/000002_accounts_and_sessions.down.sql -
Step 1: Create up migration
Keep from existing migration:
traffino.tenants— as-istraffino.sites— as-is but dropbucket_private,cdn_domain,share_domain(Stufe 1 braucht kein File-Sharing)traffino.account_roleenum — keepagency_owner,agency_employee,tenant_admin,tenant_membertraffino.twofactor_methodenum — keep onlyemail(droptotp)traffino.accounts— droptotp_secret,totp_enabled(Stufe 1 nur Email-2FA)traffino.sessions— as-istraffino.login_attempts— as-istraffino.templates— as-istraffino.template_fields— as-istraffino.pages— as-istraffino.page_contents— as-istraffino.storage_visibilityenum — keep onlypublic(no private files in Stufe 1)traffino.storage— dropvisibility(all public in Stufe 1), dropalt_texttraffino.messages— as-istraffino.analytics_events— as-is- Initial admin user INSERT — as-is
Drop entirely (not in Stufe 1):
-
traffino.geolocations -
traffino.file_shares -
traffino.email_templates -
Step 2: Create down migration
DELETE FROM traffino.accounts WHERE email = 'aleksandar.damjanovic@traffino.com';DROP TABLE IF EXISTS traffino.analytics_events;DROP TABLE IF EXISTS traffino.messages;DROP TABLE IF EXISTS traffino.storage;DROP TYPE IF EXISTS traffino.storage_visibility;DROP TABLE IF EXISTS traffino.page_contents;DROP TABLE IF EXISTS traffino.pages;DROP TABLE IF EXISTS traffino.template_fields;DROP TABLE IF EXISTS traffino.templates;DROP TABLE IF EXISTS traffino.login_attempts;DROP TABLE IF EXISTS traffino.sessions;DROP TABLE IF EXISTS traffino.accounts;DROP TYPE IF EXISTS traffino.twofactor_method;DROP TYPE IF EXISTS traffino.account_role;DROP TABLE IF EXISTS traffino.sites;DROP TABLE IF EXISTS traffino.tenants;Note: History tables created by create_base_triggers() must also be dropped. Add DROP TABLE IF EXISTS traffino.{name}_history before each main table drop.
- Step 3: Commit
git add cms/backend/migrations/git commit -m "feat(backend): add accounts and sessions migration"Task 5: Config struct and database connection
Abschnitt betitelt „Task 5: Config struct and database connection“Files:
-
Create:
cms/backend/cmd/api/config.go -
Create:
cms/backend/internal/database/database.go -
Create:
cms/backend/internal/database/migrations.go -
Step 1: Create config.go
package main
import "github.com/kelseyhightower/envconfig"
type config struct { Port int `envconfig:"PORT" default:"8080"` DatabaseURL string `envconfig:"DATABASE_URL" required:"true"` LogLevel string `envconfig:"LOG_LEVEL" default:"info"`}
func loadConfig() (config, error) { var cfg config err := envconfig.Process("", &cfg) return cfg, err}- Step 2: Create internal/database/database.go
package database
import ( "database/sql" "fmt" "time"
_ "github.com/jackc/pgx/v5/stdlib")
func Connect(databaseURL string) (*sql.DB, error) { db, err := sql.Open("pgx", databaseURL) if err != nil { return nil, fmt.Errorf("open db: %w", err) } db.SetMaxOpenConns(25) db.SetMaxIdleConns(5) db.SetConnMaxLifetime(5 * time.Minute)
if err := db.Ping(); err != nil { return nil, fmt.Errorf("ping db: %w", err) } return db, nil}- Step 3: Create internal/database/migrations.go
Go embed cannot use .. in paths. The migrations package lives at the module root level, so we create a separate file that embeds the SQL files from the correct relative path.
package database
import ( "database/sql" "embed" "fmt"
"github.com/golang-migrate/migrate/v4" mpgx "github.com/golang-migrate/migrate/v4/database/pgx/v5" "github.com/golang-migrate/migrate/v4/source/iofs")
// Embed must be set from outside because go:embed cannot use ".." paths.// main.go sets this before calling RunMigrations.var MigrationsFS embed.FS
func RunMigrations(db *sql.DB) error { source, err := iofs.New(MigrationsFS, "migrations") if err != nil { return fmt.Errorf("migration source: %w", err) } driver, err := mpgx.WithInstance(db, &mpgx.Config{}) if err != nil { return fmt.Errorf("migration driver: %w", err) } m, err := migrate.NewWithInstance("iofs", source, "postgres", driver) if err != nil { return fmt.Errorf("migration instance: %w", err) } if err := m.Up(); err != nil && err != migrate.ErrNoChange { return fmt.Errorf("migration up: %w", err) } return nil}Wait — this approach with an exported MigrationsFS variable is messy. Better approach: embed the migrations in a dedicated package at the module root level.
Revised approach: Create cms/backend/migrations/embed.go:
package migrations
import "embed"
//go:embed *.sqlvar FS embed.FSThen internal/database/migrations.go imports github.com/traffino/cms/migrations and uses migrations.FS directly. This is clean — the embed directive is in the same directory as the SQL files.
Updated internal/database/migrations.go:
package database
import ( "database/sql" "fmt"
"github.com/golang-migrate/migrate/v4" mpgx "github.com/golang-migrate/migrate/v4/database/pgx/v5" "github.com/golang-migrate/migrate/v4/source/iofs"
mfs "github.com/traffino/cms/migrations")
func RunMigrations(db *sql.DB) error { source, err := iofs.New(mfs.FS, ".") if err != nil { return fmt.Errorf("migration source: %w", err) } driver, err := mpgx.WithInstance(db, &mpgx.Config{}) if err != nil { return fmt.Errorf("migration driver: %w", err) } m, err := migrate.NewWithInstance("iofs", source, "postgres", driver) if err != nil { return fmt.Errorf("migration instance: %w", err) } if err := m.Up(); err != nil && err != migrate.ErrNoChange { return fmt.Errorf("migration up: %w", err) } return nil}Additional file to create: cms/backend/migrations/embed.go
- Step 3: Install dependencies
cd ~/ProjectsClaude/github/traffino/business/cms/backendgo get github.com/kelseyhightower/envconfiggo get github.com/jackc/pgx/v5/stdlibgo get github.com/golang-migrate/migrate/v4go get github.com/golang-migrate/migrate/v4/database/pgx/v5go get github.com/golang-migrate/migrate/v4/source/iofs- Step 4: Commit
git add cms/backend/git commit -m "feat(backend): add config and database connection with embedded migrations"Task 6: JSON response helpers
Abschnitt betitelt „Task 6: JSON response helpers“Files:
-
Create:
cms/backend/internal/shared/httputil/response.go -
Step 1: Create response.go
package httputil
import ( "encoding/json" "log/slog" "net/http")
func JSON(w http.ResponseWriter, status int, data any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) if err := json.NewEncoder(w).Encode(data); err != nil { slog.Error("failed to encode response", "error", err) }}
func Error(w http.ResponseWriter, status int, message string) { JSON(w, status, map[string]string{"error": message})}- Step 2: Commit
git add cms/backend/internal/git commit -m "feat(backend): add JSON response helpers"Task 7: main.go — Chi router, health endpoint, graceful shutdown
Abschnitt betitelt „Task 7: main.go — Chi router, health endpoint, graceful shutdown“Files:
-
Create:
cms/backend/cmd/api/main.go -
Step 1: Create main.go
package main
import ( "context" "fmt" "log/slog" "net/http" "os" "os/signal" "syscall" "time"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware"
"github.com/traffino/cms/internal/database" "github.com/traffino/cms/internal/shared/httputil")
func main() { cfg, err := loadConfig() if err != nil { slog.Error("failed to load config", "error", err) os.Exit(1) }
setupLogger(cfg.LogLevel)
db, err := database.Connect(cfg.DatabaseURL) if err != nil { slog.Error("failed to connect to database", "error", err) os.Exit(1) } defer db.Close()
if err := database.RunMigrations(db); err != nil { slog.Error("failed to run migrations", "error", err) os.Exit(1) } slog.Info("migrations completed")
r := chi.NewRouter() r.Use(middleware.RequestID) r.Use(middleware.RealIP) r.Use(middleware.Recoverer) r.Use(middleware.Timeout(60 * time.Second))
r.Get("/api/public/health", func(w http.ResponseWriter, r *http.Request) { httputil.JSON(w, http.StatusOK, map[string]string{"status": "ok"}) })
srv := &http.Server{ Addr: fmt.Sprintf(":%d", cfg.Port), Handler: r, }
go func() { slog.Info("server starting", "port", cfg.Port) if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { slog.Error("server failed", "error", err) os.Exit(1) } }()
quit := make(chan os.Signal, 1) signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) <-quit
slog.Info("server shutting down") ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() if err := srv.Shutdown(ctx); err != nil { slog.Error("server forced to shutdown", "error", err) } slog.Info("server stopped")}
func setupLogger(level string) { var l slog.Level switch level { case "debug": l = slog.LevelDebug case "warn": l = slog.LevelWarn case "error": l = slog.LevelError default: l = slog.LevelInfo } handler := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: l}) slog.SetDefault(slog.New(handler))}- Step 2: Install Chi dependency
cd ~/ProjectsClaude/github/traffino/business/cms/backendgo get github.com/go-chi/chi/v5- Step 3: Verify it compiles
cd ~/ProjectsClaude/github/traffino/business/cms/backendgo build ./cmd/apiExpected: no errors, binary created.
- Step 4: Commit
git add cms/backend/git commit -m "feat(backend): add main.go with Chi router, health endpoint, graceful shutdown"Task 8: Write health endpoint test
Abschnitt betitelt „Task 8: Write health endpoint test“Files:
-
Create:
cms/backend/cmd/api/main_test.go -
Step 1: Create test file
package main
import ( "encoding/json" "net/http" "net/http/httptest" "testing" "time"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require"
"github.com/traffino/cms/internal/shared/httputil")
func newTestRouter() *chi.Mux { r := chi.NewRouter() r.Use(middleware.RequestID) r.Use(middleware.Recoverer) r.Use(middleware.Timeout(60 * time.Second))
r.Get("/api/public/health", func(w http.ResponseWriter, r *http.Request) { httputil.JSON(w, http.StatusOK, map[string]string{"status": "ok"}) })
return r}
func TestHealthEndpoint(t *testing.T) { // Given router := newTestRouter() req := httptest.NewRequest(http.MethodGet, "/api/public/health", nil) rec := httptest.NewRecorder()
// When router.ServeHTTP(rec, req)
// Then assert.Equal(t, http.StatusOK, rec.Code)
var body map[string]string err := json.NewDecoder(rec.Body).Decode(&body) require.NoError(t, err) assert.Equal(t, "ok", body["status"])}- Step 2: Install testify
cd ~/ProjectsClaude/github/traffino/business/cms/backendgo get github.com/stretchr/testify- Step 3: Run the test
cd ~/ProjectsClaude/github/traffino/business/cms/backendgo test -v ./cmd/api/Expected: PASS
- Step 4: Commit
git add cms/backend/git commit -m "test(backend): add health endpoint test"Task 9: Update CLAUDE.md files
Abschnitt betitelt „Task 9: Update CLAUDE.md files“Files:
-
Modify:
cms/CLAUDE.md -
Create:
cms/backend/CLAUDE.md(replace old Java one) -
Step 1: Update cms/CLAUDE.md
Update structure section to reflect Go backend:
# Traffino CMS
Monorepo fuer das Traffino CMS (Backend + Frontend + AWS Lambda).
## Strukturcms/ ├── backend/ # Go (Chi, Jet, PostgreSQL) ├── frontend/ # React 19, TanStack Start (SSR), Mantine 8, Tailwind CSS 4 └── aws/ # Go Lambda-Funktionen (contact-form, analytics)
## Befehle
- **Backend:** `cd backend && make run` — siehe `backend/CLAUDE.md`- **Frontend:** `cd frontend && npm run dev` — siehe `frontend/CLAUDE.md`- **AWS:** `cd aws && make build` — siehe `aws/CLAUDE.md`- Step 2: Create new backend/CLAUDE.md
# Traffino CMS Backend
Go-basiertes CMS-Backend fuer Traffino Web-Agentur.
## Stack
Go 1.24+, Chi v5, Jet (Code-Gen), pgx v5, golang-migrate, slog, envconfig, validator/v10, PostgreSQL 18
## Befehle
```bashmake run # Server starten (PORT=8080)make build # Binary bauen (bin/api)make test # Tests ausfuehrenmake test-v # Tests verbosemake lint # golangci-lintmake generate # Jet Code-Genmake migrate-up # Migrations ausfuehren (CLI)make migrate-down # Letzte Migration zurueckrollenmake migrate-create name=xyz # Neue Migration anlegenmake up # Docker Services starten (PostgreSQL + MinIO + MailHog)make down # Docker Services stoppenmake reset-db # Datenbank zuruecksetzenClaude darf selbst ausfuehren: build, test, test-v, lint, generate, migrate-up
Nur auf Anweisung des Users: up, down, reset-db, run
Umgebungsvariablen
Abschnitt betitelt „Umgebungsvariablen“| Variable | Default | Beschreibung |
|---|---|---|
PORT | 8080 | HTTP Server Port |
DATABASE_URL | (required) | PostgreSQL Connection String |
LOG_LEVEL | info | Log Level (debug, info, warn, error) |
Lokale Entwicklung: DATABASE_URL=postgres://traffino_user:traffino_password@localhost:5433/traffino?sslmode=disable
Projekt-Struktur
Abschnitt betitelt „Projekt-Struktur“cmd/ api/ main.go # Entrypoint, Wiring, Server config.go # Config Struct (envconfig) database.go # DB Connection + Embedded Migrationsinternal/ feature/ # Feature-Packages (kommt mit ersten Features) shared/ httputil/ response.go # JSON(), Error() middleware/ # Auth, Logging (kommt spaeter)migrations/ # SQL (golang-migrate, sequenziell nummeriert)gen/ # Jet-generierter Code (kommt spaeter)Architektur
Abschnitt betitelt „Architektur“- Clean Architecture — Feature-basierte Packages unter
internal/ - UseCase-Pattern — Request/Response/Execute pro Feature
- Repository-Pattern — Interface + Jet-Implementierung
- Manual Wiring — Kein DI-Framework, alles in main.go
- Multi-Tenancy — tenant_id auf allen Business-Tabellen
- Schema —
traffino(eigenes Schema, nicht public)
API-Pfad-Struktur
Abschnitt betitelt „API-Pfad-Struktur“| Prefix | Auth | Beschreibung |
|---|---|---|
/api/public/* | Nein | Login, 2FA-Verify, Session-Check, Health |
/api/protected/* | Session-Cookie | Alle Business-Endpoints |
/api/service/* | X-Service-Token | Template-Sync, Content-Delivery (Astro Build) |
Audit-Pattern
Abschnitt betitelt „Audit-Pattern“Jede Business-Tabelle braucht:
id UUID PRIMARY KEYcreated_at TIMESTAMPTZ,created_by TEXTupdated_at TIMESTAMPTZ,updated_by TEXTrow_version INT DEFAULT 0,row_period TSTZRANGE- Dann:
SELECT create_base_triggers('traffino.tabellenname')
Konventionen
Abschnitt betitelt „Konventionen“- Migrations unter
migrations/mit sequenzieller Nummerierung - Public Endpoints:
/api/public/{feature}/... - Protected Endpoints:
/api/protected/{feature}/... - Health-Check:
GET /api/public/health - gofmt ist Pflicht
- Errors wrappen mit
%w, nie ignorieren
- [ ] **Step 3: Commit**
```bashgit add cms/CLAUDE.md cms/backend/CLAUDE.mdgit commit -m "docs(backend): update CLAUDE.md for Go backend"Task 10: End-to-end verification
Abschnitt betitelt „Task 10: End-to-end verification“- Step 1: Start Docker services
cd ~/ProjectsClaude/github/traffino/business/cms/backendmake upWait for PostgreSQL to be ready (~5s).
- Step 2: Run the server
cd ~/ProjectsClaude/github/traffino/business/cms/backendDATABASE_URL="postgres://traffino_user:traffino_password@localhost:5433/traffino?sslmode=disable" make run &Wait for “server starting” log message.
- Step 3: Test health endpoint
curl -s http://localhost:8080/api/public/health | jq .Expected:
{ "status": "ok"}- Step 4: Verify migrations ran
PGPASSWORD=traffino_password psql -h localhost -p 5433 -U traffino_user -d traffino -c "\dt traffino.*"Expected: All tables listed (tenants, sites, accounts, sessions, etc. plus their history tables).
- Step 5: Stop server and services
kill %1cd ~/ProjectsClaude/github/traffino/business/cms/backendmake down- Step 6: Final commit
git add -A cms/backend/git commit -m "chore(backend): Go backend project setup complete"