| name | tech |
| description | Use when writing or reviewing Go code in fusionaly-oss — adding routes, handlers, domain contexts, background jobs, the manager CLI, wiring the app, or writing tests. Covers the cartridge framework, matcha for the manager, Phoenix Contexts, lifecycle/shutdown, and the test conventions. |
Fusionaly Tech & Conventions
Overview
Fusionaly is a Go monolith (Fiber + GORM + SQLite, React/Inertia frontend). The guiding principle: the libraries do the heavy lifting; app code stays thin and domain-focused. Boilerplate — HTTP server, routing, sessions, CSRF, rate limiting, static serving, Inertia, SQLite WAL/serialized writes, graceful shutdown, deploys — lives in cartridge (the app) and matcha (the manager). The app reuses them; it does not re-implement them.
This is a deliberate, pragmatic stance: Go favors explicit, no-magic code, but here we push recurring infrastructure into a shared library so each app is a small amount of clean domain code on top. Don't hand-roll what the library already provides.
The stack at a glance
| Concern | Owner | Where |
|---|
| HTTP server, lifecycle, graceful shutdown, routing, sessions, CSRF, rate limiting, static/Inertia | cartridge | cartridge.NewApplication(...) |
| SQLite connection, WAL, serialized writes, migrations plumbing | cartridge/sqlite + internal/database | dbManager, sqlite.PerformWrite |
| Background jobs lifecycle (start/stop with the server) | cartridge BackgroundWorker | internal/jobs |
| Install / update / deploy / backup / image swap (self-hosted ops) | matcha | cmd/manager |
| Domain logic (analytics, events, websites, feed, ai, …) | app (Phoenix Contexts) | internal/<domain>/ |
| Frontend | React 19 + Inertia | web/src |
cartridge: configure, don't reinvent
cartridge is our own framework — repo https://github.com/karloscodes/cartridge (Go module github.com/karloscodes/cartridge). When something feels like boilerplate, check there before writing it.
The whole app boots by handing cartridge a config struct. internal/app.go is the one place that wires it, and it's ~120 lines:
app, err := cartridge.NewApplication(cartridge.ApplicationOptions{
Config: cfg,
Logger: logger,
DBManager: dbManager,
ServerConfig: serverConfig,
RouteMountFunc: MountAppRoutes,
BackgroundWorkers: []cartridge.BackgroundWorker{jobsManager},
})
cartridge then owns the server, signal handling, and graceful shutdown — including stopping the registered BackgroundWorkers. The app never writes a main() event loop or shutdown handler.
Key sub-packages you reuse (don't reinvent): cartridge/inertia (inertia.RenderPage), cartridge/cache (cache.Cache[K,V]), cartridge/sqlite (sqlite.PerformWrite for serialized writes), cartridge/testsupport (test DBs), flash, session.
Phoenix Contexts: thin handlers, fat contexts
Each domain is a package under internal/<domain>/ exposing top-level functions that take *gorm.DB — no service objects, no HTTP imports.
type Setting struct { ID uint; Key string; Value string }
func GetOpenAIKey(db *gorm.DB) (string, error) { return GetSetting(db, KeyOpenAIKey) }
func SaveOpenAIKey(db *gorm.DB, key string) error { }
func DashboardAction(ctx *cartridge.Context) error {
metrics := analytics.GetDashboardMetrics(ctx.DB(), websiteID, tf)
return inertia.RenderPage(ctx.Ctx, "Dashboard", inertia.Props{"metrics": metrics})
}
Rules: contexts never import internal/http; handlers never hold business logic; writes go through sqlite.PerformWrite for SQLite's single-writer model. Pages use Inertia + PRG (no fetch() for page data).
matcha: the manager owns ops
matcha is our own deploy/update tool — repo https://github.com/karloscodes/matcha (Go module github.com/karloscodes/matcha). cmd/manager is a thin wrapper over matcha — install, update, deploy, backup, image swap are library calls, not bespoke shell:
m := matcha.New(matcha.Config{Name: "fusionaly", AppImage: "karloscodes/fusionaly:latest",
HealthPath: "/_health", Volumes: []string{"/app/storage", "/app/logs"},
CronUpdates: true, Backups: true, ManagerRepo: "karloscodes/fusionaly-oss"})
Add a manager command by mirroring an existing one (runMigrateToOSS mirrors the structure of the others). Don't script Docker by hand.
Background jobs & shutdown
Jobs are a BackgroundWorker registered with cartridge, so they start and stop with the server. The scheduler runs each job on a ticker through executeJobSafely (panic-safe, single-flight). A job is just a struct with Run() error — the scheduler owns timing; jobs never spin their own goroutine/ticker. See internal/jobs/scheduler.go + internal/jobs/feed.go.
Testing conventions
- Real behavior, no mocks. Use a real SQLite test DB via
testsupport.SetupTestDBManager(t) (wraps cartridge/testsupport). Register new models in testsupport.allModels().
- Env: tests require
FUSIONALY_ENV=test — run with make test (sets it for you), not bare go test.
- Four phases with blank lines between: setup, exercise, verify, teardown. Assert with testify
assert/require.
- Contexts via
t.Run for related scenarios ("with valid key", "with no key").
- Table-driven only when the cases are open-ended/homogeneous — e.g. a list of SQL strings each expected to be rejected (
internal/ai/ai_test.go TestValidateReadOnlyQuery). Don't force a table when a few t.Run blocks read clearer.
- E2E (
e2e/) is Playwright, sequential (workers: 1, story order 001→…); specs build on each other (onboarding creates the user — setup does not). Wait on conditions, never race networkidle.
Common mistakes
| Mistake | Instead |
|---|
Writing a main() loop, shutdown handler, or middleware cartridge already provides | Configure ApplicationOptions; let cartridge own it |
Business logic in an internal/http handler | Move it to the domain context; handler just parses + renders |
A context importing internal/http | Contexts are HTTP-agnostic; pass *gorm.DB |
Raw db.Exec writes scattered around | sqlite.PerformWrite(...) (single-writer safety) |
| Hand-rolled Docker/deploy logic in the manager | A matcha call |
| Mocks in tests | Real test DB via testsupport |
| A job that starts its own ticker/goroutine | Run() error + register in the scheduler |
fetch() for page data | Inertia props + PRG |