Database
Persistence runs on ORM++
(private Go module, GOPRIVATE=gitlab.techeve.de). Backend via config:
- SQLite (default):
database_pathin config.json — embedded, zero operational overhead. - PostgreSQL:
database_dsn: "postgres://..."— nothing else needed. ORM++ guarantees behavioral equivalence; application code never branches on backend.
Connection, model registration, migration, and the ORM++ workers
(projections, snapshots, archiving) are encapsulated by internal/storage/database.go
(storage.Open). The complete ORM++ API reference lives in the ORM++ repo
under doc/API.md.
Tenant model
Section titled “Tenant model”Two levels, deliberately separated:
- Gateway configuration (User, APIKey, Tenant, SmtpRoute,
BlocklistEntry, OutboundMail):
orm.TenantFree()— global tables without a tenant column. - Form data (Submission, Nonce): lives in the ORM++ tenant of the
respective customer site (
Tenant.OrmTenantID, created when a tenant is provisioned viadb.Tenants().Create). Every access requiresorm.WithTenant(ctx, tenant.OrmTenantID)— fail-closed. This gives you GDPR export (Tenants().Export) and right-to-be-forgotten (Archive+Purge) per customer site for free.
Event sourcing
Section titled “Event sourcing”Submission is event-sourced: status transitions (received → scored → sent/quarantined → released/discarded/blocked/failed) are events; the
history (sub.History(ctx)) is the audit log of the concept. The struct
itself is only the folded read model (Apply function in
internal/core/domain/submission.go).
Note: on ES read models, the query builder only knows the struct’s own
fields — the aggregate timestamps are methods. That’s why Submission has
its own ReceivedAt field (orm:"index").
Field encryption
Section titled “Field encryption”Tenant.SigningSecret and SmtpRoute.Password carry orm:"encrypted"
(AES-256-GCM, key = encryption_key from config.json). The database sees
only ciphertext; these fields are neither filterable nor sortable and are
never serialized. Back up the key — without it, these fields are lost.
Schema changes
Section titled “Schema changes”- Additive (new column/index/model): adjust the model or register it in
registerModels()— ORM++‘s auto-diff handles the rest. - Non-additive (restructuring/removing a field): increase
storage.SchemaVersionand add anorm.MigrationTo(db, n, ...)step (expand/contract with dual-write, see ORM++ doc/API.md §8). Mark fields slated for removal withorm:"deprecated"first. - Drift protection: a changed model without a version bump ⇒ startup error.
Repositories & tests
Section titled “Repositories & tests”CRUD access is encapsulated by the repositories (internal/storage/repositories);
orm.ErrNotFound is translated to repositories.ErrNotFound via translate().
For submissions (ES), the services use orm.New/Load/Query directly.
Tests open a throwaway database in the temp directory:
db, err := storage.Open(ctx, orm.SQLite(filepath.Join(t.TempDir(), "test.db")), make([]byte, 32))Template: internal/core/services/services_test.go.
Gotchas
Section titled “Gotchas”- Never call
UpdateSetwith pointer values (orm.Set("X", time.Now()), not&now) — pointers bypass ORM++‘s value encoding and make the row unreadable on the next scan. - Nonce redemption uses the unique constraint as an atomic lock
(
repositories.NonceRepository.Consume): a second insert of the same nonce ⇒ErrNonceUsed(replay).