41 KiB
Architecture Research
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,partyship together, version together, and are released as one thing — internal packages in one module is correct here (no plugin ever needs a different version oflagoonthan 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 inwhy-go-not-scala.md— ago.workworkspace of separate modules, mirroring Caddy/xcaddy where each plugin module registers itself via a blank import andinit().go.work'susedirective resolves local plugin modules withoutreplacehacks 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 |
Recommended Project Structure
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
pactcontracts 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 aspactinterfaces hold. plugins/<name>/go.modper plugin,go.workat the app root. Matches the already-recorded decision (why-go-not-scala.md): "plugins/ is a workspace of Go modules."go.work'susedirective means a plugin never needs areplaceline or a tagged release to be part of the local build —summer buildjust runsgo buildinside 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 (perCLAUDE.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 ofgo mod init+git subtree split, a mechanical move because the plugin's import path is already package-qualified. cmd/summerlives in the app repo, not the framework repo, because the generatedplugins.gen.goimport list is inherently app-specific (it lists this app's plugin set). The framework repo's owncmd/summeris a template/skeleton the app repo'ssummer make:pluginscaffolding 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
- First real bottleneck, if any: the admin cookie session store, the moment a second app instance runs — must move from in-memory
scsto its Postgres-backed store before scaling out horizontally. - 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:
- Framework core (
summercms.go, this repo) —backpackthroughparty,surfthroughsunset. No app knowledge. Versioned and released on its own. - Shared stack plugins (
user, and eventuallyblog/pages/paymentequivalents) — start life inside the app repo'splugins/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 owngo.modalready exists as a workspace member; promoting it is a repo split, not a rewrite. - 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:
compassboots config (DB DSN,SUMMER_ENV).backpackboots the app container.lagoonconnects to Postgres, runs one goose migration (create_genres).partyregisters a minimalfonotekaplugin descriptor with one model (Genre) and one route.surfserves the route through the real middleware chain, including...bouncer's JWT middleware (decode + verify a real token, minted by a throwawaysummercommand against a seeded test user) — even the simplest authed route inroutes.phpsits behindjwt.auth, so skipping this would not be a faithful slice.- 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.RegisterModulemechanics — 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()/Pluginextension 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.phpandroutes.php— read in full for this research; the concrete source of the request-lifecycle mapping, theEvent::listenextension examples, and the plugin descriptor's optional-method shape
Architecture research for: SummerCMS (Go) — framework + Płytarium v1 port Researched: 2026-09-16