Field Tags Reference
A model’s behaviour is declared with three struct tags on each field. This page
documents all three, and every directive accepted by the mfx tag.
The three tags
| Tag | Controls | Default if omitted |
|---|---|---|
json | field name in request and response bodies | snake_case of the Go field name |
db | database column name | the resolved json name |
mfx | field behaviour — validation, querying, and more | no directives |
Title string `json:"title" db:"title" mfx:"required,filterable,sortable"`
The mfx tag holds a comma-separated list of directives. Whitespace around each
directive is trimmed. Directives are either flags (a bare word) or
key-value directives (key:value). An unrecognised directive is ignored.
Excluding a field
A field is dropped from the model entirely — no column, not in any payload — if
any of its three tags is set to -:
Internal string `mfx:"-"` // excluded
Cache string `json:"-"` // excluded
Scratch string `db:"-"` // excluded
Validation directives
These constrain the values a field accepts on write.
| Directive | Effect |
|---|---|
required | the field must be present in a create request |
enum:a|b|c | the value must be one of the pipe-separated options |
min:N | numeric minimum (N is a number) |
max:N | numeric maximum |
default:V | value applied when the field is absent; cast to the field’s type |
Status string `json:"status" mfx:"required,enum:draft|published|archived"`
Priority int `json:"priority" mfx:"min:1,max:5,default:3"`
Write-access directives
These govern whether a field can be set by a client, and when.
| Directive | Effect |
|---|---|
readonly | stripped from all write operations; values sent by a client are ignored |
immutable | accepted on create, rejected on update |
BaseModel’s created_at and updated_at are readonly. Use immutable for
values that are set once and must not change afterwards, such as an owner ID.
Email string `json:"email" mfx:"required,immutable"`
ApiKey string `json:"api_key" mfx:"readonly"`
Response-visibility directives
Both directives drop the field from API responses. They differ in whether the client may write the field.
| Directive | Read in responses | Write on create / update |
|---|---|---|
writeonly | no | yes |
hidden | no | no |
writeonlyis for values the client must supply but should never see again — typically passwords. The field is included in the create and update request schemas; only the response is scrubbed.hiddenis for values clients have no business touching at all — server-managed internals, audit fields, derived data. The field is dropped from create and update schemas as well, so it cannot be set from the API. It is still stored, and code running inside the pipeline (a middleware on the Service step, for example) can populate it.
// Client sets it on create, never sees it back.
Password string `json:"password" mfx:"required,writeonly"`
// Server-managed; client can neither read nor write it.
InternalScore float64 `json:"internal_score" mfx:"hidden"`
A field that is both readonly and not hidden is the opposite case: visible
in responses, never accepted from the client.
Record-locking directives
These freeze a whole record — not just a field — once its state matches a condition. Useful for terminal states in business workflows (posted invoices, closed pay periods, confirmed POs).
| Directive | Effect |
|---|---|
lock_when:field=value | when the existing record’s field equals value, updates and deletes return 422 RECORD_LOCKED |
Multiple lock_when directives accumulate; any matching condition locks
the record. The directive can be written on any field — the referenced
field is what matters. A typo in the referenced JSON name is caught at
registration so you never ship a rule that silently never matches.
type Invoice struct {
maniflex.BaseModel
Number string
Status string `mfx:"enum:draft|posted|void,lock_when:status=posted,lock_when:status=void"`
Amount int
}
The transition into a locked state is itself allowed — when the request arrives, the loaded record is still in its previous state. After that update commits, the record becomes frozen.
lock_when is checked before the default Validate step’s other rules on
update, and before the adapter’s Delete call on delete. Creates are
exempt: there is no prior state to check.
Pessimistic lock directive
| Directive | Effect |
|---|---|
lock_scope:ModelName | before a create, acquire a SELECT … FOR UPDATE lock on the row referenced by this field’s value |
Eliminates manual ctx.LockForUpdate calls in the most common case: a
create that must read-then-write a shared resource without a concurrent
write sneaking in between.
type Dispense struct {
maniflex.BaseModel
StockID string `json:"stock_id" db:"stock_id" mfx:"required,lock_scope:StockBalance"`
Quantity int `json:"quantity" db:"quantity" mfx:"required,min:1"`
}
Requirements:
- The model must run inside a transaction. Register
maniflex.WithTransaction(nil)on the Service step; otherwise the DB step aborts with500 LOCK_SCOPE_NO_TX. - The referenced model name must be registered. A typo is caught at startup
(in
Handler()), so it never reaches production silently. - If the referenced row does not exist, the create returns
404 NOT_FOUND.
server.Pipeline.Service.Register(
maniflex.WithTransaction(nil),
maniflex.ForModel("Dispense"),
maniflex.ForOperation(maniflex.OpCreate),
)
Comparison with ctx.LockForUpdate:
lock_scope tag | ctx.LockForUpdate | |
|---|---|---|
| Declaration | struct tag | custom Service middleware |
| Fields locked | one per tag directive | any ID at runtime |
| Requires transaction | yes (enforced at runtime) | yes (enforced at call time) |
| Use when | one fixed FK to lock | dynamic or multiple targets |
See Transactions for the underlying ctx.LockForUpdate
and BeginTx APIs.
Query directives
These opt a field into the query string. A field is not filterable or sortable unless explicitly tagged.
| Directive | Effect |
|---|---|
filterable | the field may be used in ?filter= |
sortable | the field may be used in ?sort= |
searchable | the field is indexed for native full-text search (?q=); text columns only |
cursor_field:<name> | opt the model into keyset (cursor) pagination; <name> is the sortable column to walk by |
See Querying for the filter, sort, and cursor-pagination grammar.
Schema directives
| Directive | Effect |
|---|---|
unique | a hint to the adapter to add a UNIQUE constraint on the column |
index | create a (non-unique) index on the column during AutoMigrate |
Slug string `json:"slug" mfx:"required,unique"`
Email string `json:"email" mfx:"index"`
index creates an index named idx_<table>_<column>. It is skipped when the
column is already covered by another index — a unique constraint on the same
field (databases index unique columns implicitly), a ModelConfig.Indices entry,
or a scheduled-column auto-index — so adding it is always safe. Indexing a
foreign-key column (e.g. mfx:"index" on UserID) is a common, valid use.
Relation directives
A field may declare a relationship to another model. The legacy ID-suffix
convention (a UserID field implies a relation to User) needs no tag; the
directives below configure explicit relations.
| Directive | Effect |
|---|---|
relation:Name | marks the field as an explicit relation; Name is the companion struct field carrying the target type |
relation:Name;onDelete:action | sets the referential action — cascade, setNull, or restrict |
through:Model | on a slice field, declares a many-to-many relation through the named junction model |
norelation | opt a convention-FK field (name ends in ID) out of the automatic relation; keep it a plain column |
onDelete sub-options are joined to the relation: directive with a semicolon,
not a comma. Relationships are covered in full in Relations.
By default a field whose name ends in ID (e.g. UserID) implies a BelongsTo
relation to the matching model (User). Use norelation when the column is just
an identifier — an external reference, an opaque token — that should not be
resolved as a relation:
ExternalID string `json:"external_id" mfx:"norelation"` // stays a scalar column
File upload directives
file marks a field as a file-upload field. The column stores the storage key;
multipart form-data is then accepted for create and update on the model.
| Directive | Effect |
|---|---|
file | mark the field as a file upload |
max_size:N | maximum file size; accepts KB, MB, GB suffixes, or plain bytes |
accept:p1|p2 | allowed MIME-type patterns, e.g. image/*|application/pdf |
auto_delete:false | keep the stored file when the record is hard-deleted or the field is replaced (default: delete it) |
file_acl:private | (default) response carries the raw storage key |
file_acl:signed | response replaces the key with a pre-signed URL (TTL: Config.FileSignedURLTTL, default 1h) |
file_acl:public | response replaces the key with a permanent / long-lived URL |
Avatar string `json:"avatar" mfx:"file,max_size:2MB,accept:image/*"`
Logo string `json:"logo" mfx:"file,file_acl:public,accept:image/*"`
See File Fields & Uploads for the upload workflow.
Encryption directives
| Directive | Effect |
|---|---|
encrypted | the field is encrypted at rest (AES-256-GCM) and decrypted on read |
key:name | the key name passed to the key provider; defaults to default |
Encrypted fields cannot be filtered or sorted, because the stored value is
ciphertext. If unique is also set, a companion {field}_hmac column enforces
uniqueness without exposing the plaintext.
SSN string `json:"ssn" mfx:"encrypted,key:patient-pii"`
Scheduled directives
The scheduled directive declares a time-driven transition on a timestamp
field — for example, soft-deleting a row once a timestamp passes. The directive
only marks the field; the transitions are applied by a background runner
documented in Events & Background Jobs.
It is an advanced feature with several sub-options joined by semicolons:
ExpiresAt time.Time `json:"expires_at" mfx:"scheduled;soft-delete"`
PublishAt time.Time `json:"publish_at" mfx:"scheduled;field=status;from=draft;to=published"`
| Sub-option | Effect |
|---|---|
soft-delete | soft-delete the row when the timestamp is reached |
hard-delete | permanently delete the row when the timestamp is reached |
field=F | the field to change |
from=V | apply only when field currently equals V |
to=V | the value to set field to |
Locale directives
These apply to fields declared as maniflex.LocaleString — a multilingual
string stored as a JSON object keyed by locale code (e.g. {"en":"Finance","ar":"مالية"}).
Mark a field as locale-aware with mfx:"locale". All other locale directives
require locale to be present.
| Directive | Effect |
|---|---|
locale | marks the field as a LocaleString; enables locale-aware response serialisation |
split | (default) response emits "name" = resolved string and "name_i18n" = full map |
resolve | response always emits "name" as a plain string; no companion field |
dynamic | response emits a string when ?locale= is set, the full map otherwise |
default_locale:code | field-level fallback locale (e.g. default_locale:ar) when the client did not request a specific locale |
type Department struct {
maniflex.BaseModel
Name maniflex.LocaleString `json:"name" mfx:"locale,filterable,sortable"`
Bio maniflex.LocaleString `json:"bio" mfx:"locale,resolve,default_locale:ar"`
}
The resolved locale for a request follows a precedence chain:
?locale= param → Accept-Language header → default_locale tag → model
DefaultLocale → app LocaleOptions.Default → "en".
See Localization for the full LocaleResolver setup and filtering/sorting behaviour.
Quick reference
| Directive | Category |
|---|---|
required | validation |
enum:… min: max: default: | validation |
readonly immutable | write access |
hidden writeonly | response visibility |
filterable sortable searchable cursor_field:… | querying |
unique index | schema |
relation:… through:… norelation | relations |
file max_size: accept: auto_delete:false file_acl: | file upload |
encrypted key:… | encryption |
scheduled;… | scheduled transitions |
locale split resolve dynamic default_locale: | localisation |
- | exclude the field |