type
Architecture
title
simple-lms architecture
description
Request path, package layout and the three seams — access.Access, RegisterHost and the settings contract — plus the register-migration status recorded from the acceptance run.
tags
simple-lms, architecture, gin, sqlite, register, seams
status
stable
stale_after
Fri Apr 30 2027 02:00:00 GMT+0200 (Central European Summer Time)
generated
techwriter/mimo-v2.6-flash · Sat Oct 31 2026 11:02:38 GMT+0100 (Central European Standard Time)
sources
repo → https://github.com/nextrum-sy/simple-lms; main → cmd/server/main.go; host → internal/infrastructure/sqlite/register_host.go; authorize → internal/interfaces/http/authorize.go; live → https://test-lms.nextrum-sy.com/health

simple-lms — the architecture

Go + Gin + HTMX + SQLite. One register plugin, reached only through the money service. Deployed as a swarm service, one volume per tenant.

flowchart TD subgraph browser["Browser"] ui["server-rendered pages<br/>HTMX + tom-select"] end subgraph http["HTTP layer — internal/interfaces/http"] mw["middleware<br/>auth · RBAC · CSRF · lang"] handlers["handlers<br/>students · payments · ledger · settings"] plugin_tabs["plugin settings panels<br/>one tab per enabled plugin"] end subgraph app["Application services"] money["money service<br/>payments · void · ledger · boxes"] settingsvc["settings service<br/>one JSON document"] audit["audit service<br/>one row per change"] end subgraph host["Register host — internal/infrastructure/sqlite"] boot["bootRegister<br/>Register → Migrate → InitAll"] svc["Service(ctx, tags)<br/>typed *Service"] rtx["RunPluginTx<br/>db.RunInTx + runtime.WithTx"] ttl["5s settings TTL re-read"] end subgraph plugin["nx-register (plugin)"] psvc["Service<br/>Post · Void · Close · Count"] pset["Settings struct<br/>lock_enforced · variance_category"] store["Store<br/>Filter · Page · Sums"] end db[("SQLite<br/>re_movements · re_lines · re_boxes<br/>re_settings · settings · audit_log")] ui --> mw --> handlers handlers --> plugin_tabs handlers --> money handlers --> settingsvc handlers --> audit plugin_tabs --> pset money --> svc svc --> psvc psvc --> store store --> db pset --> db settingsvc --> db audit --> db boot --> db rtx --> db ttl --> pset class ui ext class mw,handlers,plugin_tabs,money,settingsvc,audit,boot,svc,rtx,ttl host class psvc,pset,store plugin class db store classDef host fill:#15314a,stroke:#2f5d84,color:#e6edf7 classDef plugin fill:#163a2f,stroke:#2f7d5b,color:#e6edf7 classDef store fill:#3a2f16,stroke:#8a7327,color:#f2e8cf classDef ext fill:#2a1f3a,stroke:#6b4f8a,color:#e9dcf5 classDef bad fill:#4a1f28,stroke:#8a3f4f,color:#f7c9d2

Request path

Every protected request walks the same chain (cmd/server/main.go, internal/infrastructure/web, internal/interfaces/http/authorize.go):

flowchart TD req["request"] --> recovery["gin.Recovery"] recovery --> sec["web.SecurityHeaders"] sec --> tel["telemetry: tracing + request metrics"] tel --> lang["web.LanguageMiddleware<br/>cookie or ?lang, resolved into the context"] lang --> csrf["stable per-browser CSRF cookie<br/>(hidden form field always matches it)"] csrf --> guard{"route class"} guard -->|"public: /health, /auth/login"| pub["handler"] guard -->|"protected"| authmw["web.AuthMiddleware<br/>session cookie → user, 24h TTL"] authmw --> csrfmw["web.CSRFMiddleware (mutations)"] csrfmw --> setup["h.RequireSetup<br/>settings-write + no boxes → redirect /setup"] setup --> gate["h.Require(resource, action)"] gate -->|"error or denied"| forbid["NotAuthorized — 403, fail closed"] gate -->|"allowed"| handler["handler<br/>record-level scope via scope.go"] handler --> svc["application service"] svc --> repo["sqlite repo — handle resolved from ctx"] repo --> db[("SQLite<br/>WAL · one connection")] class req,recovery,sec,tel,lang,csrf,guard,pub,authmw,csrfmw,setup,gate,handler,svc,repo host class forbid bad class db store classDef host fill:#15314a,stroke:#2f5d84,color:#e6edf7 classDef store fill:#3a2f16,stroke:#8a7327,color:#f2e8cf classDef bad fill:#4a1f28,stroke:#8a3f4f,color:#f7c9d2

Two rules make this chain safe rather than merely ordered:

Package layout

Path Holds
cmd/server the only wiring point: config → DB → repos → services → provisioners → handlers → routes
cmd/mockseed demo-data seeder (500 students, per-user random passwords)
config env loading, currency map, language directions
internal/domain entities + repository interfaces per bounded context (rbac, money, student, school, settings, auth, audit, transport, id)
internal/application use-case services: auth, rbac, access, school, student, money, settings, guardian, audit, transport
internal/infrastructure/sqlite all repos, the embedded migrations, the seeder/provisioners, and the register host binding
internal/infrastructure/web session store, CSRF, security headers, language middleware, localized renderer, money/date formatting
internal/infrastructure/telemetry OTel init, Gin tracing, slog bridge
internal/interfaces/http handlers, routes_*.go per module, authorize.go, scope.go, validation, pagination, rate limit
internal/interfaces/templates template parse/execute tests
internal/i18n locale loading and lookup
web templates, locales, static assets (on disk, read at boot)

The three seams

1. access.Access — users and permissions

type Access interface {
    Authenticate(ctx, username, password string) (*Principal, error)
    Can(ctx, userID, resource, action string) (Decision, error) // Decision{Allowed, Scope}
    InvalidateUser(ctx, userID string)
    InvalidateRole(ctx, roleCode string)
    InvalidateAll(ctx)
}

Implemented in-process today (internal/application/access) over the auth and rbac services, behind the interface, so moving it into a plugin is a wiring change rather than a rewrite. The route middleware calls Can; record-level scope is enforced by the handlers themselves (scope.go). Full contract, cache semantics and boot behaviour: Access and RBAC.

2. RegisterHost — the ledger

money.RegisterHost (internal/application/money/service.go) is the whole surface between host and plugin: Service(ctx, tags) · Settings() · RunTx(ctx, fn) · RefreshSettings(ctx) · SetClock · ID · Version · Enabled. The money service depends on that interface, never on nxregister types or re_ tables. The binding lives in internal/infrastructure/sqlite/register_host.go:

3. The settings contract

The plugin's Settings() value (Fields/GetAll/Put/Update from nx-common) is rendered as a tab of the settings page and saved by the same Save button — inputs namespaced plugin_<id>_<key> so they cannot collide with the host document. The host never learns the plugin's struct or its table. Details: settings contract and settings.

Cross-cutting invariants

Status — register migration complete

simple-lms runs only nx-register; the pilot package go-register is gone from go.mod, and its tables (movements, lines, boxes, cash_boxes, drawer_closes, drawer_counts, register_schema_migrations) no longer exist in a database created by the squashed baseline — only the plugin's re_* tables do.

The money flows — fee payment, expense, salary — were verified end to end on the deployed tenant (test-lms) against the plugin's ledger rather than against host tables, including a salary cancellation that left a voided movement plus a reversal line and a box balance that equals the posted-only sum exactly. The run and the five UI gaps it exposed are recorded in nx-register.

Plugin boot

The register plugin declares Core() == true today, so its Register/Migrate/Init failures stop the process (bootPlugins in register_host.go); a non-core plugin would be logged and disabled instead. The owner considers the register non-core in principle — the flip is one line (return false) plus the host's already-tested degrade path; it is deferred rather than decided against.

Rendered from simple-lms/architecture.md · OKF bundle · regenerate with npm run render