Zum Inhalt springen

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


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 Go

Task 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

Terminal-Fenster
cd ~/ProjectsClaude/github/traffino/business/cms/backend
rm -rf src/ pom.xml .mvn/ dev
  • Step 2: Initialize Go module
Terminal-Fenster
cd ~/ProjectsClaude/github/traffino/business/cms/backend
go mod init github.com/traffino/cms
  • Step 3: Create directory structure
Terminal-Fenster
cd ~/ProjectsClaude/github/traffino/business/cms/backend
mkdir -p cmd/api internal/shared/httputil migrations
  • Step 4: Create .editorconfig
root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
indent_style = tab
[*.go]
indent_style = tab
max_line_length = 120
[*.{yaml,yml,json,toml}]
indent_style = space
indent_size = 2
[*.sql]
indent_style = space
indent_size = 4
[*.md]
indent_style = space
indent_size = 2
trim_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
Terminal-Fenster
git add -A cms/backend/
git commit -m "chore(backend): remove Java backend, init Go module"

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
# Run
run:
go run ./cmd/api
# Build
build:
go build -o bin/api ./cmd/api
# Test
test:
go test -race -count=1 ./...
test-v:
go test -race -count=1 -v ./...
# Lint
lint:
golangci-lint run ./...
# Jet Code Generation
generate:
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)
# Docker
up:
docker compose up -d
down:
docker compose down
reset-db:
docker compose down -v
docker compose up -d traffino-database
# Cleanup
clean:
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
Terminal-Fenster
git add cms/backend/Makefile
git commit -m "chore(backend): add Makefile with standard targets"

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_gist extension
  • traffino schema
  • audit_deletions table
  • auditing() trigger function
  • versioning() 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
Terminal-Fenster
git add cms/backend/migrations/
git commit -m "feat(backend): add audit infrastructure migration"

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-is
  • traffino.sites — as-is but drop bucket_private, cdn_domain, share_domain (Stufe 1 braucht kein File-Sharing)
  • traffino.account_role enum — keep agency_owner, agency_employee, tenant_admin, tenant_member
  • traffino.twofactor_method enum — keep only email (drop totp)
  • traffino.accounts — drop totp_secret, totp_enabled (Stufe 1 nur Email-2FA)
  • traffino.sessions — as-is
  • traffino.login_attempts — as-is
  • traffino.templates — as-is
  • traffino.template_fields — as-is
  • traffino.pages — as-is
  • traffino.page_contents — as-is
  • traffino.storage_visibility enum — keep only public (no private files in Stufe 1)
  • traffino.storage — drop visibility (all public in Stufe 1), drop alt_text
  • traffino.messages — as-is
  • traffino.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
Terminal-Fenster
git add cms/backend/migrations/
git commit -m "feat(backend): add accounts and sessions migration"

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 *.sql
var FS embed.FS

Then 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
Terminal-Fenster
cd ~/ProjectsClaude/github/traffino/business/cms/backend
go get github.com/kelseyhightower/envconfig
go get github.com/jackc/pgx/v5/stdlib
go get github.com/golang-migrate/migrate/v4
go get github.com/golang-migrate/migrate/v4/database/pgx/v5
go get github.com/golang-migrate/migrate/v4/source/iofs
  • Step 4: Commit
Terminal-Fenster
git add cms/backend/
git commit -m "feat(backend): add config and database connection with embedded migrations"

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
Terminal-Fenster
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
Terminal-Fenster
cd ~/ProjectsClaude/github/traffino/business/cms/backend
go get github.com/go-chi/chi/v5
  • Step 3: Verify it compiles
Terminal-Fenster
cd ~/ProjectsClaude/github/traffino/business/cms/backend
go build ./cmd/api

Expected: no errors, binary created.

  • Step 4: Commit
Terminal-Fenster
git add cms/backend/
git commit -m "feat(backend): add main.go with Chi router, health endpoint, graceful shutdown"

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
Terminal-Fenster
cd ~/ProjectsClaude/github/traffino/business/cms/backend
go get github.com/stretchr/testify
  • Step 3: Run the test
Terminal-Fenster
cd ~/ProjectsClaude/github/traffino/business/cms/backend
go test -v ./cmd/api/

Expected: PASS

  • Step 4: Commit
Terminal-Fenster
git add cms/backend/
git commit -m "test(backend): add health endpoint test"

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).
## Struktur

cms/ ├── 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
```bash
make run # Server starten (PORT=8080)
make build # Binary bauen (bin/api)
make test # Tests ausfuehren
make test-v # Tests verbose
make lint # golangci-lint
make generate # Jet Code-Gen
make migrate-up # Migrations ausfuehren (CLI)
make migrate-down # Letzte Migration zurueckrollen
make migrate-create name=xyz # Neue Migration anlegen
make up # Docker Services starten (PostgreSQL + MinIO + MailHog)
make down # Docker Services stoppen
make reset-db # Datenbank zuruecksetzen

Claude darf selbst ausfuehren: build, test, test-v, lint, generate, migrate-up Nur auf Anweisung des Users: up, down, reset-db, run

VariableDefaultBeschreibung
PORT8080HTTP Server Port
DATABASE_URL(required)PostgreSQL Connection String
LOG_LEVELinfoLog Level (debug, info, warn, error)

Lokale Entwicklung: DATABASE_URL=postgres://traffino_user:traffino_password@localhost:5433/traffino?sslmode=disable

cmd/
api/
main.go # Entrypoint, Wiring, Server
config.go # Config Struct (envconfig)
database.go # DB Connection + Embedded Migrations
internal/
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)
  • 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
  • Schematraffino (eigenes Schema, nicht public)
PrefixAuthBeschreibung
/api/public/*NeinLogin, 2FA-Verify, Session-Check, Health
/api/protected/*Session-CookieAlle Business-Endpoints
/api/service/*X-Service-TokenTemplate-Sync, Content-Delivery (Astro Build)

Jede Business-Tabelle braucht:

  • id UUID PRIMARY KEY
  • created_at TIMESTAMPTZ, created_by TEXT
  • updated_at TIMESTAMPTZ, updated_by TEXT
  • row_version INT DEFAULT 0, row_period TSTZRANGE
  • Dann: SELECT create_base_triggers('traffino.tabellenname')
  • 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**
```bash
git add cms/CLAUDE.md cms/backend/CLAUDE.md
git commit -m "docs(backend): update CLAUDE.md for Go backend"

  • Step 1: Start Docker services
Terminal-Fenster
cd ~/ProjectsClaude/github/traffino/business/cms/backend
make up

Wait for PostgreSQL to be ready (~5s).

  • Step 2: Run the server
Terminal-Fenster
cd ~/ProjectsClaude/github/traffino/business/cms/backend
DATABASE_URL="postgres://traffino_user:traffino_password@localhost:5433/traffino?sslmode=disable" make run &

Wait for “server starting” log message.

  • Step 3: Test health endpoint
Terminal-Fenster
curl -s http://localhost:8080/api/public/health | jq .

Expected:

{
"status": "ok"
}
  • Step 4: Verify migrations ran
Terminal-Fenster
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
Terminal-Fenster
kill %1
cd ~/ProjectsClaude/github/traffino/business/cms/backend
make down
  • Step 6: Final commit
Terminal-Fenster
git add -A cms/backend/
git commit -m "chore(backend): Go backend project setup complete"