Files
summercms/.planning/research/ARCHITECTURE.md
2026-09-16 12:09:13 +02:00

41 KiB

Architecture Research

Phase 1 correction (2026-09-16): The canonical Go module is git.golem15.com/golem15/summercms; the framework tool lives in this repository, while generated app main.go and plugins.gen.go live in each app. Phase 1 CONTEXT.md D-01, D-02 and D-04 supersede older github.com/golem15/summercms and app-owned cmd/summer sketches below.

Domain: Go CMF (WinterCMS/Laravel-shaped), headless-first, compiled-plugin model, v1 = Płytarium port Researched: 2026-09-16 Confidence: HIGH for plugin-registration mechanics (verified against Caddy/xcaddy and PocketBase current docs) and for request-lifecycle mapping (grounded directly in the real fonoteka Plugin.php/routes.php); MEDIUM for the cross-plugin schema-extension pattern and the framework/app repo split (design recommendation, not yet validated by a build)

Standard Architecture

System Overview

┌───────────────────────────────────────────────────────────────────────────┐
│ APPLICATION REPO (e.g. fonoteka.go)                                       │
│  ┌─────────────────────────────┐   ┌────────────────────────────────┐    │
│  │ plugins/ (go.work members)   │   │ cmd/summer (app binary entry)   │    │
│  │  fonoteka/   (domain plugin) │   │  plugins.gen.go (blank imports) │    │
│  │  user/       (stack plugin)  │   │  main.go -> backpack.Boot()     │    │
│  │  translate/, websockets/,    │   └────────────────────────────────┘    │
│  │  feedback/, sitemap/, ...    │                                         │
│  └──────────────┬───────────────┘                                         │
├─────────────────┼───────────────────────────────────────────────────────┤
│                  ▼            FRAMEWORK CORE (this repo, one Go module)   │
│  ┌─────────┐ ┌─────────┐ ┌──────────┐ ┌─────────┐ ┌─────────┐            │
│  │ party   │ │ backpack│ │ festival │ │ compass │ │ bonfire │  kernel     │
│  │ (plugin │ │ (app    │ │ (event   │ │ (config)│ │ (CLI)   │  layer      │
│  │ registry│ │ container)│ bus)     │ │         │ │         │            │
│  └────┬────┘ └────┬────┘ └────┬─────┘ └────┬────┘ └────┬────┘            │
│       │           │           │            │           │                 │
├───────┴───────────┴───────────┴────────────┴───────────┴─────────────────┤
│                        INFRASTRUCTURE ADAPTERS (framework core)           │
│  ┌────────┐ ┌─────────┐ ┌───────────┐ ┌────────┐ ┌────────┐ ┌──────────┐ │
│  │ surf   │ │ lagoon  │ │ bouncer   │ │ cooler │ │ conga  │ │ postcard │ │
│  │ (HTTP/ │ │ (GORM/  │ │ (JWT/     │ │ (cache)│ │ (River │ │ (mail)   │ │
│  │ router)│ │ Postgres│ │ session/  │ │        │ │ jobs)  │ │          │ │
│  │        │ │ /migrate│ │ OAuth2)   │ │        │ │        │ │          │ │
│  └────────┘ └─────────┘ └───────────┘ └────────┘ └────────┘ └──────────┘ │
│  ┌───────────┐ ┌───────────┐ ┌────────┐ ┌────────┐                       │
│  │ lifeguard │ │ sandcastle│ │phrasebk│ │ sunset │  pact = shared         │
│  │ (validate)│ │ (blob)    │ │ (i18n) │ │ (view, │  interfaces, imported  │
│  │           │ │           │ │        │ │ stub)  │  by every layer, zero  │
│  └───────────┘ └───────────┘ └────────┘ └────────┘  deps on the others    │
├───────────────────────────────────────────────────────────────────────────┤
│                        EXTERNAL SERVICES                                  │
│  Postgres · Centrifugo (WS) · Typesense · Discogs/Anthropic/OpenAI APIs   │
└───────────────────────────────────────────────────────────────────────────┘
                 ▲                                            ▲
                 │ REST (JWT / token / OAuth2 bearer)          │ WS (client-side)
        vue-fonoteka-app (Nuxt 4)                     fonoteka-mcp (MCP server)

Key structural decision: the framework core is one Go module; plugins are go.work workspace members, each its own module. This is a deliberate split of the "single module vs multi-module workspace" question, not an either/or:

  • backpack, pact, towel, compass, festival, surf, lagoon, bouncer, lifeguard, cooler, conga, postcard, bonfire, sandcastle, phrasebook, sunset, party ship together, version together, and are released as one thing — internal packages in one module is correct here (no plugin ever needs a different version of lagoon than another plugin in the same binary; a shared module guarantees that).
  • Plugins (fonoteka, user, translate, websockets, ...) are independently ownable, independently testable, and — per the already-recorded decision in why-go-not-scala.md — a go.work workspace of separate modules, mirroring Caddy/xcaddy where each plugin module registers itself via a blank import and init(). go.work's use directive resolves local plugin modules without replace hacks or tagged releases during development; a plugin gets a real semver tag only once it needs to be shared across apps (see Integration Points).

Component Responsibilities

Component Responsibility Typical Implementation
pact Interfaces shared across packages to avoid import cycles (Plugin, Authenticator, Searchable, locale/user contracts) Pure interfaces, zero deps, imported by everything
towel String/slice/map helpers, small stdlib gap-fillers (Illuminate\Support equivalent) Generic functions, stdlib only
compass Config discovery, env layering, plugin config namespaces Ported design from summer-compass (HOCON→koanf), unchanged layering semantics
festival Typed in-process event bus; "filter" events that plugins fill in additively ~200 LOC hand-rolled generic bus (no library dominates this niche per ecosystem research)
backpack App container: boot lifecycle (Register→Boot, mirroring PluginBase), type-keyed service binding/resolution App struct + Bind[T any]/Resolve[T any] generics helpers
party Plugin descriptor contract, registry, dependency-ordered boot/migrate, generated import list, summer build/make:plugin Caddy/xcaddy-style: init() self-registration + generated blank-import file
bonfire Console command framework, scaffolding, rich output Ported design from summer-bonfire; cobra or hand-rolled per that doc's "no external TUI framework" stance
surf net/http ServeMux wrapper, middleware chain, route groups stdlib ServeMux (1.22+ patterns) + typed middleware stack
lagoon GORM models, Postgres, per-plugin migrations (goose), pagination GORM + goose, AutoMigrate-free (explicit migrations only, for the "drop last migration and fix it" workflow)
bouncer JWT (SPA), personal API tokens, OAuth2/OIDC (MCP/ChatGPT), backend admin sessions golang-jwt/jwt, zitadel/oidc, alexedwards/scs
lifeguard YAML rules: → runtime validation with translated messages go-playground/validator's Var() (per-field runtime rules, not struct tags — see Patterns)
cooler In-process cache, rate-limit buckets otter
conga Queue/jobs, transactional enqueue riverqueue/river (Postgres)
postcard Mail sending, template layouts wneessen/go-mail + html/template
sandcastle File storage, cover images gocloud.dev/blob
phrasebook i18n, CLDR plurals, namespaced keys Ported design from summer-phrasebook
sunset View/templating — minimal in v1 (headless) html/template stub only; no theme engine work in v1
summercms.go/                        # THIS repo — framework core, one Go module
├── go.mod                           # module github.com/golem15/summercms
├── pact/                            # interfaces only
├── towel/
├── compass/
├── festival/
├── backpack/
├── party/                           # plugin descriptor + registry + build tooling
│   ├── plugin.go                    # Plugin, HasModels, HasRoutes, ... capability interfaces
│   ├── registry.go                  # Register(), topological Requires() sort
│   └── build/                       # `summer build`/`make:plugin` codegen (invoked by bonfire commands)
├── bonfire/
├── surf/
├── lagoon/
├── bouncer/
├── lifeguard/
├── cooler/
├── conga/
├── postcard/
├── sandcastle/
├── phrasebook/
├── sunset/
└── cmd/summer/                      # the `summer` CLI binary itself (make:plugin, build, migrate, serve)

fonoteka.go/                         # SEPARATE repo — the Płytarium application
├── go.work                          # use ./plugins/fonoteka, ./plugins/user, ./plugins/translate, ...
├── go.mod                           # requires github.com/golem15/summercms
├── config/                          # compass base + env overlays, per-plugin namespaces
├── plugins/
│   ├── fonoteka/                    # domain plugin — its own go.mod (workspace member)
│   │   ├── go.mod
│   │   ├── plugin.go                # descriptor: ID/Requires/Register/Boot + capability methods
│   │   ├── models/                  # Album, Artist, Collection, ... (25 GORM structs)
│   │   ├── migrations/              # 27 goose migrations, embed.FS
│   │   ├── routes.go                # surf.Router registration (mirrors routes.php groups exactly)
│   │   ├── admin/                   # 5 AdminController impls + fields.yaml/columns.yaml
│   │   ├── console/                 # ReindexAlbums, IssueOAuthClient, PruneNotifications
│   │   ├── jobs/                    # CsvImportWorker, WishlistDigestWorker (conga.JobWorker)
│   │   └── lang/                    # pl/en phrasebook namespaces
│   ├── user/                        # ported stack plugin — own go.mod
│   ├── translate/, websockets/, feedback/, sitemap/, apparatus/, golem/ (AI)
│   └── ...
├── cmd/summer/
│   ├── plugins.gen.go                # generated blank-import list (regenerated by `summer build`)
│   └── main.go                       # calls backpack.Boot(app) then bonfire dispatch or surf.Serve()
├── admin-spa/                        # Vue 3 + TS, consumes /_admin/api/schema/* + OpenAPI types
└── test/parity/                      # API parity harness (see Patterns)

Structure Rationale

  • Framework repo has no knowledge of Płytarium. No Discogs client, no Centrifugo channel names, no Typesense schema live here — those are app concerns. This is the enforceable version of "core plugins... cannot have breaking changes... unless directly asked" from the user's global rules: the framework's pact contracts are the only surface an app plugin may depend on for cross-cutting concerns, so the framework can evolve without touching app code as long as pact interfaces hold.
  • plugins/<name>/go.mod per plugin, go.work at the app root. Matches the already-recorded decision (why-go-not-scala.md): "plugins/ is a workspace of Go modules." go.work's use directive means a plugin never needs a replace line or a tagged release to be part of the local build — summer build just runs go build inside the workspace.
  • Stack plugins (user, translate, websockets, ...) start inside the app repo's plugins/ workspace, not the framework repo. They are genuinely shared across projects today (per CLAUDE.md's core-plugin constraint) but v1 has exactly one consumer. Promote a plugin to its own repo (mirroring today's PHP golem15 stack-plugin-per-repo + submodule model) only when a second app (keios.eu) needs it — this is the Go-workspace equivalent of go mod init + git subtree split, a mechanical move because the plugin's import path is already package-qualified.
  • cmd/summer lives in the app repo, not the framework repo, because the generated plugins.gen.go import list is inherently app-specific (it lists this app's plugin set). The framework repo's own cmd/summer is a template/skeleton the app repo's summer make:plugin scaffolding is generated from, not a binary anyone runs directly.

Architectural Patterns

Pattern 1: Plugin descriptor via one required interface + optional capability interfaces

What: WinterCMS's PluginBase uses optional methods (registerNavigation(), registerPermissions(), registerSettings(), registerMailTemplates(), registerSchedule()) that a plugin overrides only if relevant, PHP silently no-ops the rest. Go has no optional-override inheritance, so the port is small capability interfaces checked with a type assertion — exactly PocketBase's and Caddy's own idiom (a module "is" whatever interfaces it satisfies).

When to use: Every plugin. This is the core extension mechanism requested by the milestone.

Trade-offs: More boilerplate per plugin (must declare which interfaces it satisfies) but fully static — go vet/go build catch a typo in a route handler signature that PHP would only surface at runtime.

Example (directly modeled on the real golem15.fonoteka Plugin.php read for this research — $require, register(), boot(), registerConsoleCommand, registerNavigation, registerPermissions, registerSchedule, Event::listen):

package pact

type Plugin interface {
    ID() string           // "golem15.fonoteka"
    Requires() []string   // ["golem15.apparatus", "golem15.user"] — party topo-sorts on this
    Register(app *backpack.App) error // bind own services; must not call other plugins yet
    Boot(app *backpack.App) error     // safe to reference other plugins' bound services
}

// Optional capabilities — a plugin implements whichever it needs.
type HasModels        interface{ Models() []any }                    // GORM structs, for migrate/reflect
type HasMigrations    interface{ Migrations() fs.FS }                 // goose files, embed.FS
type HasRoutes        interface{ Routes(r *surf.Router) }
type HasCommands      interface{ Commands() []bonfire.Command }
type HasJobs          interface{ Jobs() []conga.JobWorker }
type HasListeners     interface{ Listeners(bus *festival.Bus) }
type HasAdminControls interface{ AdminControllers() []party.AdminController }
type HasNavigation    interface{ Navigation() []party.NavItem }
type HasPermissions   interface{ Permissions() []party.Permission }
type HasSchedule      interface{ Schedule() []party.ScheduledCommand }
type HasMailTemplates interface{ MailTemplates() fs.FS }
type HasConfig        interface{ ConfigDir() fs.FS }   // compass.AddNamespace
type HasLang          interface{ LangDir() fs.FS }     // phrasebook namespace
// plugins/fonoteka/plugin.go
package fonoteka

type Plugin struct{}

func New() *Plugin { return &Plugin{} }
func (p *Plugin) ID() string         { return "golem15.fonoteka" }
func (p *Plugin) Requires() []string { return []string{"golem15.apparatus", "golem15.user"} }

func (p *Plugin) Register(app *backpack.App) error { return nil }
func (p *Plugin) Boot(app *backpack.App) error {
    p.registerNotificationListeners(app)     // Event::listen('eloquent.created: Album', ...)
    p.registerCollectionProvisioning(app)    // Event::listen('golem15.user.register', ...)
    return nil
}

func (p *Plugin) Models() []any { return []any{&Album{}, &Artist{}, &Collection{}, /* ...25 total */} }
func (p *Plugin) Migrations() fs.FS { return migrationsFS } // //go:embed migrations/*.sql
func (p *Plugin) Routes(r *surf.Router) { registerRoutes(r) }
func (p *Plugin) Commands() []bonfire.Command {
    return []bonfire.Command{ReindexAlbums{}, IssueOAuthClient{}, PruneNotifications{}}
}
func (p *Plugin) Jobs() []conga.JobWorker { return []conga.JobWorker{CsvImportWorker{}, WishlistDigestWorker{}} }
func (p *Plugin) Navigation() []party.NavItem { /* mirrors registerNavigation() */ return nav }
func (p *Plugin) Permissions() []party.Permission { /* mirrors registerPermissions() */ return perms }
func (p *Plugin) Schedule() []party.ScheduledCommand {
    return []party.ScheduledCommand{{Command: "fonoteka:prune-notifications", Cron: "@daily"}}
}

func init() { party.Register(New()) } // Caddy-style: blank-imported package self-registers

Pattern 2: Generated import list + summer build (Caddy/xcaddy model, verified)

What: Confirmed against Caddy's own docs and xcaddy's generated main.go: each module registers itself in init() via caddy.RegisterModule, and xcaddy generates a main.go with blank imports for caddycmd, the standard modules, and any requested plugins, then calls caddycmd.Main(). SummerCMS's party mirrors this exactly, one file, regenerated on demand:

// cmd/summer/plugins.gen.go — REGENERATED by `summer build`, do not hand-edit
package main

import (
    _ "github.com/golem15/fonoteka.go/plugins/fonoteka"
    _ "github.com/golem15/fonoteka.go/plugins/user"
    _ "github.com/golem15/fonoteka.go/plugins/translate"
    _ "github.com/golem15/fonoteka.go/plugins/websockets"
    // ...
)

summer make:plugin <name> scaffolds plugins/<name>/{go.mod,plugin.go,models/,migrations/,routes.go}, adds a use ./plugins/<name> line to go.work, and appends the blank import to plugins.gen.go. summer build regenerates plugins.gen.go from a manifest (or by scanning plugins/*/plugin.go for the party.Register call) and runs go build ./cmd/summer. A dev watch loop (reflex/air-style, restart-on-save) makes iterating on a plugin feel like WinterCMS's drop-in autoload, at the cost of a sub-second rebuild.

When to use: Always, for every plugin, first-party or (later) third-party. This is the only supported extension path in v1 — no runtime plugin loading, per the recorded decision.

Trade-offs: Requires the Go toolchain at build/deploy time (acceptable — CI already needs it); adding a plugin is a build-time operation, not a runtime one (this is the entire point of the compiled-plugin decision, not a regression).

Pattern 3: Plugin-extends-plugin without dynamic traits — three concrete mechanisms, not one

WinterCMS's Model::extend() and \Event::listen() closures let a plugin bolt fillable attributes, relations, and behavior onto another plugin's model at runtime. Go has no runtime monkey-patching. Three separate, concrete techniques replace it, matched to what the extension actually needs to do — and this is a load-bearing distinction, not a single hand-wave:

3a. Serialization/response extension → typed "filter" events on festival. This is the port of a pattern already live in the real fonoteka Plugin.php (Event::listen('golem15.user.getApiArray', ...), additive, halt=false, flat-merge). It requires no schema change — only extra keys in an outgoing JSON payload.

package user // owned by the user plugin

type APIArrayEvent struct {
    User  *User
    Extra map[string]any // listeners append; core serializer merges this into the response
}
// plugins/fonoteka/plugin.go — Boot()
bus.Listen(func(e *user.APIArrayEvent) {
    e.Extra["organisation_id"]      = e.User.OrganisationID
    e.Extra["must_change_password"] = e.User.MustChangePassword
})

The user plugin's serializer never imports fonoteka; fonoteka imports user's exported event type. One-directional dependency, no cycle.

3b. Lifecycle extension → GORM's own callback registry, not a custom event. For "when this other plugin's model is created/updated, do something" (the port of Event::listen('eloquent.created: '.Album::class, ...)), GORM already has the right primitive: a plugin registers a named callback directly on the shared *gorm.DB, keyed by model type via db.Statement.Schema. No modification to the owning model's file.

db.Callback().Create().After("gorm:create").Register("fonoteka:notify_album_created", func(tx *gorm.DB) {
    if tx.Statement.Schema.ModelType != reflect.TypeOf(Album{}) { return }
    // ... build actor, call NotificationService — same logic as the PHP listener
})

This is registered once at boot (typically inside the owning plugin's Boot(), alongside festival listeners), and is a real GORM feature (its own Plugin interface: Initialize(*gorm.DB) error plus db.Callback()), not an invented abstraction.

3c. Schema extension (one plugin adding columns to another plugin's table) → migration + companion struct, not struct mutation. WinterCMS plugins routinely Schema::table('users', ...) from a different plugin's migration to add columns to a shared table. The Go port keeps this real capability but makes the querying side explicit and compile-time safe: the extending plugin ships its own migration (ALTER TABLE) and defines its own struct that maps onto the same table, embedding the owning struct for the fields it doesn't add:

// plugins/fonoteka/models/user_org.go — fonoteka's own file, never touches plugins/user/models/user.go
type UserWithOrg struct {
    user.User                  // embeds the owning plugin's columns
    OrganisationID   string `gorm:"column:organisation_id"`
    OrganisationRole string `gorm:"column:organisation_role"`
}
func (UserWithOrg) TableName() string { return "users" }

fonoteka's migration adds the columns; fonoteka's own code queries via UserWithOrg; user's own code is unaware the columns exist and keeps using its own User struct. This is real Go/GORM composition, not hand-waving, and it is exactly what "plugins extend other plugins' models through ordinary Go interfaces and events" reduces to once you need actual new columns rather than just new response keys.

Explicit non-goal: arbitrary runtime field injection into another plugin's existing struct is out of scope — Go structs are fixed at compile time. Anything needing that dynamism belongs behind the WASM extension seam later (.planning/seeds/wasm-extension-api.md), not the compiled plugin API.

Pattern 4: Rule-string validation without struct tags

What: WinterCMS's fields.yaml/columns.yaml rules: block is a YAML-authored, per-field pipe string (required|max:255) resolved at runtime, not at compile time — so it cannot become a Go struct tag (struct tags are fixed at compile time; these rules are data). go-playground/validator's validator.Var(value, tag) — not validator.Struct — is the correct primitive: lifeguard parses each YAML rule string once into a validator tag string, and calls Var() per field at request time, translating validator's field/tag errors through phrasebook for the message.

When to use: Every admin-form save and every plugin-defined YAML validation block.

Trade-offs: Slightly slower than compile-time struct validation (reflection-light, not reflection-free) but is the only approach that keeps fields.yaml authorable without a rebuild — which is the entire reason WinterCMS put rules in YAML in the first place.

Data Flow

Request Flow — SPA-authenticated API call (the 154 ported routes)

This is a direct structural port of the real routes.php middleware groups read for this research (jwt.auth+bindings, inv.scope:*, the public/onboarding groups, the OAuth groups):

Nuxt app request
    │
    ▼
surf: recover/panic guard → request-id/logging → CORS
    │
    ▼
surf: locale middleware (Accept-Language / user.preferred_locale → phrasebook.SetLocale on ctx)
    │
    ▼
bouncer: route-group-specific auth
    ├─ JWT group  (/_fonoteka/api/v1/*)      → decode+verify JWT, set User in ctx
    ├─ token group (/api/v1/fonoteka/*)      → resolve inv-token, then inv.scope:<read|write|ai>
    ├─ OAuth2 group (/oauth/mcp/*)           → zitadel/oidc RS validation, or unauthenticated token/register/metadata endpoints
    └─ public/onboarding groups              → no auth, IP-throttled (cooler-backed limiter)
    │
    ▼
surf: must-change-password gate (if JWT group) → org/collection context resolver
      (sets ActiveCollection into ctx via an unexported context-key type — never a string key)
    │
    ▼
surf: per-route rate limiter (cooler token buckets — mirrors the named RateLimiter::for() buckets)
    │
    ▼
plugin route handler (registered by fonoteka.Routes(r))
    │
    ├─→ lagoon (GORM query/save) ──→ Postgres
    ├─→ festival (fire event; other plugins' Listeners react — Pattern 3a/3b)
    ├─→ conga (enqueue job, same DB transaction as the write — River's transactional enqueue)
    └─→ sandcastle / cooler / postcard as needed
    │
    ▼
JSON response (encoding/json, byte-shape-compatible with the PHP response — parity is the acceptance test)

Job Flow

HTTP handler (e.g. CSV import POST) 
    → conga.Enqueue(tx, CsvImportJob{...})   // same DB transaction as the parent write
    → tx.Commit()                            // job only becomes visible to workers on commit
River worker pool (in-process goroutines or a separate `summer work` process)
    → picks up job → runs plugin-registered JobWorker.Work(ctx, job)
    → festival event fired on completion/failure
    → postcard (digest email) / websocket publisher (Centrifugo) as side effects

Migration Flow

`summer migrate` (bonfire command)
    → party.Registry resolves plugin boot/migrate order via Requires() (topological sort)
    → for each plugin, in order:
        lagoon.Migrator runs goose against a PER-PLUGIN version table
        (goose -table golem15_fonoteka_goose_version, not the shared default)
    → cross-plugin ALTER-table migrations (Pattern 3c) execute after the
      altered table's owning plugin has already migrated — guaranteed by
      the same Requires() order used for Boot()
`summer migrate:rollback --plugin=fonoteka` targets one plugin's own table
      (ports the "drop the last migration and fix it" workflow named in PROJECT.md)

Admin Form Save Flow

Admin SPA: GET /_admin/api/schema/albums/fields
    → party parses fields.yaml (embedded via AdminController.FieldsYAML()) into typed FieldSchema
    → served as JSON (field name, type ∈ {text,textarea,number,switch,dropdown,relation,repeater,fileupload,datepicker}, rules, label key)
Admin SPA renders form generically (PocketBase-style: the SPA does not need a
    compile-time TS type for the schema itself — only for the underlying Album
    API shape, which IS OpenAPI-generated, per PROJECT.md)
User submits → PUT /_admin/api/albums/{id}
    → lifeguard.Validate(fields, payload) — per-field validator.Var() runtime calls (Pattern 4)
    → on success: lagoon Save() → GORM callbacks fire (Pattern 3b) → festival event (Pattern 3a)
    → response

Scaling Considerations

Scale Architecture Adjustments
0-1k users (household-scale reality of Płytarium) Single summer binary, single Postgres, River workers as goroutines inside the same process. This is the permanent steady state for v1's actual target.
1k-100k users (second app, e.g. keios.eu, or a future SaaS framing) JWT/token auth is fully stateless — horizontal scale-out of the API binary behind a load balancer needs no sticky sessions. Only the backend admin cookie session (bouncer/scs) needs a shared store (Postgres-backed scs, not in-memory) once more than one instance runs. River workers can be split into a dedicated summer work process pool separate from the HTTP-serving process.
100k+ users Out of scope for the stated domain (a personal/household record collection tool) — noted only so the design doesn't accidentally block it: Typesense already offloads search, gocloud.dev/blob already offloads file storage to object storage, and Postgres read replicas are the natural next lever for lagoon if ever needed.

Scaling Priorities

  1. First real bottleneck, if any: the admin cookie session store, the moment a second app instance runs — must move from in-memory scs to its Postgres-backed store before scaling out horizontally.
  2. Second: River worker throughput for CSV import/wishlist digest under real household-scale data is unlikely to matter; not a design concern for v1.

Anti-Patterns

Anti-Pattern 1: Treating backpack's container as a service locator everywhere

What people do: Resolve everything via backpack.Resolve[T](app) inside handlers and jobs, Laravel-facade style, because it's convenient and mirrors WinterCMS. Why it's wrong: Defeats Go's compile-time dependency graph — go vet/the compiler can no longer tell you a handler's real dependencies, and tests need a fully-booted container instead of a struct literal. Instead: Use the container only at Register()/Boot() time to wire concrete dependencies into each plugin's own structs (handlers, workers, admin controllers) via constructor injection; application code holds typed fields, not container references.

Anti-Pattern 2: Marshaling the full plugin surface over RPC/WASM now

What people do: Reach for hashicorp/go-plugin or extism/wazero to get "real" plugin isolation, because it sounds safer. Why it's wrong: Already decided against in why-go-not-scala.md and wasm-extension-api.md — the plugin surface here (register models, extend other plugins' models via GORM callbacks and events, add form field types, add HTTP routes) is too wide to marshal across a process/sandbox boundary without losing the entire point of Patterns 1-3. Instead: Compiled plugins only, until the compiled API has been stable for a full milestone; then a narrow, explicitly-scoped WASM slot per the seed doc.

Anti-Pattern 3: String-keyed request context values for org/locale/user

What people do: ctx.Value("orgID") — easy, and how a lot of quick Go HTTP code gets written. Why it's wrong: Collides across packages, no compile-time safety, easy to typo. Instead: Unexported struct key types per concern (type orgCtxKey struct{}), one per surf/bouncer/phrasebook concern, accessor functions (surf.OrgFromContext(ctx)) exported instead of the key.

Anti-Pattern 4: Letting a single shared goose_db_version table serialize unrelated plugins' migrations

What people do: One migrations table for the whole app, because it's goose's default. Why it's wrong: Forces a strict global migration order across plugins that don't actually depend on each other, and makes "roll back just this plugin's last migration" (the workflow PROJECT.md explicitly asks for) impossible without hand-editing the tracking table. Instead: Per-plugin goose version table (-table golem15_<plugin>_goose_version), ordered only by the Requires() DAG, not by a single global sequence.

Integration Points

External Services

Service Integration Pattern Notes
Postgres lagoon (GORM) + conga (River, same DB) One migration target for v1, per the recorded constraint.
Centrifugo bouncer/festival publish only; Nuxt client connects directly, unchanged Framework never becomes a WS server itself — only a publisher + token issuer, per the recorded constraint.
Typesense A thin lagoon-adjacent search client, driven by festival events on model create/update Mirrors Scout's observer-driven sync; Album.disableSearchSyncing() equivalent gate ported as a settings check.
Discogs / Anthropic / OpenAI Plain net/http clients inside the fonoteka plugin, never inside the framework core App-specific; framework core has zero knowledge these exist.
ChatGPT connector / MCP bouncer's zitadel/oidc OAuth2/OIDC provider, mirrored 1:1 from the /oauth/mcp/* routes read in routes.php RFC 8414 metadata, RFC 7591 DCR, auth code + PKCE, refresh tokens — same shapes, same acceptance test.

Internal Boundaries

Boundary Communication Notes
Framework core ↔ plugins Go interfaces (pact) only, one direction (plugins import framework, never the reverse) Enforces the "core plugins can't have breaking changes" rule structurally: the framework doesn't know a specific plugin exists.
Plugin ↔ plugin (e.g. fonoteka ↔ user) Exported events (festival, Pattern 3a), GORM callbacks (Pattern 3b), companion structs over shared tables (Pattern 3c), and Requires()-ordered boot Never a concrete import of another plugin's internal package beyond its exported model/event types — mirrors the real Plugin.php's class_exists() guards for optional cross-plugin coupling (e.g. Golem15\WebSockets\Classes\AuthorizerRegistry), which Go should port as an interface-typed, nil-checked optional dependency rather than a hard import.
Framework repo ↔ application repo go.mod require (versioned once stable) or go.work use ../summercms.go (local dev) The framework never imports application code; the application always imports the framework by module path.
API parity harness ↔ both backends Plain HTTP client replaying a recorded fixture corpus The harness has almost no dependency on SummerCMS internals — it can be built and its fixture corpus assembled starting at t=0, in parallel with all framework work, since it only needs a running PHP backend to record against.

Framework vs Application Repo Boundary

Płytarium is not a plugin inside summercms.go. It is a plugin — actually a small workspace of plugins — inside a separate application repo (proposed name: fonoteka.go, sibling to the existing PHP fonoteka repo), which go.mod-requires this framework. Three tiers, mirroring WinterCMS's own reality more faithfully than a two-tier split would:

  1. Framework core (summercms.go, this repo) — backpack through party, surf through sunset. No app knowledge. Versioned and released on its own.
  2. Shared stack plugins (user, and eventually blog/pages/payment equivalents) — start life inside the app repo's plugins/ workspace for v1 velocity (only one consumer exists), get extracted to their own repos the moment a second app (keios.eu) needs one, exactly as ../IDEA_LIB_NAMES.md's "Porting Illuminate module by module... the target app pulls what is needed" already implies. Extraction is mechanical: the plugin's own go.mod already exists as a workspace member; promoting it is a repo split, not a rewrite.
  3. App-specific domain plugin (fonoteka) — always lives in the app repo. Never shared, never promoted.

This gives the roadmap a clean phase boundary: framework-core phases produce summercms.go releases; app phases produce fonoteka.go commits against a pinned (or go.work-local) framework version. The two repos can be developed by the same people without merging their concerns.

First Vertical Slice

The smallest binary that proves the whole stack end-to-end against a real Płytarium endpoint: GET /_fonoteka/api/v1/genres (or styles — either is equally minimal: no relations beyond a simple has-many from Album, no jobs, no search, no storage, no OAuth). It still forces every layer to exist in its simplest form:

  1. compass boots config (DB DSN, SUMMER_ENV).
  2. backpack boots the app container.
  3. lagoon connects to Postgres, runs one goose migration (create_genres).
  4. party registers a minimal fonoteka plugin descriptor with one model (Genre) and one route.
  5. surf serves the route through the real middleware chain, including...
  6. bouncer's JWT middleware (decode + verify a real token, minted by a throwaway summer command against a seeded test user) — even the simplest authed route in routes.php sits behind jwt.auth, so skipping this would not be a faithful slice.
  7. Response JSON is diffed, byte-for-byte on shape, against the same request recorded from the live PHP backend — the first fixture in the API parity harness.

Everything past this (the other 24 models, 153 routes, jobs, realtime, search, OAuth2, admin SPA) is the same pattern repeated and parallelized; this slice is the one phase that cannot be skipped or parallelized, because every later phase depends on all seven of its pieces existing and working together.

Suggested Build Order

Layer 0 (parallel, no deps):        pact, towel
Layer 1 (parallel, needs L0 only):  compass, phrasebook, bonfire   ← bonfire has zero inter-module deps per its own doc
Layer 2:                            festival (needs pact)
Layer 3:                            backpack (needs compass, festival, pact)
Layer 4:                            party (needs backpack, pact, festival, bonfire)
Layer 5 (parallel, needs L0-4):     lagoon, surf                   ← neither depends on the other
Layer 6:                            bouncer (needs lagoon for user storage, surf for middleware shape)
                                     ── FIRST VERTICAL SLICE checkpoint here ──
Layer 7 (parallel, independent adapters, needs L0-4 only):
                                     lifeguard, cooler, conga (needs lagoon for Postgres), postcard, sandcastle
Layer 8 (parallel, independent of each other, needs L6-7):
                                     admin schema pipeline (fields.yaml→JSON, party+lagoon+lifeguard+surf)
                                     API parity harness (near-zero summer-* deps — can actually start at t=0,
                                       fixture recording against the live PHP backend needs no Go work at all)
                                     bulk model/route porting (24 remaining models, 153 remaining routes,
                                       parallelizable across plugins, gated only by each model's own FK order —
                                       the same DAG the 27 PHP migrations already encode)
Layer 9 (last, lowest risk):        sunset (stub only — no theme work needed in v1)

sunset is deliberately last and thin: v1 is headless, so the only place templating shows up at all is postcard's mail layouts, which html/template covers without a separate investment.

Sources

  • Caddy build docs and xcaddy generated-main.go/caddy.RegisterModule mechanics — verified 2026-09-16: Extending Caddy, Build from source, xcaddy DeepWiki
  • PocketBase hook system (event-driven extension, schema-driven admin) — verified 2026-09-16: Extend with Go — Event hooks, Hook System — DeepWiki, Collection schema management — DeepWiki
  • GORM's own Callback()/Plugin extension mechanism (training-knowledge, MEDIUM confidence — not independently re-verified this session; validate against current GORM docs in the phase that first uses cross-plugin callbacks)
  • .planning/PROJECT.md, .planning/research/go-ecosystem.md, .planning/notes/why-go-not-scala.md, .planning/notes/v1-target-plytarium.md, .planning/seeds/wasm-extension-api.md (this repo)
  • modules/summer-compass/README.md, modules/summer-bonfire/PLAN.md, modules/summer-phrasebook/README.md (design carry-overs, this meta-repo)
  • /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/Plugin.php and routes.php — read in full for this research; the concrete source of the request-lifecycle mapping, the Event::listen extension examples, and the plugin descriptor's optional-method shape

Architecture research for: SummerCMS (Go) — framework + Płytarium v1 port Researched: 2026-09-16