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.
Request path
Every protected request walks the same chain (cmd/server/main.go,
internal/infrastructure/web, internal/interfaces/http/authorize.go):
Two rules make this chain safe rather than merely ordered:
- Fails closed.
Requirerenders 403 on a denied decision and on an internal error fromaccess.Can— an error never becomes access (internal/interfaces/http/authorize.go). - UI and enforcement agree. The template
perm(roleID, resource, action)function funnels through the sameAuthorizationServicethe middleware uses, so a hidden button can never contradict the gate behind it.
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:
- Boot is graded.
RegisterandMigratefailures stop the process (the register declaresCore() == true); a failedInitAllfor a non-core plugin only hides that plugin's features. A broken plugin must never take the school offline. - Handles come from the context.
RunTx=db.RunInTx+runtime.WithTx; every repo and the plugin resolve their handle withruntime.EngineFor(ctx, db). Reading*bun.DBinside a transaction takes a second connection from the one-connection pool and hangs, silently. - The method catalog is the deployment's.
Service(ctx, tags)rebuilds the plugin over the current payment-method tags; the host re-inits when the tag signature, the clock, or a 5 s settings TTL says the plugin's in-memory view is stale, so a settings edit lands on the next write without a restart.
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
- One Save is one transaction.
SettingsSaveopens the transaction once (money.RunPluginTx) and runs the settings document, both cash-box syncs, every plugin panel (with its before/after audit row) inside it. Any failure writes nothing. - Every change is audited. Mutating handlers call
recordAudit— action, resource, record id, user, role, IP — intoaudit_log. - The RBAC seam is
rbac.RoleProvisioner. The definitions (DefaultRoles,Catalogue) live in code, andcmd/serverhands them to whichever implementation owns storage at start-up — sqlite today, a users/access plugin tomorrow. Migrations carry schema only; no role row is written by SQL.
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