Models & BaseModel
A model is a Go struct registered with the server. From it, maniflex derives a database table, the JSON request and response shapes, the set of REST routes, and the validation applied to every write. This page covers what a struct must contain to be a valid model, how it maps to a table, and the options available at registration. Field-level tags are documented in Field Tags Reference; relationships in Relations.
Definition
A model is an ordinary struct that embeds maniflex.BaseModel:
type Article struct {
maniflex.BaseModel
Title string `json:"title" mfx:"required,filterable,sortable"`
Body string `json:"body" mfx:"required"`
}
Registration validates the struct and adds it to the registry:
server.MustRegister(Article{})
Register returns an error; MustRegister panics on failure and is intended
for use in main or package initialisation. A struct is rejected at
registration if it is not a struct type or does not embed BaseModel.
Two embedding rules are enforced there as well:
- Embed by value, never by pointer.
*maniflex.BaseModel— or any other pointer embed — is left nil by the record scanner, so every field access through it panics on the first request. It is refused at registration instead. - Two fields cannot map to the same column. A field shadowing one of
BaseModel’s columns produced two entries with the same DB name, and the framework then disagreed with itself about which was meant: lookups took the first, writes took the last. Rename one, or give it an explicitdb:"...".
BaseModel
Every model must embed maniflex.BaseModel. It contributes three columns common to
all tables:
type BaseModel struct {
ID string `json:"id" db:"id"`
CreatedAt time.Time `json:"created_at" db:"created_at" mfx:"readonly,sortable"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at" mfx:"readonly,sortable"`
}
ID— the primary key, a UUID assigned by the framework on create.CreatedAt— set once, when the row is created.UpdatedAt— refreshed on every update.
All three are managed by the framework. CreatedAt and UpdatedAt are
readonly: values supplied for them in a request body are ignored rather than
stored. Because they are part of BaseModel, they are never declared on
individual models.
A struct that does not embed BaseModel — or otherwise lacks an id column —
fails registration.
Field mapping
Each exported field of a model maps to a database column. Three struct tags control the mapping:
| Tag | Purpose |
|---|---|
json | the field’s name in request and response bodies |
db | the column name; defaults to the snake_case field name if omitted |
maniflex | field behaviour — validation, filterability, and so on |
A minimal field needs only a json tag; db is derived and mfx is optional.
The mfx tag is the largest of the three and has its own reference in
Field Tags Reference. Fields that name a related model — for example
a UserID foreign key — are interpreted as relations; see
Relations.
Nullability is the Go type
A pointer field gets a NULL column; everything else gets NOT NULL. No
tag controls this — the type is the whole declaration:
type Note struct {
maniflex.BaseModel
Title string `json:"title"` // NOT NULL — cannot be null
Body *string `json:"body"` // NULL — may be null
}
That decides what a request may send. {"body": null} stores SQL NULL;
{"title": null} is refused with 422 naming the field, because the column has
no null to store:
{
"error": {
"code": "VALIDATION_ERROR",
"details": [{
"field": "title",
"message": "field \"title\" cannot be null; its type has no null value — send a value, omit the field, or make it a pointer to allow null"
}]
}
}
Three things are distinct and stay distinct: null (refused unless the field is
a pointer), "" (a value, stored as written), and omitting the key (leaves
the stored value alone on a PATCH).
If you need to tell “empty” from “not set”, make the field a pointer. A non-pointer field genuinely cannot represent the difference — it reads back as the zero value either way.
Table names
By default the table name is the struct name converted to snake_case and pluralised:
| Struct | Table |
|---|---|
Article | articles |
BlogPost | blog_posts |
Category | categories |
Analysis | analyses |
Matrix | matrices |
A short list of Latin and Greek stems inflect classically — analysis, basis,
axis, diagnosis, thesis (and hypothesis), datum, medium, matrix,
vertex, criterion, phenomenon — including inside a compound name, so
BloodAnalysis becomes blood_analyses. Everything else takes the English
ending: album → albums, complex → complexes, and index → indexes,
since that is the plural the database world uses.
To use a different name, pass a ModelConfig with TableName set when
registering:
server.MustRegister(
Article{}, maniflex.ModelConfig{TableName: "articles"},
)
TableName is also how you pin an existing table whose generated name has
changed — see the v0.3.0 note in the changelog if you have a model named after
one of the stems above.
Registration options
ModelConfig carries per-model options. All fields are optional; an omitted
ModelConfig applies the defaults described above.
| Field | Purpose |
|---|---|
TableName | override the derived table name |
SoftDelete | opt the model into soft deletion — see Soft Delete |
Middleware | pipeline middleware scoped to this model, installed at registration — see Writing Middleware |
Versioned | record field-change history in a sibling {model}_history table |
VersionedDiffOnly | with Versioned, store only changed fields rather than full snapshots |
Indices | additional database indexes created during AutoMigrate |
ExportEnabled | mount GET /:model/export (CSV / XLSX) — see CSV / XLSX Export |
MaxExportRows | row cap for the export endpoint; default 100,000 |
AggregateEnabled | mount GET /:model/aggregate?aggregate=<url-encoded JSON> (grouped count/sum/avg/min/max) — see Aggregations |
OptimisticLock | enable If-Match / ETag concurrency control on PATCH and DELETE |
Adapter | route this model to a separate database adapter |
Singleton | expose the model as a single-row resource (GET / PATCH, no id) — global, or one row per tenant when scoped; see Singleton models |
Headless | register the model fully but mount no REST routes, freeing its path for a custom action — see Serving a model’s own path from an action |
Optimistic locking (OptimisticLock)
When OptimisticLock: true, every PATCH and DELETE request that includes an
If-Match header is checked against the current record’s ETag before the write
executes. A mismatch returns 412 Precondition Failed (PRECONDITION_FAILED).
Requests without If-Match are unaffected — the flag opts in to enforcement,
not mandatory locking.
The ETag format is identical to the one emitted by response.Cache (MD5 of the
JSON response body), so clients can use the header from a preceding GET directly:
server.MustRegister(Invoice{}, maniflex.ModelConfig{OptimisticLock: true})
server.Pipeline.Response.Register(
response.Cache(300),
maniflex.ForModel("Invoice"),
maniflex.ForOperation(maniflex.OpRead),
maniflex.AtPosition(maniflex.After),
)
GET /invoices/42 → 200 ETag: "d41d8cd9..."
PATCH /invoices/42 If-Match: "d41d8cd9..." → 200
PATCH /invoices/42 If-Match: "stale" → 412
PATCH /invoices/42 If-Match: * → 200
PATCH /invoices/99 If-Match: * → 404 (no such record)
If-Match: * is the RFC 9110 wildcard: it holds for any existing record, so it
means “overwrite whatever is there, but do not create it” rather than pinning a
particular version. It still takes the row lock, so it is safe to use on a
contended record — it just does not care which version it lands on.
The check and the write it guards run as a single transaction, with the record
held under a row lock (SELECT … FOR UPDATE on Postgres) from the ETag
comparison until the write commits. Two clients holding the same ETag therefore
cannot both succeed: the loser waits on the lock, then re-reads a record whose
ETag has moved on and gets its 412. When the request already runs inside a
transaction (maniflex.WithTransaction) the guard joins it and the lock is held
until that transaction commits; otherwise the DB step opens and commits one of
its own.
Singleton models (Singleton)
Some resources are inherently single-row: an application config record, a set of
feature flags, the banner an admin edits and every client reads at launch. With
Singleton: true the model drops its collection and item routes and exposes just
two endpoints on the bare table path — no id in the URL:
GET /:model → read the one row
PATCH /:model → update the one row
There is no POST, DELETE, or list endpoint; requesting them returns
405 Method Not Allowed, and there is no /:model/:id subtree.
The single backing row is provisioned lazily under the well-known
maniflex.SingletonID on first access, from each column’s default. So the first
GET returns defaults before anything has been written, and PATCH always
targets an existing row — it behaves like an upsert:
type AppConfig struct {
maniflex.BaseModel
MaintenanceMode bool `json:"maintenance_mode" mfx:"default:false"`
MinAppVersion string `json:"min_app_version" mfx:"default:1.0.0"`
Banner string `json:"banner"`
}
server.MustRegister(
AppConfig{}, maniflex.ModelConfig{Singleton: true, TableName: "config"},
)
GET /config → 200 {"data":{"id":"singleton","maintenance_mode":false,"min_app_version":"1.0.0","banner":""}}
PATCH /config {"maintenance_mode": true} → 200 {"data":{"id":"singleton","maintenance_mode":true, ...}}
GET /config → 200 (reflects the update)
POST /config → 405
Because the row is auto-provisioned from column defaults, a singleton model may
not declare mfx:"required" fields — there would be no value to satisfy them on
first access. Such a model is rejected at registration. Give fields sensible
mfx:"default:…" values (or make them pointers) instead.
One row per tenant
The example above is one row for the whole application. The other common shape is one row per tenant — a storefront, a profile, a per-org settings record — which is near-universal in B2B SaaS.
You get it by scoping the model the same way you scope any other: register a
db.Tenancy or db.ForceFilter for it on the DB step. The singleton then
resolves and provisions the caller’s row rather than a global one.
type StoreSite struct {
maniflex.BaseModel
OwnerID string `json:"owner_id" db:"owner_id" mfx:"filterable,unique,default:"`
Banner string `json:"banner" mfx:"default:untitled"`
}
server.MustRegister(StoreSite{}, maniflex.ModelConfig{Singleton: true})
server.Pipeline.DB.Register(
db.Tenancy("owner_id", func(ctx *maniflex.ServerContext) string {
return ctx.Auth.Claims["owner_id"].(string)
}),
maniflex.ForModel("StoreSite"),
)
GET /store_sites (as owner A) → 200 A's storefront, created on first access
GET /store_sites (as owner B) → 200 B's storefront — a different row
PATCH /store_sites (as owner A) → 200 updates A's, never B's
There is no separate SingletonScope setting, because a bare column name cannot
say where the value comes from — whether it is ctx.Auth.UserID, a tenant
claim, or something else. The scoping middleware already answers that, so the
singleton reads its scope from the request’s forced filters and there is exactly
one place to configure it.
The route shape is unchanged: still the bare table path, still no id, still no
POST/DELETE. Only which row it addresses changes.
A few consequences worth knowing:
- A scoped row keeps an ordinary generated primary key.
maniflex.SingletonIDnames the global row only; one fixed id could not name one row per scope. - Give the scope column a unique index (
mfx:"unique", above). Two concurrent first accesses would otherwise both provision a row; with the index, the loser collides and re-reads the winner’s. - A request with no scope gets the global row. If your resolver returns
nilfor an unauthenticated caller, that caller reads theSingletonIDrow. Scope-or-refuse is whatdb.Tenancydoes (it answers403when it cannot determine the tenant);db.ForceFilterapplies no filter instead. - The scope has to be one the framework can write, not merely read. It is
stamped onto the provisioned row, so it must be a plain equality — which is
what
db.Tenancyanddb.ForceFilterbuild. A scope that names no single value (aninfilter, or adb.ForceFilterViawhose value lives on another table) cannot provision a row and is refused rather than creating one its own author could not then read.
This replaces the previous workaround — Headless plus a hand-written action —
which cost 40–60 lines and, because actions skip the Validate
step, silently gave up every
mfx tag rule (required, enum, min/max, immutable) and the generated
OpenAPI schema along with them.
Upgrading: a singleton that already has a global row and then gains a scope does not migrate that row — it has no scope column value, so it matches nobody and each tenant is provisioned a fresh one. Backfill or drop it deliberately.
ModelConfig registration order
A ModelConfig is positioned immediately after the model it configures:
server.MustRegister(
User{},
Article{}, maniflex.ModelConfig{Versioned: true},
Comment{},
)
Here User and Comment use defaults; only Article is versioned.
Two argument shapes are registration errors, because there is no valid reading of either and both used to silently discard the config you wrote:
- A
ModelConfigat position 0 (no preceding model to attach to). - Two
ModelConfigs in a row (the second has no fresh model to bind to).
See Strict mode for the full set of configuration problems caught at startup.
Optional embeds
Beyond BaseModel, the framework provides embeds that add columns and switch on
behaviour when present:
| Embed | Adds | Effect |
|---|---|---|
maniflex.WithDeletedAt | deleted_at (nullable timestamp) | timestamp-based soft delete |
maniflex.WithIsDeleted | is_deleted (boolean) | flag-based soft delete |
Embedding one of these is equivalent to setting SoftDelete in ModelConfig.
The two approaches and their query semantics are covered in
Soft Delete.
type Article struct {
maniflex.BaseModel
maniflex.WithDeletedAt // DELETE marks deleted_at instead of removing the row
Title string `json:"title" mfx:"required"`
}
Registration order
Models must be registered before the database adapter is opened. The adapter is constructed from the registry — it reads the registered models to run migrations and resolve relations — so the registry must be complete first:
server.MustRegister(User{}, Article{}, Comment{}) // 1. populate the registry
db, err := sqlite.Open("./app.db", server.Registry()) // 2. build the adapter from it
server.SetDB(db) // 3. inject the adapter
Registering a model after SetDB has no effect on an already-open adapter.
Next
- Field Tags Reference — every
mfx:tag and its meaning. - Relations — foreign keys and slice fields.
- Soft Delete —
WithDeletedAt,WithIsDeleted, and query behaviour.