maniflex
Annotated Go structs in. A full REST API out.
maniflex is a Go framework that turns a plain struct into a production REST API —
filtering, pagination, relations, soft-delete, file uploads, and an OpenAPI 3.1
spec — with no generated code and no per-endpoint boilerplate. Behaviour is
declared with mfx: struct tags and customised through a composable
six-step middleware pipeline.
type Post struct {
maniflex.BaseModel
maniflex.WithDeletedAt
Title string `json:"title" mfx:"required,filterable,sortable"`
Body string `json:"body" mfx:"required"`
Status string `json:"status" mfx:"required,filterable,enum:draft|published|archived"`
UserID string `json:"user_id" mfx:"required,filterable"`
}
Register that struct and you get GET/POST /posts, GET/PATCH/DELETE /posts/{id},
?filter=status:eq:published, ?sort=created_at:desc, ?page=2&limit=20,
?include=user, soft-delete semantics, and an entry in /openapi.json.
Why maniflex
- Reflection, not codegen. Register a struct at startup; routes, schema, and validation are derived from it at runtime. Nothing to regenerate when a field changes.
- One module, two dependencies. The core
maniflexmodule depends only on chi and uuid. Postgres, Redis, Kafka, NATS, bcrypt and friends live in satellite modules you pull in only if you import them. - A pipeline you can reach into. Every request flows through six ordered
steps — Auth → Deserialize → Validate → Service → DB → Response. Hook
middleware
Before,After, orReplaceat any step, scoped by model or operation. - Batteries included, swappable. Ready-made middleware for JWT auth, unique validation, password hashing, multi-tenancy, audit logging, CORS, and more — each one just a function you can replace.
- SQLite or Postgres. Both backends share one SQL adapter. Develop against pure-Go SQLite (no CGo, no external service), deploy on Postgres.
Core concepts
Five ideas carry the whole framework. Understand these and the rest of the docs slot into place.
Model
A Go struct that embeds maniflex.BaseModel and declares its fields with mfx:
struct tags. The struct is the single source of truth: it defines the database
table, the JSON request and response shapes, the validation rules, and which
fields are filterable or sortable. Optional embeds add behaviour — maniflex.WithDeletedAt
turns on soft-delete — and naming conventions like a UserID field declare
relations.
→ Models & BaseModel, Field Tags Reference,
Relations
Registry
The collection of every registered model, built by MustRegister at startup.
It is consumed in two places: the HTTP router reads it to mount routes, and the
DB adapter reads it to run migrations and resolve relations. This is why
MustRegister must run before sqlite.Open / postgres.Open — the adapter
is handed the populated registry.
→ Getting Started
Pipeline
Six ordered steps every request flows through: Auth → Deserialize → Validate → Service → DB → Response. The pipeline is the unit of customisation — instead of writing handlers, you attach middleware to the step where your logic belongs. → Pipeline Overview, ServerContext
Middleware
A func(ctx *maniflex.ServerContext, next func() error) error registered on a pipeline
step. Registration is scoped with maniflex.ForModel(...) and maniflex.ForOperation(...),
and positioned with maniflex.Before (default), maniflex.After, or maniflex.Replace. Set
ctx.Response and return without calling next() to short-circuit the request.
→ Writing Middleware, Middleware Catalogue
Adapter
The database backend implementing the storage interface. Two ship in-tree —
db/sqlite (pure-Go, no CGo) and db/postgres — and both share one SQL core.
Inject one with server.SetDB(db), which patches the pipeline’s DB step in
place.
→ Database Backends, Transactions
Where to go next
- Getting Started — install, define your first model, run the server.
- Models & Tags — the full
mfx:tag reference and relation conventions. - The Pipeline — how requests flow and where to hook in.
- Middleware Catalogue — ready-made middleware for every step.
- Querying the API — filtering, sorting, pagination, includes.
maniflex requires Go 1.25 or newer.
Getting Started
This guide takes you from an empty directory to a running REST API with a
filterable, paginated posts resource — in about five minutes and roughly
thirty lines of Go.
Prerequisites
- Go 1.25 or newer — check with
go version. - That’s it. The first example uses the pure-Go SQLite backend, so there is no database server to install and no CGo toolchain to configure.
1. Create the project
mkdir blog && cd blog
go mod init blog
go get github.com/xaleel/maniflex
maniflex itself pulls in only two dependencies — chi
and uuid. The SQLite adapter lives in its own
satellite module, so add it explicitly:
go get github.com/xaleel/maniflex/db/sqlite
2. Define a model
A model is a plain struct that embeds maniflex.BaseModel. The mfx: struct tags
declare how each field behaves — what’s required, what can be filtered, what
can be sorted.
Create main.go:
package main
import (
"log"
"github.com/xaleel/maniflex"
"github.com/xaleel/maniflex/db/sqlite"
)
type Post struct {
maniflex.BaseModel
Title string `json:"title" mfx:"required,filterable,sortable"`
Body string `json:"body" mfx:"required"`
Status string `json:"status" mfx:"required,filterable,enum:draft|published|archived"`
}
maniflex.BaseModel contributes the id, created_at, and updated_at fields, so
you never declare them yourself. See Field Tags Reference for every
tag and Models & BaseModel for the embeds.
3. Wire up the server
Registration order matters: models must be registered before the database is opened, because the SQLite adapter needs the registry to run migrations and resolve relations.
func main() {
// 1. Create the server — no DB yet.
server := maniflex.New(maniflex.Config{
Port: 8080,
PathPrefix: "/api",
AutoMigrate: true,
})
// 2. Register models — this populates the registry.
server.MustRegister(Post{})
// 3. Open SQLite with the populated registry.
db, err := sqlite.Open("./blog.db", server.Registry())
if err != nil {
log.Fatal(err)
}
defer db.Close()
// 4. Inject the adapter into the pipeline.
server.SetDB(db)
// 5. Serve.
log.Fatal(server.Start())
}
AutoMigrate: true creates and updates tables to match your structs on
startup — convenient for development. For an in-memory database that resets on
every run, pass ":memory:" to sqlite.Open.
4. Run it
go run .
The server is now listening on :8080, and Post{} has a full set of routes
mounted under the /api prefix:
| Method | Path | Action |
|---|---|---|
POST | /api/posts | create a post |
GET | /api/posts | list posts |
GET | /api/posts/{id} | read one post |
PATCH | /api/posts/{id} | update a post |
DELETE | /api/posts/{id} | delete a post |
5. Make some requests
Create a post:
curl -X POST localhost:8080/api/posts \
-H 'Content-Type: application/json' \
-d '{"title":"Hello","body":"First post","status":"published"}'
List, filter, sort, and paginate — all from the query string:
# Only published posts
curl 'localhost:8080/api/posts?filter=status:eq:published'
# Newest first, ten per page
curl 'localhost:8080/api/posts?sort=created_at:desc&page=1&limit=10'
Filtering and sorting only work on fields tagged filterable / sortable —
that’s why Title and Status carry those tags above. The full filter grammar
is in Querying.
What you get for free
From that one struct, maniflex derived:
- Five REST endpoints with JSON request/response handling.
- Field validation (
required,enum) on every write. - Query-string filtering, sorting, and pagination.
- A generated table kept in sync by
AutoMigrate. - An OpenAPI 3.1 entry at
/api/openapi.json.
No generated code, no per-endpoint handlers.
Where to go next
- Quickstart Tutorial — build a small app end to end.
- Models & BaseModel — relations, soft-delete, file fields.
- The Request Pipeline — the six steps every request flows through, and where to hook in your own middleware.
- Querying — the full filter, sort, and
includegrammar. - Database Backends — switching from SQLite to PostgreSQL.
App Anatomy
A maniflex app has very little machinery of its own — the framework derives routes, schema, and validation from your structs, so the code you write is mostly models and middleware. This page shows how to lay that code out, starting from a single file and growing into packages as the app gets bigger.
The smallest app
A maniflex app is just a package main that does four things in order:
package main
import (
"log"
"github.com/xaleel/maniflex"
"github.com/xaleel/maniflex/db/sqlite"
)
type Message struct {
maniflex.BaseModel
Title string `json:"title" mfx:"required,filterable,sortable"`
}
func main() {
server := maniflex.New(maniflex.Config{Port: 8080, PathPrefix: "/api", AutoMigrate: true})
server.MustRegister(Message{})
if db, err := sqlite.Open("./app.db", server.Registry()); err == nil {
defer db.Close()
server.SetDB(db)
} else {
log.Fatal(err)
}
log.Fatal(server.Start())
}
Four steps — create server → register models → open and set DB → serve.
Everything below is about where the code for each step lives as the app grows.
The ordering is load-bearing: MustRegister must run before sqlite.Open,
because the adapter is handed the populated registry. See
Getting Started.
A typical project layout
Once an app has more than a handful of models, split it into packages by responsibility, not by model. A layout that scales well:
myapp/
├── go.mod
├── main.go # wiring only: create, register, set DB, serve
├── config.go # maniflex.Config assembly, env-var reading
├── models/ # one file per model — the structs and their tags
│ ├── user.go
│ ├── post.go
│ └── comment.go
├── middleware/ # custom pipeline middleware
│ ├── auth.go
│ ├── audit.go
│ └── register.go # attaches all middleware to the pipeline
└── internal/ # non-framework code: services, clients, helpers
└── mailer/
└── ...
Nothing here is enforced by the framework — maniflex never scans directories. It is a convention that keeps each file answering one question.
What goes in each file
main.go — wiring, nothing else
main.go should read top-to-bottom as the four-step sequence and contain no
business logic. Its whole job is to assemble the pieces and call Start():
func main() {
server := maniflex.New(config.Load())
server.MustRegister(
models.User{},
models.Post{},
models.Comment{},
)
db, err := sqlite.Open("./app.db", server.Registry())
if err != nil {
log.Fatal(err)
}
defer db.Close()
server.SetDB(db)
middleware.Register(server) // all pipeline hooks, in one place
log.Fatal(server.Start())
}
If you can’t see the four steps at a glance, something belongs in another file.
config.go — building maniflex.Config
Keep maniflex.Config construction — and any environment-variable reading — out of
main.go. A single Load() function makes the app’s knobs easy to find:
package config
func Load() maniflex.Config {
return maniflex.Config{
Port: envInt("PORT", 8080),
PathPrefix: "/api",
AutoMigrate: env("APP_ENV", "dev") != "production",
}
}
See Configuration for every maniflex.Config field.
models/ — one file per model
Each file holds one struct, its mfx: tags, and the relation fields that point
at other models. This is the heart of the app — the struct is the table, the
JSON shape, and the validation rules all at once.
// models/post.go
package models
type Post struct {
maniflex.BaseModel
maniflex.WithDeletedAt // opt-in soft-delete
Title string `json:"title" mfx:"required,filterable,sortable"`
Body string `json:"body" mfx:"required"`
Status string `json:"status" mfx:"required,filterable,enum:draft|published|archived"`
UserID string `json:"user_id" mfx:"required,filterable"` // BelongsTo User
Comments []Comment `json:"comments,omitempty"` // HasMany
}
Put model-spanning relations in whichever file is the “owning” side and let Go’s
package scope resolve the rest — all models share the models package, so
Post can reference Comment freely. See Models & BaseModel,
Field Tags Reference, and Relations.
middleware/ — your pipeline hooks
A middleware is a func(ctx *maniflex.ServerContext, next func() error) error. Group
related hooks per file (auth.go, audit.go), and keep one register.go that
attaches them all — so there is exactly one place to see how the request
pipeline has been customised:
// middleware/register.go
package middleware
func Register(s *maniflex.Server) {
s.Pipeline.Auth.Register(bearerToken,
maniflex.ForOperation(maniflex.OpCreate, maniflex.OpUpdate, maniflex.OpDelete))
s.Pipeline.Service.Register(hashPassword,
maniflex.ForModel("User"),
maniflex.ForOperation(maniflex.OpCreate, maniflex.OpUpdate))
s.Pipeline.DB.Register(auditLog,
maniflex.ForOperation(maniflex.OpCreate, maniflex.OpUpdate, maniflex.OpDelete),
maniflex.AtPosition(maniflex.After))
}
The middleware functions themselves live in their topic files. See Writing Middleware and the Middleware Catalogue for hooks that already ship with the framework.
internal/ — everything that isn’t maniflex
Code that has nothing to do with the framework — a mail client, a payment SDK
wrapper, domain calculations — goes under internal/. Middleware in the
Service step calls into these packages; the packages themselves never import
maniflex. This keeps the framework-facing layer thin and your business logic
unit-testable on its own.
Structuring a large monolith
The layer-based layout above (models/, middleware/) stays readable up to a
few dozen models. Past that, a flat models/ directory with sixty files and a
main.go that names every one of them becomes the bottleneck. Large maniflex
codebases switch from splitting by layer to splitting by domain.
Domain packages
Give each business domain its own package that owns all of its code — models, middleware, and business logic together:
myapp/
├── main.go
├── domains/
│ ├── auth/
│ │ ├── models.go # User, Role, Session, ApiKey
│ │ ├── middleware.go # token auth, password hashing
│ │ └── register.go # exports Models and Register(s)
│ ├── catalog/
│ │ ├── models.go # Product, Category, Variant
│ │ ├── middleware.go
│ │ └── register.go
│ └── orders/
│ ├── models.go # Order, LineItem, Invoice, Refund
│ ├── middleware.go
│ └── register.go
└── internal/
A new feature now touches one directory instead of being smeared across
models/, middleware/, and internal/.
Registering models in groups
server.Register (and MustRegister) is variadic and flattens any slice
argument — so each domain can export its models as a single slice, and
main.go registers the slices side by side:
// domains/auth/register.go
package auth
// Models is every model this domain owns. One list, one place to update.
var Models = []any{
User{},
Role{},
Session{},
ApiKey{},
}
// main.go
server.MustRegister(
auth.Models, // each argument is a []any —
catalog.Models, // Register flattens them into individual models
orders.Models,
)
main.go no longer grows when a domain gains a model; only that domain’s
Models slice changes. To register everything as one list instead, concatenate
the slices: append(append(auth.Models, catalog.Models...), orders.Models...).
Registering middleware in groups
Apply the same idea to the pipeline. Each domain exposes its own
Register(s *maniflex.Server), and the top-level middleware registration just calls
each one — exactly the shape in the request from a growing app:
// domains/orders/register.go
package orders
// Register attaches every pipeline hook this domain needs.
func Register(s *maniflex.Server) {
s.Pipeline.Validate.Register(checkStock, maniflex.ForModel("Order"))
s.Pipeline.Service.Register(chargePayment,
maniflex.ForModel("Order"), maniflex.ForOperation(maniflex.OpCreate))
s.Pipeline.DB.Register(emitOrderEvent,
maniflex.ForModel("Order"), maniflex.AtPosition(maniflex.After))
}
// main.go (or a thin top-level middleware/register.go)
func registerMiddleware(s *maniflex.Server) {
auth.Register(s)
catalog.Register(s)
orders.Register(s)
}
Each domain controls its own pipeline hooks; the top-level function is just a table of contents.
Co-locating per-model middleware
For middleware that belongs to exactly one model, skip the separate Register
call entirely — ModelConfig.Middleware lets you attach hooks at registration
time, scoped to that model automatically:
server.MustRegister(
Order{}, maniflex.ModelConfig{
Middleware: &maniflex.ModelMiddleware{
Validate: []maniflex.MiddlewareFunc{checkStock},
Service: []maniflex.MiddlewareFunc{chargePayment},
},
},
)
This keeps a model and its rules in one declaration — useful when the hook is meaningless without the model.
Conventions that keep a big monolith honest
- One registration point per concern. Exactly one place lists the model groups, and one lists the middleware groups. If you can’t find where a model is registered, the structure has drifted.
- Domains depend inward, never sideways. A domain may import
internal/andmaniflex; it should not import a sibling domain. Cross-domain relations are expressed by FK fields (a stringUserID), which need no import. internal/holds framework-free logic. Payment, mail, and pricing code lives here and never importsmaniflex, so it stays unit-testable in isolation.main.gostays a fixed size. Adding a domain adds one line to each registration list and nothing else. Ifmain.gogrows with the app, logic has leaked into it.
How a request moves through the files
Tracing one POST /api/posts shows why the layout is split this way:
- The router (built from the registry in
main.go) matches the route. - The request enters the pipeline —
Auth → Deserialize → Validate → Service → DB → Response. - At each step, the hooks from
middleware/register.gorun, scoped by model and operation. Validatechecks themfx:tags declared inmodels/post.go.Servicemiddleware may call intointernal/for business logic.- The adapter injected via
SetDBruns the SQL at theDBstep.
Each file owns one stage of that journey — which is exactly why a growing app stays readable.
Where to go next
- Example 1: Simple Blog — this layout filled in for a real three-model app.
- The Request Pipeline — the six steps in depth.
- Configuration — every
maniflex.Configfield.
Example 1: Simple Blog
This is the first worked example: a small blog API built end to end. It uses
only what the previous pages covered — models and mfx: tags,
the four-step setup, and SQLite. No relations, no middleware, no pipeline
customisation yet; those arrive in later chapters. The goal is to see a complete,
runnable app with nothing unexplained in it.
What we’re building
A blog with two independent resources:
- Post — an article with a title, body, and a publication status.
- Subscriber — an email address signed up for the newsletter.
The two are unrelated — each is its own table with its own endpoints — which keeps the example to concepts already introduced.
The whole app
The blog is small enough to live in a single main.go, the
smallest-app shape from App Anatomy:
package main
import (
"log"
"github.com/xaleel/maniflex"
"github.com/xaleel/maniflex/db/sqlite"
)
// Post is a blog article.
type Post struct {
maniflex.BaseModel
Title string `json:"title" mfx:"required,filterable,sortable"`
Body string `json:"body" mfx:"required"`
Status string `json:"status" mfx:"required,filterable,sortable,enum:draft|published|archived"`
}
// Subscriber is a newsletter sign-up.
type Subscriber struct {
maniflex.BaseModel
Email string `json:"email" mfx:"required,filterable"`
Name string `json:"name" mfx:"filterable,sortable"`
}
func main() {
// 1. Create the server.
server := maniflex.New(maniflex.Config{
Port: 8080,
PathPrefix: "/api",
AutoMigrate: true,
})
// 2. Register both models — populates the registry.
server.MustRegister(Post{}, Subscriber{})
// 3. Open SQLite with the populated registry, then inject it.
db, err := sqlite.Open("./blog.db", server.Registry())
if err != nil {
log.Fatal(err)
}
defer db.Close()
server.SetDB(db)
// 4. Serve.
log.Fatal(server.Start())
}
That is the entire blog. Run it:
go run .
Reading the models
Every field choice maps to a tag covered in Getting Started:
| Field | Tags | Effect |
|---|---|---|
Title | required,filterable,sortable | must be present; usable in ?filter= and ?sort= |
Body | required | must be present; not queryable |
Status | required,...,enum:draft|published|archived | rejected unless one of the three values |
Email | required,filterable | must be present; filterable but not sortable |
Name | filterable,sortable | optional; queryable and sortable |
maniflex.BaseModel adds id, created_at, and updated_at to both structs, so
those are never declared by hand. With AutoMigrate: true, the posts and
subscribers tables are created to match on startup.
The endpoints you get
Registering the two structs mounts a full REST surface under /api:
| Method | Path | |
|---|---|---|
POST | /api/posts | create a post |
GET | /api/posts | list posts |
GET | /api/posts/{id} | read one post |
PATCH | /api/posts/{id} | update a post |
DELETE | /api/posts/{id} | delete a post |
Subscriber gets the identical five routes under /api/subscribers.
Using the API
Create a post
curl -X POST localhost:8080/api/posts \
-H 'Content-Type: application/json' \
-d '{"title":"Hello World","body":"My first post","status":"draft"}'
The response echoes the stored row, including the id and timestamps that
BaseModel filled in.
Validation in action
Leave out a required field, or send a status outside the enum, and the
write is rejected before it reaches the database:
# Missing body, and status is not in the enum
curl -X POST localhost:8080/api/posts \
-H 'Content-Type: application/json' \
-d '{"title":"Broken","status":"weekly"}'
# → 400, the response names the offending fields
Update and delete
# Publish the post — PATCH only sends the fields that change
curl -X PATCH localhost:8080/api/posts/<id> \
-H 'Content-Type: application/json' \
-d '{"status":"published"}'
curl -X DELETE localhost:8080/api/posts/<id>
List, filter, sort, paginate
All from the query string, on the fields tagged filterable / sortable:
# Only published posts
curl 'localhost:8080/api/posts?filter=status:eq:published'
# Newest first
curl 'localhost:8080/api/posts?sort=created_at:desc'
# Page two, five per page
curl 'localhost:8080/api/posts?page=2&limit=5'
# Combine them
curl 'localhost:8080/api/posts?filter=status:eq:published&sort=title:asc&limit=5'
Add a couple of subscribers and the same querying works there too:
curl -X POST localhost:8080/api/subscribers \
-H 'Content-Type: application/json' \
-d '{"email":"ada@example.com","name":"Ada"}'
curl 'localhost:8080/api/subscribers?sort=name:asc'
The API documents itself
You never wrote a schema, yet the server already publishes one. Alongside the
model routes, maniflex auto-generates an OpenAPI 3.1 specification describing
every endpoint, field, and validation rule — derived from the same structs and
mfx: tags:
curl localhost:8080/api/openapi.json
The spec updates itself whenever a model changes; there is nothing to
regenerate. To browse it as interactive documentation, the framework ships an
HTML viewer in static/openapi.html that loads /api/openapi.json — open
http://localhost:8080/static/openapi.html while the server is running.
The OpenAPI step is fully customisable later; see OpenAPI Spec.
What this example showed
- A complete, runnable app from two plain structs and a four-step
main. mfx:tags driving validation (required,enum) and queryability (filterable,sortable).- Multiple models registered in one call, each with an independent REST surface.
- Filtering, sorting, and pagination with no query code written.
- A self-updating OpenAPI 3.1 spec at
/api/openapi.json.
Where to go next
Everything here treated the two models as separate islands. Real apps connect
them — a post belongs to an author, a comment belongs to a post. That, and
the mfx: tags beyond the basics, are the next chapters:
- Models & BaseModel — the embeds and what they contribute.
- Field Tags Reference — every
mfx:tag. - Relations — connecting models with foreign keys.
Architecture
This page explains how maniflex is put together. The reference pages describe each piece in isolation; here we look at how they fit. A reader who finishes this page should be able to point at any other doc and predict roughly what it covers.
The five pieces
┌──────────────────────┐
register → │ Registry │ ← models discovered here
└──────────────────────┘
│ │
│ ▼
│ ┌──────────────────────┐
│ │ Database adapter │
│ │ (sqlite | postgres) │
│ └──────────────────────┘
▼
┌──────────────────────┐
│ Router │ ← chi v5
└──────────────────────┘
│
▼
HTTP request → ┌──────────────────────────────────────────────────┐
│ Pipeline: │
│ Auth → Deserialize → Validate → │
│ Service → DB → Response │
└──────────────────────────────────────────────────┘
│
▼
HTTP response ← APIResponse envelope
The framework has five primary moving parts:
| Piece | What it is | Where it lives |
|---|---|---|
| Registry | An in-memory map of every registered model’s *ModelMeta — fields, tags, relations, indices, scheduled specs. | built by MustRegister, consumed by the router and the adapter |
| Router | A chi v5 Router mounted with one sub-router per registered model. | router.go |
| Pipeline | Six ordered steps that every model-route request flows through, plus a parallel three-step pipeline for /openapi.json. | pipeline.go |
| ServerContext | The single per-request struct threaded through every step. | context.go |
| DBAdapter | The backend interface implemented by db/sqlite, db/postgres, and any custom backend. | db.go |
Every other concept in the docs sits on top of these five.
Reflection, not codegen
The framework derives everything from the registered structs at startup:
ScanModelwalks the struct withreflectonce per model, builds a*ModelMeta, and inserts it into the registry.- The adapter reads the registry to emit
CREATE TABLE/ALTER TABLEstatements duringAutoMigrate. - The router reads the registry to mount the five REST routes per model
plus
/openapi.json. - The Validate step reads each request’s model meta to enforce
mfx:tag rules. - The DB step reads each request’s model meta to assemble the SQL.
Reflection runs once at registration, never per request. The per-request
path is allocation-light: a map[string]any for the body, a few string keys,
and the slice of registered middleware filtered by ForModel /
ForOperation. A model with N fields produces O(N) work at boot and O(N)
work per request, both linear in the size of the model.
This is the architectural difference from codegen frameworks: there is no
generated file to keep in sync. Changing a mfx: tag changes the runtime
behaviour the next time the process starts.
The registry is the contract
Every other piece reads from the registry; nothing writes to it after
Start(). That single rule explains several constraints:
MustRegistermust run beforesqlite.Open/postgres.Open. The adapter reads the registry during its constructor to learn about tables and relations.- Models cannot be added or removed at runtime. New models require a process restart.
- Models can be inspected from middleware via
ctx.Model, which is the*ModelMetathe router selected for this request. - Cross-model operations work because middleware reaches into the
registry through
ctx.GetModel(name)or by name in scoped registration.
The framework’s startup sequence is deliberate:
1. maniflex.New(cfg) → empty registry
2. server.MustRegister(...) → populated registry
3. sqlite.Open(..., reg) → adapter built from registry
4. server.SetDB(db) → adapter wired into the DB step
5. middleware.Register(...) → pipeline customised
6. server.Start() → router built from registry, listener opens
Start() runs AutoMigrate (if enabled) before opening the listener, so a
schema mismatch fails fast instead of corrupting writes.
The pipeline is the unit of customisation
Every HTTP request to a model route is wrapped in a ServerContext and run
through six steps in this order:
| Step | Default behaviour |
|---|---|
| Auth | passthrough |
| Deserialize | parse query string + body |
| Validate | enforce mfx: tag rules |
| Service | passthrough — business logic goes here |
| DB | dispatch to the adapter |
| Response | write the JSON envelope |
Each step has its own StepRegistry on server.Pipeline. A registration
attaches a MiddlewareFunc at Before (the default), After, or Replace
position, scoped by ForModel / ForOperation. At request time the
registry returns the matching chain for the (model, operation) pair, the
chain runs, and any step can short-circuit by setting ctx.Response and
returning without calling next().
The pipeline is the answer to “where do I put X?”:
- Identity checks — Auth.
- Coerce types, strip unknown fields — Validate (Before).
- Hash passwords, set derived fields — Service.
- Bracket the DB call — DB (Before / After).
- Webhooks, events, audit log — DB (After).
- Headers, redactions, metrics — Response.
The same answer applies whether the code lives in a catalogue middleware,
a custom function, or a per-model ModelConfig.Middleware.
The adapter is one interface
maniflex.DBAdapter has fewer than a dozen methods — FindByID, FindMany,
Create, Update, Delete, BeginTx, Raw, Ping, plus the schema
operations called by AutoMigrate. Two implementations ship:
db/sqlite— pure-Go SQLite for development.db/postgres—lib/pqfor production.
Both implementations share db/sqlcore, a SQL adapter that knows about
filters, sorts, includes, soft delete, and relations. A custom backend — an
HTTP data service, a different SQL database — implements the same interface
and is injected with server.SetDB(myAdapter). No other code changes.
The same holds for FileStorage and KeyProvider: small interfaces with
shipped implementations and obvious extension points.
Satellite modules
maniflex is a multi-module repository. The core module imports only chi and
uuid. Everything heavier — a database driver, a JWT library, a Kafka client,
bcrypt — lives in its own satellite under the same root, so a consumer
pulls in only the dependencies it actually imports.
The split keeps the core small and stable. It also keeps the trust boundary
clear: the framework’s surface area is the maniflex package; everything in
middleware/*, events/*, jobs/*, db/* is application code that
happens to ship alongside the framework.
See Satellite Modules for the full layout and import rules.
Two pipelines, one router
The router actually mounts two pipelines.
The first one — the six-step pipeline described above — handles
/<table> and /<table>/{id} for every registered model.
The second is a three-step pipeline for GET /openapi.json:
OpenAPI.Auth → OpenAPI.Generate → OpenAPI.Response
Generate derives the spec from the registry every time the endpoint is
hit, then Response serialises it. After-position middleware on Generate
can mutate the spec — change titles, add servers, install security schemes,
or rewrite arbitrary fields. See OpenAPI Spec and the
OpenAPI Middleware catalogue.
A third, trimmed pipeline runs for custom actions:
Auth → [per-action middleware] → handler → Response
The Deserialize, Validate, Service, and DB steps are skipped — actions own their body parsing and database work.
Where each feature lives in the lifecycle
| Feature | Step(s) | Notes |
|---|---|---|
mfx: tag rules (required, enum, min, …) | Validate | per-field |
| Required-on-create, immutable-on-update, readonly-strip | Validate | tied to Operation |
mfx:"file" multipart parsing | Deserialize | populates ctx.Files |
| File storage write | Service (built-in) | writes the storage key |
| Soft-delete filter on reads | DB | adapter rewrites the SQL |
mfx:"encrypted" envelope + HMAC | DB (Before for writes, after for reads) | needs KeyProvider |
Versioned history row | DB (Before for pre-image, After for write) | sibling _history table |
mfx:"scheduled" sweep | outside the request — separate runner | see Scheduled Fields |
?filter=…&sort=…&include=… parsing | Deserialize | into ctx.Query |
?include= population | DB | secondary queries after the main SELECT |
Auto-tenant filter (db.Tenancy) | DB (Before) | appends to ctx.Query.Filters |
| Audit log | DB (Before) | needs the pre-image; writes outside the request |
maniflex.WithTransaction | Service (Before) or DB (Replace) | wraps the DB step |
LockForUpdate | inside the DB step’s transaction | SELECT ... FOR UPDATE on Postgres |
For a single concrete trace of every step running on one request, see the Request Lifecycle walkthrough.
What maniflex is not
To set expectations on the architecture choice:
- Not codegen. No generated files, no separate build step.
- Not a router framework. The HTTP layer is chi; maniflex uses it.
- Not opinionated about JSON shape. The envelope is the default, but
response.Envelopelets you replace it. Errors always use the error envelope. - Not a service mesh. One process, one binary. Multi-process concerns (events, jobs, distributed locks) live in the satellite modules.
- Not magic. Every behaviour is a function in the
maniflexpackage or one of the catalogue middlewares. Read the source when in doubt; the pipeline is small.
Next
- Request Lifecycle — a single
POST /api/orderstraced end-to-end through every step. - Glossary — every framework term in one place.
- Pipeline Overview — the per-step reference.
Request Lifecycle
This page traces a single request through every piece of the framework. The
example is a POST /api/orders on an authenticated user, with a Service
middleware that hashes a derived field, a transaction wrapping the DB step,
and an audit-log middleware on DB-After. It exercises the full pipeline
without being contrived.
Setup
server.MustRegister(models.Order{})
server.Pipeline.Auth.Register(auth.JWTAuth("secret"))
server.Pipeline.Service.Register(
maniflex.WithTransaction(nil),
maniflex.ForOperation(maniflex.OpCreate, maniflex.OpUpdate, maniflex.OpDelete),
)
server.Pipeline.Service.Register(
service.SetField("customer_id", func(ctx *maniflex.ServerContext) any {
return ctx.Auth.UserID
}),
maniflex.ForModel("Order"), maniflex.ForOperation(maniflex.OpCreate),
)
server.Pipeline.DB.Register(
db.AuditLog(sink, db.WithChanges()),
maniflex.ForModel("Order"),
maniflex.ForOperation(maniflex.OpCreate, maniflex.OpUpdate, maniflex.OpDelete),
)
server.Pipeline.Response.Register(response.CORSHeaders())
The request
POST /api/orders HTTP/1.1
Authorization: Bearer eyJ...
Content-Type: application/json
Idempotency-Key: 2af9...
{"total": 42.50, "status": "pending"}
What happens, in order
1. The router selects the route
chi matches POST /api/orders to the sub-router mounted by mountModel
for the Order model. The matched handler calls handler.Create(meta),
which:
- Allocates a fresh
*ServerContext. - Sets
ctx.Request,ctx.Writer,ctx.Ctx. - Reads the
X-Request-Idchi added in the outer middleware and stores it onctx.RequestID. - Reads the
traceparentheader (if present) intoctx.TraceID. - Sets
ctx.Model = metaforOrder. - Sets
ctx.Operation = OpCreate. - Leaves
ctx.ResourceIDempty — create has no path id. - Calls
Pipeline.execute(ctx).
If Config.QueryTimeout is non-zero, ctx.Ctx is wrapped in a
context.WithTimeout here, so every downstream DB call inherits the
deadline.
2. The Auth step runs
Pipeline.Auth.build("Order", OpCreate) returns a chain consisting of
matching middleware in registration order, then the default Auth handler at
the end (which is a passthrough).
For our setup the chain is just [auth.JWTAuth, defaultAuth]. JWTAuth:
- Reads the
Authorizationheader. - Verifies the signature with the configured secret.
- Parses the claims and populates
ctx.Auth = &AuthInfo{UserID: …, Roles: …, TenantID: …}. - Calls
next()to continue.
A missing or invalid token would have produced ctx.Abort(401, "UNAUTHORIZED", …)
and returned without next(), ending the request right here.
3. The Deserialize step runs
The default handler parses two things:
- Query parameters →
ctx.Query(a*QueryParams). For a create, this is mostly empty — there are nofilterorsortto read. - Body. The
Content-Typeisapplication/json, so it reads up to 4 MB fromctx.Request.Body, setsctx.RawBodyto the raw bytes, and parses the JSON into the read-onlyctx.ParsedBody(a*RequestBody):
// ctx.ParsedBody now holds { "total": 42.5, "status": "pending" }
total, _ := ctx.Field("total") // 42.5
status, _ := ctx.Field("status") // "pending"
The same values are bound to the typed record ctx.Record; middleware mutate
either through ctx.SetField / ctx.DeleteField.
If the Content-Type had been multipart/form-data, the default handler
would route through parseMultipart instead, populating ctx.ParsedBody
with form fields and ctx.Files with the file parts.
After-position middleware on Deserialize (e.g. idempotency.Middleware,
which sees ctx.RawBody) runs next. With idempotency configured, the
middleware would compute a body hash and either replay a cached response or
fall through to step 4.
4. The Validate step runs
The default handler iterates ctx.Model.Fields and applies the mfx: tag
rules to ctx.ParsedBody:
idis stripped — the adapter assigns it.readonlyfields (created_at,updated_at) are stripped.immutablefields are stripped ifOpUpdate— not on create.requiredfields must be present.totalandstatusare required; the request supplies both, so no error.enummembership is checked onstatus—"pending"is in the allowed set, so no error.min/maxare checked on numeric fields when present.
If any rule had failed, the step would have called ctx.Abort(422, "VALIDATION_FAILED", …)
with details: [...] listing the bad fields.
Custom Validate middleware (none in this example) would run alongside the
default — Before middleware first, then the default, then After.
5. The Service step runs
Two middleware are scoped to this request:
maniflex.WithTransaction(nil) runs first:
- Sees
ctx.Tx == nil. - Calls
ctx.BeginTx(ctx.Ctx, nil), which delegates to the adapter. - Assigns the resulting
Txtoctx.Txand re-wrapsctx.Ctxwith the tx stored undertxContextKey{}. - Defers
tx.Rollback()— a no-op afterCommit. - Calls
next()to run the rest of the pipeline inside the transaction.
service.SetField("customer_id", ...) runs second:
- Resolves the callback against
ctx.Auth.UserID. - Calls
ctx.SetField("customer_id", "user-alice"), writing through to bothctx.ParsedBodyand the typedctx.Record. - Calls
next().
6. The DB step runs
The default DB step calls defaultSteps.db:
- Sees
ctx.Tx != niland constructs adbExec{adapter, tx: ctx.Tx}. - Builds the DB-column write set from the typed
ctx.Record(falling back totoDBMap(ctx.ParsedBody)for bodies the record can’t represent); here the column names match the JSON keys. - If the model had
mfx:"encrypted"fields, callsencryptFieldsto replace plaintexts withenc:<base64>envelopes and write{field}_hmaccompanions for unique ones. - Dispatches by
ctx.Operation:
result, err := exec.Create(ctx.Ctx, model, dbData)
exec.Create on a transactional dbExec calls tx.Create(...), which
runs INSERT INTO orders (...) RETURNING * (Postgres) or
INSERT ... ; SELECT ... (SQLite).
The adapter returns the inserted row as a map[string]any. The DB step
assigns it to ctx.DBResult.
If the adapter returned maniflex.ErrNotFound, the step would abort with
404 NOT_FOUND. *maniflex.ErrConstraint becomes 409 CONFLICT. A
context-cancelled error becomes 504 TIMEOUT. Any other adapter error
becomes 500 DATABASE_ERROR.
6a. Audit-log Before middleware
We registered db.AuditLog at the default Before position (because
WithChanges() needs to read the pre-image). On OpCreate there is no
pre-image, so the middleware merely sets up to collect the post-image. It
calls next(), which runs the rest of the chain — the default DB handler
above.
After next() returns and ctx.Response is still nil (the create
succeeded), the middleware:
- Reads
ctx.DBResultfor the inserted row. - Builds an
AuditRecordwith model, operation, actor (ctx.Auth.UserID), tenant, request id, trace id, and a diff of every changed field. - Spawns a goroutine that calls
sink.Write(bgCtx, record). Audit writes are fire-and-forget — a sink error never fails the request.
7. WithTransaction commits
Control returns to WithTransaction (because we are inside its next()
call). It checks:
next()returned nil → no pipeline error.ctx.Response == nilor< 400→ no aborted step.- Calls
tx.Commit(). The deferredRollbackis now a no-op. - Clears
ctx.Txso any post-commit code uses the bare adapter.
If next() had returned an error, or if any step had set ctx.Response
to a status >= 400, Commit would have been skipped and the deferred
Rollback would have fired.
8. The Response step runs
The default Response handler:
- Sees
ctx.Response == nil. - Sees
ctx.Operation == OpCreate. - Builds:
ctx.Response = &APIResponse{
StatusCode: http.StatusCreated,
Data: toJSONMap(ctx.DBResult.(map[string]any), model),
}
toJSONMap converts DB column names back to JSON field names and applies
hidden and writeonly filtering — any column tagged those is dropped
from the response shape.
After-position middleware on Response runs next. response.CORSHeaders
adds the appropriate Access-Control-* headers via ctx.Writer.Header().
9. The envelope is written to the wire
APIResponse.Write(ctx.Writer):
- Sets
Content-Type: application/json. - Writes the status code header (
201 Created). - Encodes
{"data": {...}}to the response body. - Returns.
chi’s RealIP and RequestID middleware (registered at the router root,
outside the maniflex pipeline) wrap the whole exchange — they have already
set X-Request-Id on the response by the time we get here.
The dispatch cleanup
After the response is written, the handler runs its cleanup phase:
- Closes any open multipart file readers in
ctx.Files. - Removes the multipart temporary directory.
- Lets the
*ServerContextgo out of scope; it is garbage-collected with the request.
The framework does not pool or reuse ServerContext values. The per-request
allocation is small; the simplicity is worth more than the saved
allocations.
What changes for other operations
Different operations exercise slightly different paths:
OpRead/OpList— Validate runs but is a no-op (it only fires for create/update). The DB step callsFindByIDorFindMany. Response for List wraps withmeta: {total, page, limit, pages}.OpUpdate— Like create, butctx.ResourceIDis set,immutablefields are stripped in Validate, and the DB step callsUpdate. The audit middleware fetches the pre-image beforenext()so theChangesdiff has both sides.OpDelete— No body to deserialize, no Validate work. The DB step callsDelete(which becomes a soft-deleteUPDATEfor models withWithDeletedAt). The default Response is204 No Content.OpAction— The trimmed pipeline runsAuth → action middleware → handler → Response. The handler is responsible for its own body parsing, validation, and database calls./openapi.json— A separate three-step pipeline (OpenAPI.Auth → Generate → Response) builds the spec from the registry every time, then writes the JSON document.
What happens on errors
Three error paths at every step:
| Trigger | Effect |
|---|---|
Middleware returns a non-nil error from next() | Bubbles up; later steps are skipped. The chain returns the error to the handler, which logs and writes a 500 INTERNAL envelope. |
Middleware calls ctx.Abort(...) and returns nil without next() | ctx.Response is set; subsequent steps are skipped (because next() was never called); the Response step’s default reads ctx.Response and writes it. |
| Panic anywhere in the chain | PanicRecoverer catches it, logs through Config.PanicLogger, writes a 500 PANIC envelope. |
A transaction in flight is rolled back by WithTransaction’s deferred
Rollback in all three cases — the same code path that handles success.
What this tour did not show
- Multi-tenant scoping with
db.Tenancy— would have appended toctx.Query.Filtersin step 5/6. mfx:"file"uploads — would have parsed multipart in step 3 and written bytes toFileStoragebetween steps 5 and 6.mfx:"scheduled"— fires outside the request, in a separate Scheduled Runner goroutine.mfx:"versioned"— would have written a sibling history row in step 6’s After phase, in the same transaction.- The OpenAPI pipeline — same shape, three steps, separate registrations.
Each of those is covered in its own page; the lifecycle in step-by-step form is the same.
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.
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.
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 |
To use a different name, pass a ModelConfig with TableName set when
registering:
server.MustRegister(
Article{}, maniflex.ModelConfig{TableName: "articles"},
)
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 (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) — see Singleton models |
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
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.
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 detected and logged as a warning (they’re foot-guns, not errors yet — strict mode will promote them to a panic):
- A
ModelConfigat position 0 (no preceding model to attach to). - Two
ModelConfigs in a row (only the first applies to the model; the second has no fresh model to bind to and is dropped).
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.
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 |
Relations
A relation connects two models through a foreign-key column or a junction
table. Relations are declared on the struct — by a field name, a tag, or a
slice — and are populated on demand via the ?include= query parameter.
maniflex recognises three kinds:
| Kind | Direction | Declared by |
|---|---|---|
| BelongsTo | this row holds the FK | UserID field (convention) or mfx:"relation:Name" (explicit) |
| HasMany | the other table holds the FK | a slice field of the related type |
| ManyToMany | a junction table connects both sides | a slice field with mfx:"through:Junction" |
BelongsTo (convention)
The simplest case: a field whose name is the related model name plus ID. No
tag is required.
type Post struct {
maniflex.BaseModel
Title string `json:"title" mfx:"required"`
UserID string `json:"user_id" mfx:"required,filterable"` // → User
}
UserID is treated as a foreign key to User. The relation is keyed user
(snake-case of the trimmed field name) — that is the value used in ?include=,
in nested filters (?filter=user.role:eq:admin), and in nested sorts
(?sort=user.name:asc).
The FK field itself is also a regular column. Tag it filterable if clients
need to query by it, exactly like any scalar.
Opting out of the convention
Sometimes a field ends in ID but is not a foreign key — an external reference,
an opaque token, a third-party identifier. Tag it mfx:"norelation" to keep it a
plain scalar column with no relation, no ?include= key, and no entry in the
generated OpenAPI relations:
ExternalID string `json:"external_id" mfx:"norelation"` // just a string, not → External
Including the related row
curl 'localhost:8080/api/posts?include=user'
Each post in the response gains a user object populated from the related
table. Multiple includes are comma-separated:
curl 'localhost:8080/api/posts/<id>?include=user,comments'
BelongsTo (explicit)
When the FK field name does not match the target model — for example, a
ManagerID pointing to User — declare the relation with mfx:"relation:Name"
and add a companion field of the target type:
type Team struct {
maniflex.BaseModel
Name string `json:"name" mfx:"required"`
ManagerID string `json:"manager_id" mfx:"required,filterable,relation:Manager"`
Manager User `json:"manager,omitempty"`
}
ManagerIDis the column that stores the FK.Manageris the companion field: it carries the target type (User) so the framework can resolve the relation. It is not a column itself — its only role is type information for relation scanning.
The relation key here is manager (snake-case of Manager), so the include
becomes ?include=manager.
A relation:Name directive without a matching companion field fails
registration.
HasMany
The inverse side: a slice of the related struct, declared on the model that does not hold the FK.
type User struct {
maniflex.BaseModel
Name string `json:"name"`
Posts []Post `json:"posts,omitempty"` // populated when ?include=posts
}
There is no column on users for this — Posts is purely a relation
declaration. The FK is expected on the related table, named after the parent
model: Post is expected to carry user_id. (That column comes from UserID
on Post, as in the BelongsTo example above.)
The relation key for a slice is the snake-case of the field’s JSON name, here
posts.
curl 'localhost:8080/api/users/<id>?include=posts'
ManyToMany
A many-to-many relation uses a third junction model that carries the two FKs.
Both sides declare a slice with mfx:"through:JunctionModel":
type Product struct {
maniflex.BaseModel
Name string `json:"name"`
Tags []Tag `json:"tags,omitempty" mfx:"through:ProductTag"`
}
type Tag struct {
maniflex.BaseModel
Label string `json:"label"`
Products []Product `json:"products,omitempty" mfx:"through:ProductTag"`
}
// The junction model — register it just like any other.
type ProductTag struct {
maniflex.BaseModel
ProductID string `json:"product_id" mfx:"required,filterable"`
TagID string `json:"tag_id" mfx:"required,filterable"`
}
All three models must be registered. The junction columns follow the BelongsTo
convention (ProductID, TagID), so the framework can resolve which side of
the join goes where.
?include=tags on a product follows the junction and returns the related tags
directly; the junction rows are hidden from the response.
Cascading deletes
A BelongsTo relation may declare what happens when the parent row is deleted,
using the onDelete sub-option:
UserID string `json:"user_id" mfx:"required,relation:Author;onDelete:cascade"`
| Action | Effect on this row when the referenced row is deleted |
|---|---|
cascade | this row is deleted too |
setNull | the FK column is set to NULL (the field must be nullable) |
restrict | the delete is refused while this row exists |
| omitted | no referential constraint is emitted |
Soft delete and relations
When the related model uses soft delete, rows whose deleted marker is set are
omitted from ?include= results — the same filter the framework applies to
list endpoints. See Soft Delete.
Quick reference
| Goal | Declaration |
|---|---|
Belongs to User (FK column matches) | UserID string |
Belongs to User under a different name | ManagerID string with mfx:"relation:Manager" + Manager User companion |
Has many Post | Posts []Post (other side carries UserID) |
Many-to-many via ProductTag | Tags []Tag with mfx:"through:ProductTag", on both sides |
| Cascade on parent delete | mfx:"...,onDelete:cascade" |
| Populate in a response | ?include=user,comments |
Soft Delete
A soft-deleted row is left in the database but marked as deleted. DELETE
requests flip the marker; list, read, and include queries hide rows whose
marker is set. This page covers how a model opts in, the two storage styles,
and the query semantics that follow.
Opting in
There are two ways to enable soft delete on a model. Both produce the same behaviour; pick whichever fits the declaration style of the rest of the model.
By embed
Embed one of the framework’s marker types:
type Article struct {
maniflex.BaseModel
maniflex.WithDeletedAt // timestamp-based soft delete
Title string `json:"title"`
}
| Embed | Column added | Storage style |
|---|---|---|
maniflex.WithDeletedAt | deleted_at — nullable timestamp; NULL means not deleted | timestamp |
maniflex.WithIsDeleted | is_deleted — boolean; false means not deleted | flag |
Both columns are tagged readonly and filterable. They are not part of any
write request — the framework manages them.
By configuration
The same setup, expressed at registration:
server.MustRegister(
Article{}, maniflex.ModelConfig{
SoftDelete: maniflex.SoftDeleteConfig{
Enabled: true,
Field: "deleted_at",
FieldType: maniflex.SoftDeleteTimestamp, // or maniflex.SoftDeleteBool
},
},
)
If both an embed and a ModelConfig.SoftDelete are present, the explicit
config wins.
Choosing between timestamp and boolean
Both styles work; they differ in what you can tell from the column afterwards.
WithDeletedAtrecords when the row was deleted, which makes audit trails, “deleted in the last 30 days” queries, and undelete-with-context possible. It is the default choice.WithIsDeletedstores only the fact of deletion. Use it when the surrounding system already records deletion timestamps elsewhere, or when a boolean fits an existing schema better.
Delete semantics
For a soft-deletable model, DELETE /api/<table>/{id} updates the marker
instead of removing the row:
| Style | What DELETE does |
|---|---|
| Timestamp | sets deleted_at to the current UTC time |
| Boolean | sets is_deleted to true |
The endpoint, the response, and the status code are the same as for a hard-delete model; only the underlying SQL differs.
Query semantics
Once enabled, soft-deleted rows are filtered out everywhere the framework reads the table:
- List (
GET /<table>) — only un-deleted rows are returned. - Read (
GET /<table>/{id}) — a soft-deleted row returns404. - Includes — relations populated via
?include=skip soft-deleted children. - Update —
PATCHon a soft-deleted row returns404; the row is treated as absent.
To surface the marker for clients that need it (e.g. an admin tool), filter on it explicitly:
# Only soft-deleted rows
curl 'localhost:8080/api/articles?filter=deleted_at:ne:null'
deleted_at and is_deleted are filterable, so the standard filter grammar
applies — see Querying.
Restoring a row
The framework does not ship a built-in “undelete” endpoint, because the right
semantics differ across applications (does restoring also reset audit fields?
republish events?). The mechanics are simple: clear the marker. This is usually
done with a custom action that runs a raw UPDATE or
calls the adapter directly.
Interaction with hard delete
A model is either soft- or hard-delete; the choice is a property of the model, not the request. If you need a true hard delete on a soft-deletable model — for example, to honour an erasure request — perform it through a raw query or a custom action that bypasses the standard handler.
Quick reference
| Goal | Declaration |
|---|---|
| Timestamp soft delete | embed maniflex.WithDeletedAt |
| Boolean soft delete | embed maniflex.WithIsDeleted |
| Soft delete with a custom column | ModelConfig.SoftDelete |
| List only deleted rows | ?filter=deleted_at:ne:null (timestamp) or ?filter=is_deleted:eq:true (boolean) |
File Fields & Uploads
A field tagged mfx:"file" accepts an uploaded file alongside the model’s
JSON. The column stores an opaque storage key; the bytes live in a
configured FileStorage backend. Standalone upload, download, and delete
endpoints are also mounted when storage is configured.
Declaring a file field
Add the file directive to a string field:
type Article struct {
maniflex.BaseModel
Title string `json:"title" mfx:"required"`
Cover string `json:"cover" mfx:"file,max_size:2MB,accept:image/*"`
}
The column’s Go and DB types are string — what is stored is the storage key
returned after the upload. The on-disk bytes are managed by the storage
backend; the database row holds only the reference.
Tag sub-options:
| Sub-option | Effect |
|---|---|
file | mark the field as a file upload |
max_size:N | per-field size limit; suffixes KB, MB, GB or plain bytes |
accept:p1|p2 | allowed MIME-type patterns, e.g. image/*|application/pdf |
auto_delete:false | keep the stored file when the row is hard-deleted or the field is replaced (default: delete) |
file_acl:private | (default) response carries the raw storage key; downloads go via /files/<key> or the per-model attachment route |
file_acl:signed | response replaces the key with a pre-signed URL valid for Config.FileSignedURLTTL (default 1h). Requires FileStorage.URL() |
file_acl:public | response replaces the key with a permanent / long-lived URL (e.g. S3 7-day max). Pair with public-read ACL on the bucket for true permanence |
Tag detail is in Field Tags Reference.
file_acl modes
type Attachment struct {
maniflex.BaseModel
Logo string `mfx:"file,file_acl:public,max_size:1MB,accept:image/*"`
Resume string `mfx:"file,file_acl:signed,max_size:5MB,accept:application/pdf"`
Notes string `mfx:"file"` // implicit private — raw key in the response
}
The rewrite happens in the Response step on create, read, list, and update. A
null/empty value passes through unchanged — no fabricated URLs to nothing.
Configure the signed-URL lifetime once on Config:
maniflex.New(maniflex.Config{
FileSignedURLTTL: 15 * time.Minute, // default: 1h
})
LocalStorage.URL returns the server-relative /files/<key> for both signed
and public modes (no real signing — bring an HMAC layer if you need it).
S3Storage.URL uses awss3.NewPresignClient; ttl=0 (public mode) maps to
the AWS 7-day maximum.
Configuring storage
Uploads require a FileStorage implementation on maniflex.Config. The framework
ships one backend; bring your own for cloud storage.
import "github.com/xaleel/maniflex/storage"
fs, err := storage.NewLocalStorage("./uploads")
if err != nil {
log.Fatal(err)
}
server := maniflex.New(maniflex.Config{
Port: 8080,
FileStorage: fs,
})
FileStorage is a small interface — Store, Retrieve, Delete, Exists —
making S3, R2, GCS, or any other key-value store straightforward to adapt.
When FileStorage is nil, model endpoints still accept JSON, but multipart
uploads and the standalone /files routes respond with 501 Not Implemented.
S3, R2, MinIO, DigitalOcean Spaces
The satellite module maniflex/storage/s3 ships a FileStorage implementation
backed by the AWS SDK v2. It works against any S3-compatible service.
import "github.com/xaleel/maniflex/storage/s3"
store, err := s3.New(ctx, s3.Config{
Bucket: "my-app-uploads",
Region: "us-east-1",
// Endpoint, UsePathStyle, KeyPrefix, ACL are all optional.
})
if err != nil { log.Fatal(err) }
server.SetStorage(store)
Credentials follow the standard AWS resolution chain (env vars, shared
config, IAM instance role, IRSA, ECS task role). Override with
Config.AWSConfig when you need a custom credential provider, HTTP client,
or retry policy.
Per-service tips:
| Service | Endpoint | UsePathStyle |
|---|---|---|
| AWS S3 | leave empty | false |
| MinIO | http://localhost:9000 | true |
| Cloudflare R2 | https://<account>.r2.cloudflarestorage.com | false |
| DigitalOcean Spaces | https://<region>.digitaloceanspaces.com | false |
Use KeyPrefix to share one bucket across environments
(KeyPrefix: "staging/") — callers pass logical keys and never see the
prefix. File metadata (filename, size, content type) is stored as native
S3 object metadata so objects remain browsable via the AWS console and
aws s3 cp without the maniflex layer.
How uploads work
A model containing one or more file fields accepts multipart/form-data on
create and update, in addition to JSON:
- Form fields named the same as JSON fields populate the row’s scalar values.
- Form file parts named after a
filefield are streamed to storage; the resulting key is written to the column.
Conceptually:
POST /api/articles
Content-Type: multipart/form-data; boundary=...
--...
Content-Disposition: form-data; name="title"
The First Post
--...
Content-Disposition: form-data; name="cover"; filename="hero.png"
Content-Type: image/png
<bytes>
--...
The response is the usual JSON envelope; the cover field carries the storage
key the client uses to fetch the file later.
The framework rejects an upload before it reaches storage if it violates the
field’s max_size or accept constraints.
Sending a pre-uploaded key
A file field also accepts a plain string in JSON — the storage key of a file
already uploaded via the standalone endpoint. This is useful when the upload
is decoupled from the record creation (large files uploaded ahead of time,
re-using an existing file, and so on).
Standalone file endpoints
When FileStorage is configured, three routes are mounted under PathPrefix:
| Method | Path | Action |
|---|---|---|
POST | /files | upload a single file (multipart, field name file) |
GET | /files/{key...} | stream the file with its original content type |
DELETE | /files/{key...} | remove the file from storage |
POST /files returns 201 with
{"data": {"key": "...", "content_type": "...", "size": ..., "filename": "..."}}.
The returned key is the value to store in a file-tagged column.
GET /files/{key...} sets Content-Type, Content-Disposition: inline, and
Content-Length from the stored metadata, then streams the body. Missing keys
return 404.
These endpoints are storage-key-addressed and have no built-in auth
when Config.FileMiddleware is empty. Set it to wrap the routes with the
same pipeline middleware (e.g. JWT, role checks) that protects your model
endpoints:
maniflex.New(maniflex.Config{
FileStorage: fs,
FileMiddleware: []maniflex.MiddlewareFunc{
auth.JWTAuth(secret, auth.JWTOptions{}),
auth.RequireRole("admin"),
},
})
Each middleware sees a synthesised ServerContext (Request, Writer, Ctx,
RequestID, logger — no Model/Operation, since these routes are outside
the model pipeline). Aborting the context short-circuits the request
before the file handler runs. Leaving FileMiddleware empty keeps the
pre-fix behaviour for backward compatibility, but production deployments
should populate it — anyone who guesses a key could otherwise delete
arbitrary files.
Per-model attachment routes
For each mfx:"file" field on each model, the framework mounts a record-
scoped download path:
GET /:model/:id/:file_field
E.g. GET /api/patients/123/discharge_summary streams the file referenced
by Patient.DischargeSummary for record 123.
Unlike GET /files/{key...}, this route runs through the read pipeline
for the parent record — the same Auth, soft-delete, and tenancy
middleware that protect GET /api/patients/123 also protect the download.
Use this for any attachment whose access depends on the parent row.
Response codes:
| Status | Meaning |
|---|---|
200 | file streamed with Content-Type, Content-Disposition, Content-Length |
404 NOT_FOUND | the record does not exist (or is soft-deleted) |
404 FILE_NOT_SET | the record exists but the field is null/empty |
404 FILE_NOT_FOUND | the field references a key that is missing from storage |
401 / 403 | whatever the Auth middleware decided |
The route is only mounted when Config.FileStorage is configured; with no
storage backend, the route is absent and requests return 404 from the
router.
Internally this is dispatched as a new operation, maniflex.OpReadAttachment.
Middleware filtered by ForOperation(OpRead) does not apply to
attachment requests; use ForOperation(OpRead, OpReadAttachment) to cover
both.
Automatic cleanup
By default, a file field’s stored bytes are removed when:
- the record is hard-deleted, or
- the field is overwritten by an update.
Setting auto_delete:false opts out, leaving the file in storage for
out-of-band lifecycle management. Soft-deleted rows never trigger cleanup —
the file is preserved until the row is hard-deleted.
Bring-your-own storage
Implement maniflex.FileStorage:
type FileStorage interface {
Store(ctx context.Context, key string, r io.Reader, meta FileMeta) error
Retrieve(ctx context.Context, key string) (io.ReadCloser, FileMeta, error)
Delete(ctx context.Context, key string) error
Exists(ctx context.Context, key string) (bool, error)
URL(ctx context.Context, key string, ttl time.Duration) (string, error)
}
Retrieve returns maniflex.ErrFileNotFound when the key does not exist.
Delete should also return maniflex.ErrFileNotFound for missing keys so
the standalone DELETE /files/* handler can surface a 404 without an extra
Exists round-trip; backends that cannot detect the case atomically (e.g. S3
DeleteObject succeeds for missing keys) may return nil instead — both are
treated as “delete succeeded”. Store is given a framework-generated key of
the form uploads/<uuid>/<sanitised-filename>; create any intermediate
directories or object prefixes as needed.
Storage backends are also expected to:
- honour
ctxcancellation inStore— long uploads must abort when the request deadline elapses or the server is shutting down, - reject keys ending in
.meta.jsonin bothStoreandRetrieveif the backend uses sibling JSON files as a metadata layout (LocalStorage’s case), so the framework’s internal layout is never reachable through the file handler.
Filenames flowing through the framework-generated key are sanitised to the
charset [A-Za-z0-9._-] (other runes become _), leading dots are stripped,
and the result is truncated to 120 characters. CR / LF / NUL bytes never
survive into the storage key.
File fields vs. static files
File fields handle user-supplied content. They are unrelated to Static Files, which serves a fixed directory of assets you ship with the app.
| File fields | Static files | |
|---|---|---|
| Source | uploaded at runtime | committed to the repo |
| Storage | FileStorage backend | local disk |
| URL | /files/<key> | /static/<path> |
| Configured by | Config.FileStorage | a static/ directory |
Static Files
Alongside the generated model API, maniflex can serve a directory of plain static files — HTML, CSS, JavaScript, images, downloads — straight off disk. This is useful for a small admin page, a landing page, or assets referenced by an OpenAPI viewer, without standing up a separate web server.
How it works
On startup the server looks for a directory named static/ in the process
working directory. If it exists, every file inside is served under the
/static URL path:
myapp/
├── main.go
└── static/
├── index.html → GET /static/index.html
├── css/app.css → GET /static/css/app.css
└── logo.png → GET /static/logo.png
By default there is nothing to configure and nothing to register — drop files in
static/ and they are served. If the directory does not exist, the server logs
a warning and simply skips mounting it; the rest of the API is unaffected.
Customising the directory and prefix
Three maniflex.Config fields override the defaults:
server := maniflex.New(maniflex.Config{
StaticDir: "public", // serve ./public instead of ./static
StaticPrefix: "/assets", // under /assets instead of /static
})
| Field | Default | Effect |
|---|---|---|
StaticDir | <cwd>/static | filesystem directory served. A relative path resolves against cwd |
StaticPrefix | /static | URL prefix the directory is mounted under (at the router root) |
StaticDisabled | false | set true to turn static serving off entirely |
StaticDisabled is for when a static/ (or StaticDir) directory exists for
other reasons — a build artifact, an embedded asset source — and must not be
exposed over HTTP. A missing directory is still skipped with a warning, so you
only need StaticDisabled to suppress an existing one.
The /static route
A few details follow from how the route is mounted (the buildRouter block in
router.go):
- Resolved from the working directory. The default path checked is
<cwd>/static, where<cwd>is wherever the process was started — not the location of the binary. Run the server from the project root, orcdthere first, sostatic/is found. AStaticDirrelative path resolves the same way. - Mounted outside
PathPrefix. Static files live at/static/...(or yourStaticPrefix), not/api/static/.... ThePathPrefixfrommaniflex.Configscopes only the model API and/openapi.json; the static mount sits at the router root. - Trailing-slash redirect. A request to
/static(no trailing slash) is301-redirected to/static/. Requests below it are served directly. - Directory listing. Because it is backed by Go’s
http.FileServer, a request for a directory with noindex.htmlreturns a file listing. Add anindex.htmlto each directory you do not want browsable.
Choosing the directory
By default the directory is static/ in the working directory; set StaticDir
to serve assets that live elsewhere (no symlink or cd gymnastics required). The
static mount is also purely optional: omit the directory entirely — or set
StaticDisabled: true — and maniflex serves only the API.
Static files vs. file uploads
Static serving is for assets you ship with the app. It is unrelated to the
file-upload feature, which stores user-submitted files and is wired up
separately through Config.FileStorage and the /files endpoints. For
user uploads see File Fields & Uploads.
| Static files | File uploads | |
|---|---|---|
| URL | /static/* | /files/* |
| Source | a static/ directory you commit | user POSTs at runtime |
| Configured by | convention, or Config.Static* | Config.FileStorage |
| Use for | app assets, admin pages | avatars, attachments |
Localization
maniflex has first-class support for multilingual string fields. A single
maniflex.LocaleString field stores all translations in one JSON column and
the framework resolves the right one for each request automatically.
The LocaleString type
maniflex.LocaleString is a map[string]string where each key is a locale
code and each value is the translation for that locale:
{ "en": "Finance", "ar": "مالية", "fr": "Finance" }
On SQLite it is stored as TEXT (JSON-encoded). On Postgres it is stored as
JSONB, which allows GIN-indexed key lookups.
Declare a field as locale-aware with the locale directive:
type Department struct {
maniflex.BaseModel
Name maniflex.LocaleString `json:"name" mfx:"locale,filterable,sortable"`
Code string `json:"code" mfx:"required,unique"`
}
On create and update the client sends the full map:
{ "name": { "en": "Finance", "ar": "مالية" }, "code": "FIN" }
On read the framework emits a locale-resolved view (see Response modes).
Response modes
Every LocaleString field has a response mode that controls the shape of
the field in API responses. The mode is resolved in this order:
- The field’s own
mfxtag (split,resolve, ordynamic) - The model’s
ModelConfig.DefaultLocaleMode - The app’s
LocaleOptions.DefaultLocaleMode - The framework default:
split
split (default)
Two keys are emitted in the response:
name— the resolved string for the effective localename_i18n— the fullmap[string]string(always present)
{
"name": "Finance",
"name_i18n": { "en": "Finance", "ar": "مالية" }
}
The resolved string gives display code a stable string type; the companion
_i18n map gives the editor everything it needs to build a translation form.
The _i18n field is read-only — values sent in the request body under
that key are silently ignored.
The companion suffix defaults to "_i18n" and is configurable via
LocaleOptions.SplitSuffix.
resolve
The field is always a plain string — the resolved value for the effective locale. No companion field is emitted.
{ "name": "Finance" }
Use resolve when clients only ever need one language and the extra _i18n
key adds no value.
dynamic
Replicates legacy behaviour:
- When
?locale=is present: emits a string (resolved for that locale) - When
?locale=is absent: emits the full map
The field type is non-deterministic. Not recommended for new models.
Setting up the LocaleResolver
Install the LocaleResolver middleware on the Deserialize step before
registration, so it runs before the framework’s built-in Deserialize:
server.Pipeline.Deserialize.Register(maniflex.LocaleResolver(maniflex.LocaleOptions{
Supported: []string{"en", "ar", "fr"},
Default: "en",
FromHeader: true,
RTL: []string{"ar", "he", "fa", "ur"},
}))
LocaleOptions fields:
| Field | Type | Default | Purpose |
|---|---|---|---|
Supported | []string | all locales | Whitelist of accepted locale codes; locales not in this list fall back to Default |
Default | string | "en" | App-wide fallback locale used when the request carries no recognisable preference |
FromHeader | bool | false | Also parse Accept-Language; first match in Supported wins (quality values are ignored) |
RTL | []string | — | Locale codes with right-to-left script; matching requests get "_dir":"rtl" in response meta |
DefaultLocaleMode | LocaleMode | split | App-wide default mode for all LocaleString fields |
SplitSuffix | string | "_i18n" | Companion-field suffix used in split mode |
Locale resolution chain
When resolving which string to return, the framework walks this chain (most to least specific) and returns the first non-empty match:
- Explicit
?locale=query parameter Accept-Languageheader (first match inSupported), whenFromHeader: true- Field’s
default_locale:codetag - Model’s
ModelConfig.DefaultLocale - App’s
LocaleOptions.Default(default"en") - Any non-empty value in the map (last resort)
// Field-level default: Arabic is preferred for this field even when the
// request does not specify a locale.
Bio maniflex.LocaleString `json:"bio" mfx:"locale,default_locale:ar"`
// Model-level default: all locale fields on this model use French by default,
// unless overridden by a field's `default_locale` tag.
server.MustRegister(Article{}, maniflex.ModelConfig{DefaultLocale: "fr"})
Requiring a locale key
Use validate.RequireLocale to enforce that specific locale keys are present
and non-empty on create (or update) requests:
server.Pipeline.Validate.Register(
validate.RequireLocale("name", "en"),
maniflex.ForModel("Department"),
maniflex.ForOperation(maniflex.OpCreate),
)
A request that omits the "en" key, or supplies an empty string for it, is
rejected with HTTP 422 MISSING_LOCALE. Pass multiple keys to require several
locales at once:
validate.RequireLocale("name", "en", "ar")
Filtering and sorting locale fields
LocaleString fields tagged filterable and sortable work with the
standard query string grammar. In split and resolve mode the framework
automatically targets the effective locale’s JSON key in the database query.
GET /departments?filter=name:ilike:%25fin%25&sort=name:asc
In the example above, when the effective locale is "en", the adapter runs:
- SQLite:
json_extract("departments"."name", '$.en') LIKE '%fin%' - Postgres:
"departments"."name"->>'en' ILIKE '%fin%'
You can also filter a specific locale key explicitly:
GET /departments?filter=name.ar:contains:مال
In dynamic mode without an explicit ?locale= the filter hits the raw JSON
column, which typically returns no results for plain-string comparisons — this
is intentional: in dynamic mode the field’s meaning depends on request context.
RTL meta
When the resolved locale is in LocaleOptions.RTL, every response envelope
gains a meta object with "_dir": "rtl" — for both list responses
(which already carry pagination in meta) and single-record responses
(read / create / update):
{
"data": {
"name": "مالية",
"name_i18n": { "en": "Finance", "ar": "مالية" }
},
"meta": { "_dir": "rtl" }
}
List responses include pagination alongside the direction flag:
{ "data": [...],
"meta": { "total": 5, "page": 1, "limit": 20, "pages": 1, "_dir": "rtl" } }
Clients can use meta._dir to switch text direction without needing to know
which locale is active.
Model-level mode override
Set a uniform mode for all LocaleString fields on a model without tagging
each one individually:
server.MustRegister(LegacyArticle{}, maniflex.ModelConfig{
DefaultLocaleMode: maniflex.LocaleModeDynamic,
})
Field-level tags take precedence over the model setting, which in turn takes
precedence over the app-level LocaleOptions.DefaultLocaleMode.
Full example
type Product struct {
maniflex.BaseModel
Name maniflex.LocaleString `json:"name" mfx:"locale,filterable,sortable"`
Description maniflex.LocaleString `json:"description" mfx:"locale,resolve"`
SKU string `json:"sku" mfx:"required,unique"`
}
func main() {
server := maniflex.New(maniflex.Config{...})
server.Pipeline.Deserialize.Register(maniflex.LocaleResolver(maniflex.LocaleOptions{
Supported: []string{"en", "ar"},
Default: "en",
FromHeader: true,
RTL: []string{"ar"},
}))
server.MustRegister(Product{})
db, _ := sqlite.Open("store.db", server.Registry())
server.SetDB(db)
server.Start()
}
With this setup:
GET /productsreturnsnameas the resolved English string plusname_i18nwith all translations;descriptionis always a resolved English string.GET /products?locale=arresolves both fields to Arabic.POST /productswith{"name":{"en":"Laptop"},"sku":"LAP-01"}succeeds.
Pipeline Overview
Every HTTP request handled by a generated model route flows through the same six-step pipeline. Each step has a default behaviour supplied by the framework and a registry of user middleware that can run before, after, or in place of it. This page describes what each step is responsible for; later pages cover how to register middleware on them and how state flows between them.
The six steps
Auth → Deserialize → Validate → Service → DB → Response
| Step | Default behaviour |
|---|---|
| Auth | Pass-through. Populates nothing by default. User middleware sets ctx.Auth here. |
| Deserialize | Parses URL query parameters (page, limit, filter, sort, include) into ctx.Query. On POST/PATCH, reads the JSON body into ctx.ParsedBody (limit: 4 MB), or parses multipart/form-data into ctx.ParsedBody and ctx.Files. |
| Validate | For create and update, enforces the mfx: tag rules: strips readonly and id, rejects immutable on update, checks required, enum, min, max. |
| Service | Pass-through. Reserved for business logic supplied by user middleware. |
| DB | Dispatches to the configured adapter for the current operation — FindMany, FindByID, Create, Update, or Delete. Routes through ctx.Tx when a transaction is active. |
| Response | Builds the JSON envelope from ctx.DBResult and writes it to the http.ResponseWriter. |
The OpenAPI endpoint (GET /openapi.json) has its own three-step pipeline —
Auth → Generate → Response — accessible via server.Pipeline.OpenAPI. The
model-route pipeline described here is the one used for everything else.
Operations
The CRUD operation a request performs is identified by an Operation value
that is stable across all six steps:
| Operation | Triggered by |
|---|---|
OpList | GET /<table> |
OpRead | GET /<table>/{id} |
OpCreate | POST /<table> |
OpUpdate | PATCH /<table>/{id} |
OpDelete | DELETE /<table>/{id} |
OpHead | HEAD /<table> or HEAD /<table>/{id} |
OpOptions | OPTIONS /<table> or OPTIONS /<table>/{id} |
OpReadAttachment | GET /<table>/{id}/<file_field> — per-model attachment download (see Files) |
OpAction | a custom action endpoint registered with server.Action() |
ctx.Operation is the value middleware uses to branch behaviour. OpAction
requests follow a trimmed pipeline (Auth → action handler → Response); the
Deserialize, Validate, Service, and DB steps are skipped for them.
Per-step responsibilities
Auth
The Auth step is the place to verify a token, look up a user, and set
ctx.Auth. The default handler does nothing; an unauthenticated request reaches
the DB layer with ctx.Auth == nil. Add a middleware here to reject anonymous
callers, populate identity, or check scopes.
Deserialize
The Deserialize step assembles request input from three sources:
- The URL query string becomes a
*QueryParamsonctx.Query. Filter and sort references are validated against the model’s tag-derived field lists. - A JSON body becomes
ctx.ParsedBody(a read-only*RequestBody, JSON-keyed) and is bound to the typed recordctx.Record. Bodies over 4 MB are rejected asBODY_READ_ERROR. - A multipart body populates both
ctx.ParsedBody(the form fields) andctx.Files(the file parts). The form-field-to-file-field mapping is by name.
Reads carry no body, so only ctx.Query is populated for OpList / OpRead.
Validate
The Validate step runs only on OpCreate and OpUpdate. It applies the rules
declared by mfx: tags to ctx.ParsedBody:
readonlyfields and theidcolumn are silently stripped.immutablefields are stripped on update.requiredfields must be present on create.enum,min,maxare checked when the value is present.
Validation failures abort the pipeline with 422 Unprocessable Entity and a
details payload listing every offending field.
Service
The Service step has no default behaviour — it exists for application logic.
Hashing a password before persistence, charging a payment, recomputing a
derived total, calling an external API: all of these belong here. A Service
middleware that needs to short-circuit the request calls ctx.Abort(...) and
returns without invoking next().
DB
The DB step is the only step with side effects on the database. It selects the
operation matching ctx.Operation, builds the column-keyed write set from
ctx.Record (falling back to ctx.ParsedBody), calls the adapter, and writes
the result into ctx.DBResult — a *ListResult for lists, otherwise the record
(a typed *T on reads). When ctx.Tx is set, the call is routed through the
transaction; otherwise the bare adapter is used.
Two error classes are normalised at this step:
maniflex.ErrNotFoundbecomes404 NOT_FOUND.*maniflex.ErrConstraintbecomes409 CONFLICT.
A cancelled context becomes 504 TIMEOUT. All other adapter errors surface as
500 DATABASE_ERROR.
Response
The Response step serialises ctx.DBResult into an APIResponse and writes it
to the wire. List responses include a meta block with total, page,
limit, and pages; single-record responses do not. The standard envelope is
{"data": ...} for success and {"error": {...}} for failure.
Short-circuiting
Any middleware can stop the pipeline by setting ctx.Response (typically via
ctx.Abort(status, code, message)) and returning without calling next().
Subsequent steps are skipped and the Response step writes the prepared error
envelope. This is the standard mechanism for unauthorised requests, validation
failures inside Service middleware, and any other refusal that should not
reach the database.
Per-step middleware
Each step exposes a *StepRegistry on server.Pipeline:
server.Pipeline.Auth.Register(jwtAuth)
server.Pipeline.Service.Register(hashPassword,
maniflex.ForModel("User"), maniflex.ForOperation(maniflex.OpCreate))
Registered middleware can run Before the default (the default position), After it, or Replace it entirely. Scoping by model and operation is covered in Writing Middleware.
Next
- ServerContext — the object threaded through every step.
- Writing Middleware —
Register, options, positions. - Transactions — wrapping the DB step in a transaction.
- Error Handling — the error envelope and sentinel errors.
ServerContext
*maniflex.ServerContext is the object threaded through every pipeline step for one
HTTP request. Steps read from it, write to it, and call next() to proceed.
Middleware does the same. This page documents the fields and methods middleware
will commonly touch.
Lifecycle
A new ServerContext is constructed by the handler for every request, populated
incrementally by the pipeline, and discarded once the response is written. It
is not safe to share across requests or goroutines.
The fields populated by each step are:
| Step | Sets |
|---|---|
| handler (before Auth) | Request, Writer, Ctx, Model, Operation, ResourceID, RequestID, TraceID |
| Auth | Auth (when user middleware populates it) |
| Deserialize | RawBody, ParsedBody, Query, Files |
| Service | (whatever user middleware sets) |
| DB | DBResult, possibly Tx |
| Response | Response, then writes it to Writer |
Routing context
Set by the handler before Auth runs; safe to read in any step.
| Field | Meaning |
|---|---|
Request | the original *http.Request |
Writer | the underlying http.ResponseWriter |
Ctx | the request context.Context; cancellation propagates from here |
Model | the *ModelMeta for the resource — name, table, fields, relations |
Operation | the Operation being performed (OpCreate, OpList, …) |
ResourceID | the {id} path parameter, empty for list and create |
RequestID | chi’s request ID, echoed in X-Request-Id |
TraceID | the W3C traceparent header, when present |
Step outputs
Populated in order by the pipeline.
| Field | Populated by | Type |
|---|---|---|
RawBody | Deserialize | []byte — the raw request bytes |
ParsedBody | Deserialize | *RequestBody — read-only JSON-keyed body (mutate via SetField) |
Record | Deserialize | the typed record carrier (*T for ctx.Model) bound from the body |
Query | Deserialize | *QueryParams — pagination, filters, sorts, includes |
Files | Deserialize (multipart only) | map[string]*UploadedFile |
DBResult | DB | *ListResult for lists; the record otherwise (a typed *T on reads) |
Response | Response | *APIResponse — the envelope written to the wire |
Setting Response from any step causes the remaining steps to skip and the
prepared envelope to be written. See Abort below.
Auth
Auth *AuthInfo is populated by Auth middleware. When nil, the request is
anonymous.
type AuthInfo struct {
UserID string
Roles []string
Claims map[string]any
TenantID string
IdentityType AuthIdentityType // human, service_account, anonymous
Scopes []string
SessionID string
AuthMethod string // "jwt", "api_key", "session", …
}
ctx.HasRole(role string) bool is a convenience wrapper that returns false
when Auth is nil.
Transactions
Tx Tx carries the active transaction, if any. When set, the default DB step
routes through it. ctx.BeginTx(ctx.Ctx, opts) returns a Tx and is the
standard way for middleware to start one. See Transactions
for the full pattern.
Aborting the pipeline
ctx.Abort(status int, code, message string) populates ctx.Response with an
error envelope. The current middleware must then return nil without calling
next(). Subsequent steps are skipped; the Response step writes the prepared
error.
if header == "" {
ctx.Abort(http.StatusUnauthorized, "UNAUTHORIZED", "missing token")
return nil
}
Calling next() after Abort
Abort does not stop the pipeline — it only populates ctx.Response. If the
middleware calls next() afterwards, the chain continues exactly as if the
abort had not happened:
- The remaining steps still execute, with all their side effects. The DB step will still issue its query and possibly modify the database; a Service middleware will still call out to external services.
- Any of those steps may overwrite
ctx.Response— for example, the DB step replaces it with a404 NOT_FOUNDif the record is missing, or the default Response step builds a200 OKenvelope fromctx.DBResult. Whichever step writes last wins. - If nothing downstream touches
ctx.Response, the original abort envelope is preserved and sent to the client — but the side effects have already happened.
The result is almost always a bug: either the client sees a misleading status
(a write succeeded but the response claims it was rejected), or the database
is mutated by a request that was meant to be refused. Always return without
next() after Abort.
Reading input
Three helpers wrap common request reads:
| Method | Purpose |
|---|---|
BindJSON(v any) error | decode the body into v, enforcing the 4 MB limit |
URLParam(name string) string | read a chi URL parameter |
QueryParam(name string) string | read a URL query parameter |
BindJSON calls Abort internally on error and returns a non-nil error so the
caller can return nil immediately.
The request body
ctx.ParsedBody holds the deserialized JSON (or multipart form) body as a
*RequestBody. It is read-only: there is no exported way to index or assign
it, so a stray ctx.ParsedBody["x"] = y is a compile error. This is deliberate.
The body is mirrored onto a typed record (ctx.Record), and the only mutators —
ctx.SetField / ctx.DeleteField — keep both in sync; writing the map directly
would update one and not the other, and the change could be silently dropped at
the DB step.
Reading
| Call | Returns |
|---|---|
ctx.Field(name string) (any, bool) | one field by its JSON name |
ctx.ParsedBody.Has(name) bool | whether a key is present (an explicit null counts) |
ctx.ParsedBody.Keys() []string / .Len() int | the top-level key set |
ctx.ParsedBody.Map() map[string]any | a copy of the body, for read-only consumers |
All are nil-safe: ctx.ParsedBody is nil for body-less requests (GET, DELETE)
and the readers return zero values rather than panicking.
For typed access, read the whole body as the concrete model struct:
u, ok := maniflex.For[User](ctx) // (*User, bool) — false if no User body is bound
u, err := maniflex.Bind[User](ctx) // (*User, error) — errors when absent
// or adapt a typed handler straight into middleware:
server.Pipeline.Service.Register(
maniflex.Handle(func(ctx *maniflex.ServerContext, u *User) error {
if u.Age < 18 {
ctx.Abort(http.StatusUnprocessableEntity, "TOO_YOUNG", "must be 18+")
}
return nil
}),
maniflex.ForModel("User"), maniflex.ForOperation(maniflex.OpCreate),
)
Writing
Middleware that injects or rewrites a field must go through these setters so the value reaches both the body and the typed record (and so the DB step persists it):
| Call | Effect |
|---|---|
ctx.SetField(name string, value any) | set a field by its JSON name |
ctx.DeleteField(name string) | remove a field (e.g. strip an input-only field) |
server.Pipeline.Service.Register(func(ctx *maniflex.ServerContext, next func() error) error {
ctx.SetField("owner_id", ctx.Auth.UserID) // force the owner server-side
return next()
}, maniflex.ForOperation(maniflex.OpCreate))
Cross-step storage
For state that one middleware needs to pass to another:
ctx.Set("invoiceID", inv.ID)
// later, in another middleware:
id, ok := ctx.Get("invoiceID")
The store is per-request and discarded with the context.
Direct database access
Middleware that needs to reach beyond ctx.Model — to read another model, run
a raw query, or take a row lock — has four entry points, all routed through
ctx.Tx when one is active:
| Method | Purpose |
|---|---|
GetModel(name string) *ModelAccessor | CRUD on any registered model (.List / .Read / .Create / .Update / .Delete) |
RawQuery(sql string, args ...any) ([]map[string]any, error) | parameterised SELECT |
RawExec(sql string, args ...any) (int64, error) | parameterised non-SELECT |
LockForUpdate(modelName, id string) (map[string]any, error) | pessimistic row lock; requires ctx.Tx |
GetModel returns an accessor whose methods route through ctx.Tx when set,
so middleware in a transaction does not have to thread the Tx manually.
Typed cross-model helpers
GetModel(name) is dynamic — string-named, exchanging map[string]any. For
compile-time types use the generic free functions, which resolve the model from
the type parameter and route through ctx.Tx the same way (so they also
participate in maniflex.Batch):
u, err := maniflex.Read[User](ctx, id) // *User
all, err := maniflex.List[User](ctx, nil) // []*User
created, err := maniflex.Create(ctx, &User{Name: "Jane"})
maniflex.Update(ctx, id, &User{ /* full record */ })
maniflex.Delete[User](ctx, id)
Results that are not a registered model — raw SQL, aggregates, recursive
queries — use maniflex.Row (an alias for map[string]any); RawQuery,
Aggregate, and RecursiveQuery return []maniflex.Row.
Logging
ctx.Logger() *slog.Logger returns a slog logger pre-seeded with
request_id, trace_id, and service attributes, so log lines emitted from
middleware are correlated automatically.
ctx.Logger().Info("payment captured",
slog.String("invoice_id", inv.ID),
slog.Float64("amount", inv.Total),
)
Service name
ctx.ServiceName() returns the Config.ServiceName configured on the server.
Middleware uses this to enrich audit records or outgoing requests without
holding a reference to the framework Config.
Next
- Writing Middleware — composing middleware on these fields.
- Transactions —
ctx.Tx,BeginTx,LockForUpdate. - Error Handling —
Abortand the response envelope.
Writing Middleware
A middleware is a function that runs as part of one of the six pipeline steps. It can inspect and modify the request context, call into the database, decide whether to proceed, and inject behaviour before or after the step’s default handler.
Signature
type MiddlewareFunc func(ctx *maniflex.ServerContext, next func() error) error
A middleware does one of two things:
- Continue the pipeline — perform its work, then call
next()and return the result. The chain executes the remaining middleware in the step and then the steps after it. - Short-circuit — set
ctx.Response(typically viactx.Abort(...)) and returnnilwithout callingnext(). The remaining steps are skipped and the prepared response is written to the wire.
func bearerToken(ctx *maniflex.ServerContext, next func() error) error {
header := ctx.Request.Header.Get("Authorization")
if !strings.HasPrefix(header, "Bearer ") {
ctx.Abort(http.StatusUnauthorized, "UNAUTHORIZED", "missing bearer token")
return nil
}
ctx.Auth = &maniflex.AuthInfo{UserID: parseSubject(header)}
return next()
}
Typed middleware
For middleware that works with the request body, maniflex.Handle[T] adapts a
typed handler into a MiddlewareFunc. It hands you the bound record as a
concrete *T (the same value ctx.Record holds) instead of a map, runs only
when a *T body is bound, and is skipped on body-less operations:
server.Pipeline.Service.Register(
maniflex.Handle(func(ctx *maniflex.ServerContext, u *User) error {
if u.Age < 18 {
ctx.Abort(http.StatusUnprocessableEntity, "TOO_YOUNG", "must be 18+")
}
return nil
}),
maniflex.ForModel("User"), maniflex.ForOperation(maniflex.OpCreate),
)
To change a field, call ctx.SetField(name, value) rather than mutating the
struct, so the write reaches both the body and the record (see
ServerContext › The request body). For an ad-hoc typed read inside
a plain middleware, use maniflex.For[T](ctx) / maniflex.Bind[T](ctx).
Registration
Each pipeline step exposes a *StepRegistry on server.Pipeline. Register a
middleware on the step where its work belongs:
server.Pipeline.Auth.Register(bearerToken)
Without options, the middleware applies to every model and every operation.
Scoping
Two functional options narrow the scope. They are independent and may be combined.
ForModel(names ...string)
Restrict to one or more models, by struct name:
server.Pipeline.Service.Register(hashPassword, maniflex.ForModel("User"))
ForOperation(ops ...Operation)
Restrict to specific operations:
server.Pipeline.Auth.Register(requireToken,
maniflex.ForOperation(maniflex.OpCreate, maniflex.OpUpdate, maniflex.OpDelete),
)
Operation values: OpList, OpRead, OpCreate, OpUpdate, OpDelete,
OpHead, OpOptions, OpAction. Registering on Validate/Service/DB with
OpAction has no effect — those steps are skipped for action endpoints.
Combining
server.Pipeline.Service.Register(chargePayment,
maniflex.ForModel("Order"),
maniflex.ForOperation(maniflex.OpCreate),
)
Position
By default, a middleware runs before the step’s default handler. Use
AtPosition to change that.
| Position | When the middleware runs |
|---|---|
maniflex.Before (default) | before the default handler |
maniflex.After | after the default handler |
maniflex.Replace | instead of the default handler — the step’s built-in behaviour is skipped |
// Run after the DB step succeeds — useful for audit logs.
server.Pipeline.DB.Register(auditLog,
maniflex.ForOperation(maniflex.OpCreate, maniflex.OpUpdate, maniflex.OpDelete),
maniflex.AtPosition(maniflex.After),
)
// Replace the default DB step for one model entirely.
server.Pipeline.DB.Register(customDispatch,
maniflex.ForModel("LegacyOrder"),
maniflex.AtPosition(maniflex.Replace),
)
Within a step, all matching Before middlewares run in registration order,
then the core handler (default or Replace), then all matching After
middlewares in registration order. If multiple Replace middlewares match,
the last one registered wins.
Naming for traces
maniflex.WithName("name") attaches a human label to a middleware for use in
pipeline trace logs (enabled via Config.Trace). It does not change runtime
behaviour:
server.Pipeline.Auth.Register(rateLimit, maniflex.WithName("rate-limiter"))
Step-specific guidance
| Step | What middleware here typically does |
|---|---|
| Auth | Verify a token, populate ctx.Auth, reject unauthenticated requests. |
| Deserialize | Rarely customised. After middleware can rewrite the body via ctx.SetField / ctx.DeleteField. |
| Validate | Custom validation that goes beyond mfx: tags. Abort with 422 on failure. |
| Service | Business logic — derive fields, call external services, start transactions (maniflex.WithTransaction). |
| DB | Hooks around the database call. After middleware sees ctx.DBResult; Replace substitutes a different backend. |
| Response | After middleware can add headers; Replace lets you write a non-envelope response. |
After-middleware error handling
An After middleware sees ctx.Response when the default step has populated
it. Inspect it to decide whether to act:
func auditLog(ctx *maniflex.ServerContext, next func() error) error {
if err := next(); err != nil {
return err
}
// don't audit failed writes
if ctx.Response != nil && ctx.Response.StatusCode < 400 {
record(ctx)
}
return nil
}
Per-model middleware at registration
For middleware that belongs to exactly one model, ModelConfig.Middleware
attaches hooks scoped to that model at registration time, avoiding the separate
Register call:
server.MustRegister(
Order{}, maniflex.ModelConfig{
Middleware: &maniflex.ModelMiddleware{
Validate: []maniflex.MiddlewareFunc{checkStock},
Service: []maniflex.MiddlewareFunc{chargePayment},
},
},
)
Both forms are equivalent; choose whichever keeps the declaration close to the code that depends on it.
Built-in middleware
Several middleware functions ship with the framework or its satellite modules — JWT auth, password hashing, audit logging, CORS, and more. They are documented in Middleware Catalogue.
Next
- ServerContext — the fields a middleware reads and writes.
- Transactions —
maniflex.WithTransactionas a Service-step middleware. - Error Handling — what
Abortproduces and how it propagates.
Transactions
A transaction wraps one or more database operations so they either all commit or all roll back. In maniflex the unit of transactional work is normally a single request: every database call performed by its pipeline runs in the same transaction, and the transaction commits if and only if the request produces a successful response.
Enabling transactions
The shipped middleware maniflex.WithTransaction wraps the DB step in a
transaction. Register it on the Service step, scoped to the operations that
should be transactional:
server.Pipeline.Service.Register(
maniflex.WithTransaction(nil), // nil = default isolation level
maniflex.ForOperation(maniflex.OpCreate, maniflex.OpUpdate, maniflex.OpDelete),
)
Once registered, every matching request executes the DB step (and any subsequent After-DB middleware) inside a transaction. The middleware:
- Begins a transaction before calling
next(). - Stores it on
ctx.Txand the underlyingctx.Ctx, so all downstream code can join it. - Commits if
next()returns nil andctx.Responseis a 2xx. - Rolls back if
next()returns an error, ifctx.Response.StatusCode >= 400, or if anything panics.
The same middleware can be registered on the DB step at Replace position if
you want to substitute the default DB step entirely.
Customising isolation
maniflex.TxOptions is an alias for sql.TxOptions:
server.Pipeline.Service.Register(
maniflex.WithTransaction(&maniflex.TxOptions{
Isolation: sql.LevelSerializable,
ReadOnly: false,
}),
maniflex.ForModel("Invoice"),
)
SQLite ignores most isolation levels; for guaranteed write-locking on SQLite,
use BEGIN IMMEDIATE via the _txlock=immediate DSN option when opening the
database.
Joining the transaction from middleware
When ctx.Tx is set, every CRUD call made through ctx.GetModel(...),
ctx.RawQuery, ctx.RawExec, and the default DB step routes through it
automatically:
server.Pipeline.Service.Register(func(ctx *maniflex.ServerContext, next func() error) error {
// This Update participates in the same transaction as the DB step that follows.
if _, err := ctx.GetModel("Inventory").Update(itemID, map[string]any{
"reserved": true,
}); err != nil {
return err // triggers rollback
}
return next()
}, maniflex.ForModel("Order"), maniflex.ForOperation(maniflex.OpCreate))
There is no separate “transactional” API — calling ctx.GetModel is enough.
Starting a transaction manually
When WithTransaction does not fit — for example, when only part of a request
should be transactional, or when the transaction must span an action endpoint
— begin one yourself:
tx, err := ctx.BeginTx(ctx.Ctx, nil)
if err != nil {
return err
}
ctx.Tx = tx
defer tx.Rollback() // no-op after Commit
// ... transactional work via ctx.GetModel / ctx.RawExec ...
if err := tx.Commit(); err != nil {
return err
}
ctx.Tx = nil // clear so post-commit code uses the bare adapter
tx.Rollback() after a successful commit is safe — it returns
sql.ErrTxDone, which the framework swallows. Always defer it.
Pessimistic locking
Inside a transaction, ctx.LockForUpdate(modelName, id) acquires a row-level
write lock and returns the current row:
row, err := ctx.LockForUpdate("StockBalance", stockID)
if err != nil {
return err
}
if row["quantity"].(int64) < 1 {
ctx.Abort(http.StatusConflict, "OUT_OF_STOCK", "no inventory remaining")
return nil
}
On Postgres this appends FOR UPDATE to the SELECT. On SQLite the lock is at
the transaction level — the row is protected because the transaction itself is
write-locked.
LockForUpdate returns an error if ctx.Tx is nil; the lock is meaningless
outside a transaction.
Joining from outside a ServerContext
Code without access to *ServerContext — for example, a job-queue helper, or an
outbox writer — can retrieve the active transaction from ctx.Ctx using
maniflex.TxFromContext:
func enqueue(ctx context.Context, job Job) error {
if tx := maniflex.TxFromContext(ctx); tx != nil {
return enqueueInTx(tx, job)
}
return enqueueDirect(job)
}
WithTransaction stores the active transaction on the context.Context for
exactly this purpose.
Adapter scope
A transaction lives on a single DBAdapter. ctx.BeginTx opens the
transaction on the request’s model adapter (its ModelConfig.Adapter if
set, otherwise Config.DB). All operations on that ctx.Tx must target
models routed to the same adapter.
maniflex.Batch enforces this at runtime: calling b.Create("X", ...) for a
model that routes to a different adapter than the batch transaction
returns an error suggesting pkg/saga for cross-adapter coordination.
See Per-model adapter routing.
Nesting
WithTransaction is idempotent: if ctx.Tx is already set when it runs, it
simply calls next() without starting a new transaction. The outer transaction
remains the unit of commit.
SQLite does not support nested transactions; calling ctx.BeginTx while one
is already active returns an error.
Failure semantics
A transaction is rolled back when:
- the chain returns a non-nil error from any step;
ctx.Responseis set to a status>= 400(e.g. viactx.Abort);- a panic occurs (the framework’s panic recoverer ensures rollback).
WithTransaction is committed when:
- the chain completes without error and
ctx.Responseis a 2xx (or unset).
A commit failure is reported as 500 TX_COMMIT_ERROR; a begin failure as
500 TX_BEGIN_ERROR.
Next
- Writing Middleware — how to register
WithTransactionor write your own transactional middleware. - Error Handling — how a rollback surfaces to the client.
Error Handling
Every error returned by the pipeline is delivered to the client as the same JSON envelope. This page describes that envelope, the sentinel errors the framework recognises, and how to produce errors from middleware.
The error envelope
A failing request writes:
{
"error": {
"code": "VALIDATION_FAILED",
"message": "field \"email\" is required",
"details": { /* optional, per-error */ }
}
}
with the HTTP status code from the underlying failure. The code is a short
machine-readable identifier; message is a human-readable summary; details
is optional and may carry per-field errors or other structured context.
A successful request uses the {"data": ...} envelope instead; the two are
mutually exclusive.
Built-in error responses
The default pipeline produces the following errors without any user code:
| Status | Code | Source |
|---|---|---|
400 | INVALID_JSON | malformed JSON body |
400 | EMPTY_BODY | empty body on POST / PATCH |
400 | BODY_READ_ERROR | body exceeded the 4 MB read limit |
400 | INVALID_QUERY | unknown filter/sort field, malformed ?include, etc. |
400 | MULTIPART_ERROR | malformed multipart/form-data |
404 | NOT_FOUND | record does not exist (or is soft-deleted) |
409 | CONFLICT | unique or check constraint violated |
422 | VALIDATION_FAILED | one or more mfx: tag rules failed |
500 | DATABASE_ERROR | unclassified adapter error |
500 | TX_BEGIN_ERROR / TX_COMMIT_ERROR | transaction lifecycle failure |
501 | NO_STORAGE | file endpoint hit with no FileStorage configured |
504 | TIMEOUT | request context deadline exceeded |
A panic anywhere in the pipeline is caught and reported as 500 PANIC by the
framework’s recoverer.
Aborting from middleware
The standard way to produce an error from middleware is ctx.Abort:
func ctx.Abort(statusCode int, code, message string)
It populates ctx.Response with an error envelope. The caller must then return
nil (or an error) without calling next():
if header == "" {
ctx.Abort(http.StatusUnauthorized, "UNAUTHORIZED", "missing token")
return nil
}
Subsequent steps are skipped; the Response step writes the prepared envelope.
Calling next() after Abort allows downstream steps to overwrite the
response — usually not what you want.
Returning structured details
For per-field errors and similar payloads, set ctx.Response directly:
ctx.Response = &maniflex.APIResponse{
StatusCode: http.StatusUnprocessableEntity,
Error: &maniflex.APIError{
Code: "VALIDATION_FAILED",
Message: "one or more fields failed validation",
Details: []map[string]string{
{"field": "email", "message": "must be a valid email"},
{"field": "password", "message": "must be at least 8 characters"},
},
},
}
return nil
This is the shape used by the default Validate step.
Sentinel errors from the adapter
Adapter methods return errors that the DB step maps to HTTP responses. The two that user code most often interacts with are exported as sentinels.
maniflex.ErrNotFound
var ErrNotFound = errors.New("record not found")
Returned by FindByID, Update, and Delete when the row does not exist (or
is soft-deleted). Detect it with errors.Is:
row, err := ctx.GetModel("Invoice").Read(id)
if errors.Is(err, maniflex.ErrNotFound) {
ctx.Abort(http.StatusNotFound, "INVOICE_NOT_FOUND",
fmt.Sprintf("invoice %s does not exist", id))
return nil
}
The default DB step does this conversion automatically; you only need it when you are calling the adapter yourself from a Service middleware.
*maniflex.ErrConstraint
type ErrConstraint struct {
Table string
Column string // may be empty when the driver does not expose it
Detail string // raw driver message
}
Returned by Create and Update on unique or check constraint violations.
Both SQLite and Postgres errors are normalised into this type, so middleware
need not inspect driver-specific codes.
row, err := ctx.GetModel("User").Create(data)
var ec *maniflex.ErrConstraint
if errors.As(err, &ec) {
ctx.Abort(http.StatusConflict, "DUPLICATE",
fmt.Sprintf("%s already exists", ec.Column))
return nil
}
Errors and transactions
When a request runs inside a transaction (see Transactions)
and any step returns an error or sets ctx.Response to status >= 400, the
transaction is rolled back before the response is written. The client sees the
error envelope; the database sees no change.
Logging errors
ctx.Logger() returns a *slog.Logger pre-seeded with request_id and
trace_id, so a single log line correlates with the request that produced it:
ctx.Logger().Error("payment provider rejected charge",
slog.String("provider", "stripe"),
slog.String("error_code", resp.Code),
)
ctx.Abort(http.StatusBadGateway, "PAYMENT_DECLINED", resp.Message)
return nil
Log first, then abort — the log line is what you’ll need when debugging.
Next
- ServerContext — the full set of fields available to error-producing middleware.
- Transactions — rollback semantics.
- Writing Middleware — where to attach error-producing logic.
Example 2: B2B SaaS API
This example builds a small multi-tenant SaaS backend. Compared to Example 1, it exercises every concept introduced in the “Defining Your API” and “The Request Pipeline” sections:
- Multiple related models with
BelongsToandHasManyrelations. - Soft delete on the records that matter for an audit trail.
- A bearer-token Auth middleware that populates
ctx.Auth. - A Service-step middleware that scopes every query to the caller’s tenant.
- A Service-step middleware that runs inside a transaction.
- Custom error envelopes with
ctx.Abort.
The goal is to show how the pieces compose; the auth and tenancy code is deliberately minimal so the example fits on one page.
Domain
A SaaS platform with three resources:
- Organization — the tenant. Every other record belongs to one.
- Member — a user belonging to an organization, with a role.
- Project — a unit of work owned by a member.
type Organization struct {
maniflex.BaseModel
maniflex.WithDeletedAt
Name string `json:"name" mfx:"required,filterable,sortable"`
Plan string `json:"plan" mfx:"required,enum:free|pro|enterprise,default:free"`
Members []Member `json:"members,omitempty"`
Projects []Project `json:"projects,omitempty"`
}
type Member struct {
maniflex.BaseModel
maniflex.WithDeletedAt
OrganizationID string `json:"organization_id" mfx:"required,filterable,immutable"`
Email string `json:"email" mfx:"required,filterable,unique"`
Role string `json:"role" mfx:"required,enum:owner|admin|editor|viewer,default:viewer,filterable"`
Projects []Project `json:"projects,omitempty"`
}
type Project struct {
maniflex.BaseModel
maniflex.WithDeletedAt
OrganizationID string `json:"organization_id" mfx:"required,filterable,immutable"`
OwnerID string `json:"owner_id" mfx:"required,filterable,relation:Owner"`
Owner Member `json:"owner,omitempty"`
Name string `json:"name" mfx:"required,filterable,sortable"`
Status string `json:"status" mfx:"required,enum:active|paused|archived,default:active,filterable,sortable"`
}
Owner is a companion field; the explicit relation:Owner tag is required
because the FK name (OwnerID) does not match the model name (Member).
OrganizationID follows the convention, so no companion is needed there.
Wiring
A single main.go registers the models, installs three middlewares, and
starts the server.
func main() {
server := maniflex.New(maniflex.Config{
Port: 8080,
PathPrefix: "/api",
AutoMigrate: true,
})
server.MustRegister(Organization{}, Member{}, Project{})
db, err := sqlite.Open("./saas.db", server.Registry())
if err != nil {
log.Fatal(err)
}
defer db.Close()
server.SetDB(db)
registerMiddleware(server)
log.Fatal(server.Start())
}
Auth — populating ctx.Auth
A real deployment would verify a JWT; this example resolves a bearer token
against an in-memory map to keep the focus on ctx.Auth:
var tokens = map[string]maniflex.AuthInfo{
"alice-token": {UserID: "user-alice", TenantID: "org-acme", Roles: []string{"owner"}},
"bob-token": {UserID: "user-bob", TenantID: "org-acme", Roles: []string{"editor"}},
"carol-token": {UserID: "user-carol", TenantID: "org-globex", Roles: []string{"admin"}},
}
func bearerAuth(ctx *maniflex.ServerContext, next func() error) error {
header := ctx.Request.Header.Get("Authorization")
token := strings.TrimPrefix(header, "Bearer ")
info, ok := tokens[token]
if !ok {
ctx.Abort(http.StatusUnauthorized, "UNAUTHORIZED", "invalid or missing token")
return nil
}
ctx.Auth = &info
return next()
}
Tenant scoping — filtering by ctx.Auth.TenantID
A B2B API must never leak data across tenants. A Service-step middleware inspects every list and read, and rejects writes that would assign records to a foreign organization:
func enforceTenant(ctx *maniflex.ServerContext, next func() error) error {
tenant := ctx.Auth.TenantID
switch ctx.Operation {
case maniflex.OpList:
// Inject a filter so users only see their organization's rows.
ctx.Query.Filters = append(ctx.Query.Filters, maniflex.Filter{
Field: "organization_id", Op: "eq", Value: tenant,
})
case maniflex.OpCreate, maniflex.OpUpdate:
if v, ok := ctx.Field("organization_id"); ok && v != tenant {
ctx.Abort(http.StatusForbidden, "TENANT_MISMATCH",
"organization_id does not match the authenticated tenant")
return nil
}
ctx.SetField("organization_id", tenant)
}
return next()
}
The middleware applies to the two tenanted models — Member and Project —
and to every operation. Organization itself is not scoped, because the auth
middleware already binds each token to exactly one organization.
Transactions — creating a project atomically
A new project should fail entirely if the member quota check fails. A Service
middleware wraps the operation in a transaction and uses
ctx.LockForUpdate to read the organization with a write lock:
func enforceProjectQuota(ctx *maniflex.ServerContext, next func() error) error {
org, err := ctx.LockForUpdate("Organization", ctx.Auth.TenantID)
if err != nil {
return err
}
rows, err := ctx.RawQuery(
`SELECT COUNT(*) AS n FROM projects WHERE organization_id = ? AND deleted_at IS NULL`,
ctx.Auth.TenantID,
)
if err != nil {
return err
}
count := rows[0]["n"].(int64)
limit := planLimit(org["plan"].(string))
if count >= limit {
ctx.Abort(http.StatusPaymentRequired, "PROJECT_LIMIT",
fmt.Sprintf("plan %q allows %d projects; upgrade to add more", org["plan"], limit))
return nil
}
return next()
}
func planLimit(plan string) int64 {
switch plan {
case "enterprise":
return 1000
case "pro":
return 25
default:
return 3
}
}
Registering the middleware
All three middlewares are registered in one place:
func registerMiddleware(s *maniflex.Server) {
// Auth on every write — reads are public within the tenant once they
// pass enforceTenant below; tighten or relax to taste.
s.Pipeline.Auth.Register(bearerAuth,
maniflex.ForOperation(maniflex.OpCreate, maniflex.OpUpdate, maniflex.OpDelete, maniflex.OpList, maniflex.OpRead),
)
// Tenant scoping for the two tenanted models.
s.Pipeline.Service.Register(enforceTenant,
maniflex.ForModel("Member", "Project"),
)
// The DB step is wrapped in a transaction for project creation, and the
// quota check runs inside it before next() reaches DB.
s.Pipeline.Service.Register(maniflex.WithTransaction(nil),
maniflex.ForModel("Project"), maniflex.ForOperation(maniflex.OpCreate),
)
s.Pipeline.Service.Register(enforceProjectQuota,
maniflex.ForModel("Project"), maniflex.ForOperation(maniflex.OpCreate),
)
}
Order matters: WithTransaction is registered before enforceProjectQuota, so
the transaction is open by the time the quota check calls ctx.LockForUpdate.
A request, end to end
# Alice (org-acme, owner) creates a project.
curl -X POST localhost:8080/api/projects \
-H 'Authorization: Bearer alice-token' \
-H 'Content-Type: application/json' \
-d '{"name":"Atlas","owner_id":"user-alice"}'
What happens:
- Auth —
bearerAuthresolvesalice-tokenand setsctx.Auth. - Deserialize — JSON body parsed into
ctx.ParsedBody. - Validate —
mfx:tag rules pass;organization_idis missing but the next step injects it. - Service:
enforceTenantwritesorganization_id = "org-acme"into the body.WithTransactionbegins a transaction.enforceProjectQuotalocks the organization row, counts existing projects, and either aborts with402 PROJECT_LIMITor proceeds.
- DB —
Createruns through the transaction;ctx.DBResultholds the inserted row. - Response — the envelope is written; the transaction commits.
Carol’s carol-token belongs to org-globex, so the same payload from her is
rejected before it reaches the DB step:
curl 'localhost:8080/api/projects?filter=organization_id:eq:org-acme' \
-H 'Authorization: Bearer carol-token'
# → list filtered to org-globex only; the requested filter is ignored
What this example showed
- Relations declared with both the convention (
OrganizationID) and the explicitrelation:Ownerform. WithDeletedAton every audited model.ctx.Authpopulated by an Auth middleware, then read by Service middleware to scope queries.ctx.Query.Filtersmodified to enforce a tenant invariant.maniflex.WithTransactionplusctx.LockForUpdatefor a check-and-act write.- Custom error codes (
TENANT_MISMATCH,PROJECT_LIMIT) emitted withctx.Abort.
Where to go next
The next section covers the ready-made middleware that ships with maniflex — the production-quality versions of the auth and validation helpers sketched here.
- Middleware Catalogue — JWT auth, password hashing, unique validation, audit logging, and more.
- Querying — the full filter, sort, and
includegrammar used in this example.
Middleware Catalogue
The catalogue is a set of ready-made middleware packages that cover the common
needs of every production API — authentication, validation, password hashing,
audit logging, caching, CORS, and so on. Each one is an ordinary
maniflex.MiddlewareFunc you register on the appropriate pipeline step, with the
same scoping options as any other middleware.
The packages live under maniflex/middleware/. Each one is its own Go module so a
project pulls in only the dependencies it actually uses:
| Package | Step | What it ships |
|---|---|---|
middleware/auth | Auth | JWT, API key, role gates, public-read helpers |
middleware/body | Deserialize / Validate | body size limits, unknown-field stripping, type coercion |
middleware/validate | Validate | uniqueness, regex, cross-field rules, numeric precision, date ranges, conditional required |
middleware/workflow | Validate | state-machine transitions with role-gated guards |
middleware/service | Service / DB-After | password hashing, slugify, derived fields, event emission, webhooks, email |
middleware/db | DB | tenancy, forced filters, rate limiting, audit log, cache invalidation |
middleware/response | Response | CORS, caching, transforms, redaction, envelopes, metrics |
middleware/openapi | OpenAPI.Generate | security schemes, servers, titles, custom extensions |
How to use the catalogue
Import the package you need and register the returned middleware on the matching pipeline step:
import (
"github.com/xaleel/maniflex/middleware/auth"
"github.com/xaleel/maniflex/middleware/service"
)
server.Pipeline.Auth.Register(
auth.JWTAuth("my-signing-secret", auth.JWTOptions{Issuer: "my-app"}),
)
server.Pipeline.Service.Register(
service.HashField("password"),
maniflex.ForModel("User"),
)
Each middleware factory returns a maniflex.MiddlewareFunc, so the standard
options — ForModel, ForOperation, AtPosition, WithName — apply
verbatim.
Composition
Catalogue middleware is designed to be composable. The expected stack for a typical REST API is roughly:
- Auth —
JWTAuthorAPIKeyAuthpopulatesctx.Auth;RequireRolegates sensitive operations. - Body —
MaxBodySizeandStripUnknownFieldsshape input early. - Validate — built-in tag rules plus
UniqueFieldand friends. - Service —
HashField,SetField,SlugifyField, then any custom business logic, thenEmit/Webhook/SendEmailon the After side. - DB —
TenancyorForceFilterenforces row-level scoping;AuditLogandInvalidaterun After. - Response —
CORSHeaders,Cache,RedactField, thenLogging/Metricson the After side.
Mix and match freely; nothing in the catalogue is required.
Writing your own
The catalogue is just an applied form of Writing Middleware.
If a built-in does not match your needs, write your own — the contract is the
same func(ctx *maniflex.ServerContext, next func() error) error signature.
Auth Middleware
The maniflex/middleware/auth package supplies authentication and authorisation
middleware for the Auth pipeline step. Each function returns a
maniflex.MiddlewareFunc that populates ctx.Auth on success or aborts with 401
or 403 on failure.
JWTAuth
Verifies a bearer JWT and populates ctx.Auth from its claims.
import "github.com/xaleel/maniflex/middleware/auth"
server.Pipeline.Auth.Register(
auth.JWTAuth("my-signing-secret", auth.JWTOptions{
Issuer: "my-app",
Audience: "api",
TenantClaim: "org_id", // copied into AuthInfo.TenantID
ScopesClaim: "scope", // copied into AuthInfo.Scopes
}),
)
Supports HMAC (HS256/384/512) when the secret is a string and asymmetric
algorithms (RS256/384/512) when JWTOptions.PublicKey is set — useful with
external identity providers (Auth0, Okta, Cognito, etc.). AuthMethod on
ctx.Auth is set to "jwt".
APIKeyAuth
Validates a static API key from a request header. Each entry maps one key to
the AuthInfo it grants.
server.Pipeline.Auth.Register(auth.APIKeyAuth("X-API-Key",
auth.APIKeyEntry{Key: "svc-abc", Auth: maniflex.AuthInfo{
UserID: "svc-1", Roles: []string{"admin"},
}},
auth.APIKeyEntry{Key: "svc-xyz", Auth: maniflex.AuthInfo{
UserID: "svc-2", Roles: []string{"reader"},
}},
))
AuthMethod on ctx.Auth is set to "api_key". Combine with JWTAuth on
the same step to accept either credential — the first match wins.
RequireRole
Rejects the request unless ctx.Auth.Roles contains the named role. Typically
registered with ForModel / ForOperation to scope where the check applies.
server.Pipeline.Auth.Register(
auth.RequireRole("admin"),
maniflex.ForModel("User"), maniflex.ForOperation(maniflex.OpDelete),
)
Anonymous requests (ctx.Auth == nil) are rejected with 401; authenticated
requests without the role get 403.
AllowPublicRead
A passthrough that exempts read operations from upstream auth requirements.
Register it before JWTAuth / APIKeyAuth to let unauthenticated callers
hit GET routes while keeping writes locked down.
server.Pipeline.Auth.Register(auth.AllowPublicRead())
server.Pipeline.Auth.Register(auth.JWTAuth("..."),
maniflex.ForOperation(maniflex.OpCreate, maniflex.OpUpdate, maniflex.OpDelete),
)
BlockOperation
Refuses the listed operations outright, regardless of identity. Useful for making a model effectively read-only at the HTTP layer.
server.Pipeline.Auth.Register(
auth.BlockOperation(maniflex.OpCreate, maniflex.OpUpdate, maniflex.OpDelete),
maniflex.ForModel("AuditLog"),
)
The model’s routes remain mounted but always return 405 METHOD_BLOCKED.
Scoping patterns
The middleware in this package combines with ForModel / ForOperation to
build per-route policy without writing custom Auth code:
// Public reads, JWT writes, admin-only deletes
server.Pipeline.Auth.Register(auth.AllowPublicRead())
server.Pipeline.Auth.Register(auth.JWTAuth("..."),
maniflex.ForOperation(maniflex.OpCreate, maniflex.OpUpdate, maniflex.OpDelete))
server.Pipeline.Auth.Register(auth.RequireRole("admin"),
maniflex.ForOperation(maniflex.OpDelete))
Body Middleware
The maniflex/middleware/body package shapes the request body during the
Deserialize and Validate steps — before the framework’s tag rules run.
MaxBodySize
Overrides the default 4 MB body limit for the current request. Register on the Deserialize step, scoped to the model that needs the larger limit:
import "github.com/xaleel/maniflex/middleware/body"
server.Pipeline.Deserialize.Register(
body.MaxBodySize(16 << 20), // 16 MB
maniflex.ForModel("Article"),
)
Requests over the limit are aborted with 400 BODY_READ_ERROR before the JSON
parser runs.
StripUnknownFields
Removes keys from ctx.ParsedBody that do not correspond to a model field.
Register on the Validate step (or Deserialize After-position) so the cleanup
happens before tag validation and the DB step:
server.Pipeline.Validate.Register(body.StripUnknownFields())
The default behaviour is to accept and silently ignore unknown fields. Use this middleware to enforce a stricter contract when desired.
CoerceTypes
Coerces string values in ctx.ParsedBody into the Go type declared on the
model — "42" → 42, "true" → true, ISO-8601 strings → time.Time. Helps
when the client sends form-encoded or query-string-shaped payloads.
server.Pipeline.Validate.Register(body.CoerceTypes())
Coercion happens before the framework’s min / max / enum checks, so
numeric ranges and enums work against the coerced values.
Validate Middleware
The maniflex/middleware/validate package supplies validators that go beyond what
mfx: tags can express. Each one runs on the Validate step alongside the
built-in tag enforcement and aborts with 422 VALIDATION_FAILED on rejection.
UniqueField
Rejects a create or update whose value collides with an existing row.
import "github.com/xaleel/maniflex/middleware/validate"
server.Pipeline.Validate.Register(
validate.UniqueField(sqlDB, maniflex.Postgres, "email"),
maniflex.ForModel("User"),
)
The middleware runs a count query against the underlying database before the
DB step. Compared with the mfx:"unique" schema hint, it produces a structured
422 with the offending field instead of a 409 from a constraint violation
later.
The driver argument selects the placeholder dialect (maniflex.Postgres →
$N, maniflex.SQLite → ?) and must match the driver used to open the
adapter. The third argument is the JSON field name; it is resolved to the
underlying DB column via ctx.Model.FieldByJSONName.
RegexField
Validates that a string field matches a regular expression:
server.Pipeline.Validate.Register(
validate.RegexField("phone", `^\+?[0-9]{7,15}$`),
maniflex.ForModel("Contact"),
)
A non-matching value aborts with 422 and the field name in details.
ForbiddenValues
Rejects writes that contain any of the listed values for a field. Use it for defence-in-depth on enum-like fields where the mfx enum tag would still allow a privileged value:
server.Pipeline.Validate.Register(
validate.ForbiddenValues("role", "superadmin", "root"),
maniflex.ForModel("User"),
)
RequireAtLeastOne
Ensures at least one of the named fields is present in the request body. Most
useful on OpUpdate, where every field is otherwise optional:
server.Pipeline.Validate.Register(
validate.RequireAtLeastOne("name", "email"),
maniflex.ForOperation(maniflex.OpUpdate),
)
NumericPrecision
Enforces decimal precision and scale on a numeric field. The check is a
string-parse, so it works regardless of how the column is stored
(INTEGER, NUMERIC(p,s), TEXT, custom SQLTyper):
server.Pipeline.Validate.Register(
validate.NumericPrecision("amount", 19, 4), // up to 19 total digits, max 4 after the point
maniflex.ForModel("Invoice"),
)
precision— maximum total significant digits (integer + fractional). Pass0to disable.scale— maximum digits after the decimal point. Pass0to disable.
Sign (+/-), leading zeros, and trailing fractional zeros do not count
toward either limit. Scientific notation (1e3) is rejected because its
implied precision is ambiguous — supply financial values in plain form.
Absent and null values are skipped; pair with required when presence
matters.
CrossFieldValidate
A general-purpose escape hatch for rules that span multiple fields:
server.Pipeline.Validate.Register(
validate.CrossFieldValidate(func(body map[string]any) error {
if body["status"] == "scheduled" && body["scheduled_at"] == nil {
return fmt.Errorf("scheduled_at is required when status is scheduled")
}
return nil
}),
maniflex.ForModel("Post"),
)
The returned error becomes the message of a 422 VALIDATION_FAILED response.
DateRange
Ensures an end field is not before a start field. Accepts RFC3339 timestamps
("2026-05-01T08:00:00Z") and YYYY-MM-DD date strings. If either field is
absent, null, or unparseable, the rule passes silently — pair with required
or another rule when presence matters.
server.Pipeline.Validate.Register(
validate.DateRange("start_date", "end_date"),
maniflex.ForModel("Booking"),
maniflex.ForOperation(maniflex.OpCreate, maniflex.OpUpdate),
)
Equal dates are accepted (start == end). A 422 VALIDATION_ERROR is returned
with the end field named in details when the end precedes the start.
RequireWhen
Makes a field required only when other fields satisfy all listed conditions.
Each condition is a "field:op:value" string; op is one of eq, ne, gt,
gte, lt, lte. Multiple conditions are ANDed. Invalid syntax panics at
startup so misconfiguration is caught before the first request.
// Require rejection_reason whenever status is "rejected"
server.Pipeline.Validate.Register(
validate.RequireWhen("rejection_reason", "status:eq:rejected"),
maniflex.ForModel("Claim"),
)
// Require shipping_address only for physical orders with priority >= 3
server.Pipeline.Validate.Register(
validate.RequireWhen("shipping_address", "order_type:eq:physical", "priority:gte:3"),
maniflex.ForModel("Order"),
)
When the conditions are met but the target field is absent, null, or empty
string, the request is rejected with 422 VALIDATION_ERROR. When the
conditions are not all met, the rule passes regardless of the target field’s
value — it does not prevent the field from being supplied.
Numeric comparisons (gt, gte, lt, lte) coerce both the body value and
the condition value to float64. Non-numeric body values cause the condition to
evaluate as false (the target field stays optional).
Workflow Middleware
maniflex/middleware/workflow enforces a state-machine on a model’s status
field. Declare the permitted transitions once; the middleware rejects writes
that would move a record between states the workflow does not allow, or that
fail a guard (e.g. role check).
import "github.com/xaleel/maniflex/middleware/workflow"
sm := workflow.New("status",
workflow.Allow("draft", "submitted"),
workflow.Allow("submitted", "approved", workflow.RequireRole("manager")),
workflow.Allow("submitted", "rejected", workflow.RequireRole("manager")),
workflow.Allow("approved", "paid", workflow.RequireRole("finance")),
workflow.AllowAny(workflow.RequireRole("admin")), // admin escape hatch
workflow.AllowInitial("draft", "submitted"), // legal seed states on Create
)
server.Pipeline.Validate.Register(
sm.Middleware(),
maniflex.ForModel("Invoice"),
maniflex.ForOperation(maniflex.OpCreate, maniflex.OpUpdate),
)
How it runs
The middleware lives on the Validate step.
- On
OpCreate— ifAllowInitialis declared, the value of the chosen field must be in the set; otherwise any initial value passes. No guards apply on Create (the Create itself is its own authorisation surface). - On
OpUpdate— if the body does not include the status field, the middleware is a no-op. Otherwise it reads the current record viactx.GetModel(modelName).Read(id)(so reads participate inctx.Txwhen active), extractsfrom, compares to the body’sto, and:- same-state writes (
from == to) pass silently; - the first matching rule wins; its guards run in order;
- the first guard error rejects the transition with
422 INVALID_TRANSITION.
- same-state writes (
A PATCH that triggers the read also costs one round-trip. Stash the loaded
record on ctx.Set if you need it again later in the request.
Rules
| Option | Effect |
|---|---|
Allow(from, to, guards...) | permit from → to; both sides may be "*" for “any” |
AllowAny(guards...) | shorthand for Allow("*", "*", guards...) |
AllowInitial(states...) | restrict Create to the listed initial states |
Rule matching is a linear scan in declaration order — write narrow rules before broad ones if you want them to take precedence.
Guards
type Guard interface {
Check(ctx *maniflex.ServerContext, from, to string) error
}
RequireRole(roles ...string)— pass if the caller holds any one of the listed roles. OR-semantics; passing zero roles always rejects (a defensive choice against accidentally unguarded “require” rules).GuardFunc— adapt anyfunc(ctx, from, to) errorto the interface.
A non-nil guard error becomes the response message:
{
"error": {
"code": "INVALID_TRANSITION",
"message": "role [manager] required for transition \"submitted\" → \"approved\"",
"details": [{"field": "status", "from": "submitted", "to": "approved"}]
},
"status": 422
}
Status field type
Values are compared as strings via fmt.Sprintf("%v", v), matching
validate.ForbiddenValues. This covers string, int, and custom enum
types.
Service Middleware
The maniflex/middleware/service package supplies business-logic helpers for the
Service step — field transforms, derived values, owner-scoping — and
side-effect helpers (events, webhooks, email) for the DB-After step.
Field transforms
HashField
Replaces a plaintext field with its bcrypt hash before the DB step. Standard choice for passwords:
import "github.com/xaleel/maniflex/middleware/service"
server.Pipeline.Service.Register(
service.HashField("password"),
maniflex.ForModel("User"),
)
The bcrypt cost can be configured via a second argument; the default is suitable for production.
SlugifyField
Derives a slug field from a source field on create:
server.Pipeline.Service.Register(
service.SlugifyField("title", "slug"),
maniflex.ForModel("Post"), maniflex.ForOperation(maniflex.OpCreate),
)
Punctuation is stripped, spaces become hyphens, and the result is lowercased.
SetField
Sets a field on every create or update based on context — typically pulling
identity from ctx.Auth:
server.Pipeline.Service.Register(
service.SetField("user_id", func(ctx *maniflex.ServerContext) any {
return ctx.Auth.UserID
}),
maniflex.ForOperation(maniflex.OpCreate),
)
StripField
Removes a field from the request body (and the typed record) before the DB step. Useful for input-only confirmation fields that should never reach the database:
server.Pipeline.Service.Register(service.StripField("password_confirm"))
TimestampWhen
Sets a timestamp column when another field transitions to a specific value —
for example, recording published_at the moment status becomes
"published":
server.Pipeline.Service.Register(
service.TimestampWhen("published_at", "status", "published"),
maniflex.ForModel("Post"),
)
Authorisation
OwnerScope
Forces a user-id field to the authenticated caller on create. Equivalent to
SetField("user_id", …) plus a refusal if the client tries to spoof a
different value:
server.Pipeline.Service.Register(
service.OwnerScope("user_id"),
maniflex.ForOperation(maniflex.OpCreate),
)
Side effects
These middleware all run on the DB step at maniflex.After position, so they
fire only when the database write has succeeded.
Emit
Publishes a domain event to a configured event bus after every mutating operation:
server.Pipeline.DB.Register(
service.Emit(myBus),
maniflex.ForOperation(maniflex.OpCreate, maniflex.OpUpdate, maniflex.OpDelete),
maniflex.AtPosition(maniflex.After),
)
myBus implements the event interface from maniflex/events; satellite packages
exist for Kafka, NATS, RabbitMQ, and Redis. See
Events & Background Jobs.
Webhook
POSTs the affected record to an external URL with an HMAC signature:
server.Pipeline.DB.Register(
service.Webhook(service.WebhookConfig{
URL: "https://hooks.example.com/orders",
Secret: "whsec_…",
}),
maniflex.ForModel("Order"),
maniflex.AtPosition(maniflex.After),
)
SendEmail
Sends a transactional email after a write. The factory takes a mailer and a function that builds the message from the request context:
server.Pipeline.DB.Register(
service.SendEmail(mailer, func(ctx *maniflex.ServerContext) *service.EmailMessage {
user := ctx.DBResult.(map[string]any)
return &service.EmailMessage{
To: user["email"].(string),
Subject: "Welcome",
Body: "Thanks for signing up.",
}
}),
maniflex.ForModel("User"), maniflex.ForOperation(maniflex.OpCreate),
maniflex.AtPosition(maniflex.After),
)
Pairing with transactions
Side-effect middleware runs inside the request transaction when
maniflex.WithTransaction is registered. If the transaction rolls back the
side-effect already happened — outbound emails do not unsend themselves. Pair
event emission with a transactional outbox (see
Events & Background Jobs) when this matters.
DB Middleware
The maniflex/middleware/db package wraps the DB step with row-level
scoping, request budgeting, post-write hooks, and result caching.
Row-level scoping
ForceFilter
Injects a filter on every list, read, update, and delete, regardless of what the client requested. Used to enforce invariants the client cannot override:
import "github.com/xaleel/maniflex/middleware/db"
server.Pipeline.DB.Register(
db.ForceFilter("org_id", func(ctx *maniflex.ServerContext) any {
return ctx.Auth.Claims["org_id"]
}),
)
Tenancy
A specialised ForceFilter for the common multi-tenant case. Reads the tenant
id from ctx.Auth and pins every query to it:
server.Pipeline.DB.Register(
db.Tenancy("org_id", func(ctx *maniflex.ServerContext) string {
return ctx.Auth.Claims["org_id"].(string)
}),
)
Tenancy also rewrites the org_id field on creates and updates so a tenant
cannot place rows into another tenant’s bucket.
Request budgeting
Paginate
Caps the maximum ?limit= accepted on list responses. Per-model overrides are
common for tables whose rows are expensive to render:
server.Pipeline.DB.Register(db.Paginate(50), maniflex.ForModel("AuditLog"))
RateLimit
A token-bucket rate limiter scoped by IP or by authenticated user:
server.Pipeline.DB.Register(
db.RateLimit(db.RateLimitConfig{
RequestsPerMinute: 10,
Key: func(ctx *maniflex.ServerContext) string {
if ctx.Auth != nil {
return ctx.Auth.UserID
}
return ctx.Request.RemoteAddr
},
}),
maniflex.ForModel("PasswordReset"),
)
Rejected requests receive 429 RATE_LIMITED.
Post-write hooks
These run at maniflex.After position so they only fire when the database write
succeeded.
AuditLog
Writes one audit record per mutating operation to a configured sink:
server.Pipeline.DB.Register(
db.AuditLog(mySink),
maniflex.ForOperation(maniflex.OpCreate, maniflex.OpUpdate, maniflex.OpDelete),
maniflex.AtPosition(maniflex.After),
)
mySink is anything implementing the audit interface — a logger, a database
table, an external SIEM. The record carries the operation, model name, actor
(from ctx.Auth), and a JSON diff of the affected row.
Invalidate
Invalidates cache keys when a row changes. The key list is computed per request:
server.Pipeline.DB.Register(
db.Invalidate(redisCache, func(ctx *maniflex.ServerContext) []string {
return []string{
"posts:list",
fmt.Sprintf("post:%s", ctx.ResourceID),
}
}),
maniflex.ForModel("Post"),
maniflex.AtPosition(maniflex.After),
)
CacheQuery
Memoises read results (OpRead, OpList) in a CacheStore — the read-side
complement to Invalidate. On a cache hit it sets ctx.DBResult and the
adapter read is skipped; the Response step renders the cached result. On a miss
it runs the read and stores the result for TTL. Pair it with Invalidate on
writes to evict stale entries.
cache := maniflex.NewMemoryCache() // or a Redis-backed CacheStore
server.Pipeline.DB.Register(
db.CacheQuery(cache, db.CacheConfig{
TTL: 5 * time.Minute,
KeyFunc: func(ctx *maniflex.ServerContext) string {
// Only cache the common, high-traffic query shapes. Requests that
// filter on `name` or carry a ?q= search are typically long-tail,
// ad-hoc lookups — caching them floods the store with low-value
// entries that are rarely read back, so skip them by returning "".
if q := ctx.Query; q != nil {
if q.Search != "" {
return ""
}
for _, f := range q.Filters {
if f.Field == "name" {
return ""
}
}
}
return "products:list:" + ctx.Request.URL.RawQuery
},
}),
maniflex.ForModel("Product"),
maniflex.ForOperation(maniflex.OpList, maniflex.OpRead),
)
server.Pipeline.DB.Register(
db.Invalidate(cache, func(*maniflex.ServerContext) []string {
return []string{"products:list:..."}
}),
maniflex.ForModel("Product"),
maniflex.ForOperation(maniflex.OpCreate, maniflex.OpUpdate, maniflex.OpDelete),
maniflex.AtPosition(maniflex.After),
)
KeyFunc must capture every input that changes the result (model, tenant,
filters, sort, pagination, includes); returning "" skips the cache for that
request. The value stored is ctx.DBResult, so a distributed CacheStore must
round-trip a *maniflex.ListResult for lists — a store that decodes list
entries into a bare map is treated as a miss rather than panicking. Avoid
caching mfx:"encrypted" models, since the decrypted result would live in the
cache.
Ordering
Row-level scopers (ForceFilter, Tenancy) must run Before the default DB
step — the default position — so the filter is in place when the SELECT or
UPDATE runs. Post-write hooks must run After, so they observe the result.
The package’s defaults follow this; only change them if you know why.
Response Middleware
The maniflex/middleware/response package shapes the outgoing response —
headers, body transforms, redactions, and observability hooks — on the
Response step.
Cross-cutting headers
CORSHeaders
Adds CORS headers to every response. Reasonable defaults; pass options to restrict origins, methods, or credentials.
import "github.com/xaleel/maniflex/middleware/response"
server.Pipeline.Response.Register(response.CORSHeaders())
AddHeader
Sets one static header on every response:
server.Pipeline.Response.Register(
response.AddHeader("Strict-Transport-Security", "max-age=63072000"),
)
Caching
Cache
Sets Cache-Control: public, max-age=N on successful reads. Register at
maniflex.After so the framework’s own headers do not override yours:
server.Pipeline.Response.Register(
response.Cache(300), // 5 minutes
maniflex.ForOperation(maniflex.OpRead, maniflex.OpList),
maniflex.AtPosition(maniflex.After),
)
Body transforms
TransformField
Rewrites a single field value before serialisation. Common use: rebasing a stored relative path onto a CDN host.
server.Pipeline.Response.Register(
response.TransformField("avatar_url", func(v any) any {
return cdnBase + v.(string)
}),
)
RedactField
Hides a field from the response conditionally. The predicate decides per
request, often based on ctx.Auth:
server.Pipeline.Response.Register(
response.RedactField("phone", func(ctx *maniflex.ServerContext) bool {
return !ctx.HasRole("support")
}),
)
RedactField is the right tool for view-time access control on individual
columns. For all-or-nothing exclusion across an entire model, the hidden or
writeonly field tag is simpler.
Envelope
Replaces the default {"data": ...} envelope with one of your own:
server.Pipeline.Response.Register(
response.Envelope(func(ctx *maniflex.ServerContext, data any, meta *maniflex.ResponseMeta) any {
return map[string]any{
"result": data,
"paging": meta,
"trace_id": ctx.TraceID,
}
}),
)
Useful when integrating with a frontend or API gateway that expects a different shape. Error responses are unaffected; only success responses are re-enveloped.
Observability
Logging
Writes a structured access log line per request at maniflex.After:
server.Pipeline.Response.Register(
response.Logging(slog.Default()),
maniflex.AtPosition(maniflex.After),
)
The line carries request ID, trace ID, method, path, status, duration, and the authenticated user when set.
Metrics
Records per-request metrics — count, latency, status class — into a configured collector:
server.Pipeline.Response.Register(
response.Metrics(myCollector),
maniflex.AtPosition(maniflex.After),
)
A reference Prometheus collector is provided; any sink with the same interface works.
OpenAPI Middleware
The maniflex/middleware/openapi package customises the auto-generated OpenAPI
3.1 specification served at GET /openapi.json. Every middleware here is
registered on the Pipeline.OpenAPI.Generate step at maniflex.After
position, so it sees the framework’s generated spec and can mutate it before
the Response step serialises it.
SetTitle, SetDescription
Override the default title and description, which are derived from
Config.ServiceName:
import "github.com/xaleel/maniflex/middleware/openapi"
server.Pipeline.OpenAPI.Generate.Register(
openapi.SetTitle("Orders API"),
maniflex.After,
)
server.Pipeline.OpenAPI.Generate.Register(
openapi.SetDescription("# Orders API\nProduction endpoints for the orders service."),
maniflex.After,
)
AddServer
Declares a server URL in servers[]. Repeat for multiple environments:
server.Pipeline.OpenAPI.Generate.Register(
openapi.AddServer("https://api.example.com", "Production"), maniflex.After)
server.Pipeline.OpenAPI.Generate.Register(
openapi.AddServer("https://staging.example.com", "Staging"), maniflex.After)
Without AddServer the spec carries no servers array, leaving clients to
resolve URLs against the host that served the spec.
AddSecurityScheme
Adds a security scheme to components.securitySchemes and applies it to
every operation generated by the framework:
server.Pipeline.OpenAPI.Generate.Register(
openapi.AddSecurityScheme("bearerAuth", maniflex.OASSecurityScheme{
Type: "http",
Scheme: "bearer",
BearerFormat: "JWT",
}),
maniflex.After,
)
Pair with auth.JWTAuth on the runtime side. For API keys,
use Type: "apiKey" and set In and Name.
AddExtension
A general-purpose escape hatch — receives the full *maniflex.OpenAPISpec and lets
you mutate any part of it:
server.Pipeline.OpenAPI.Generate.Register(
openapi.AddExtension(func(spec *maniflex.OpenAPISpec) {
spec.Info.Contact = &maniflex.OASContact{
Name: "API team",
Email: "api@example.com",
}
spec.Info.License = &maniflex.OASLicense{Name: "MIT"}
}),
maniflex.After,
)
Use sparingly — anything you can express through the typed helpers is easier to read.
Securing the spec itself
To gate access to /openapi.json, register an Auth middleware on
Pipeline.OpenAPI.Auth — it has the same shape as the model-route Auth step:
server.Pipeline.OpenAPI.Auth.Register(auth.RequireRole("internal"))
This is the standard pattern for keeping internal APIs out of the public view while leaving the public surface documented.
Querying
Every generated list and read endpoint accepts the same query parameters —
page, limit, filter, sort, include, and select. This page
documents their grammar and the fields that opt in to each.
page and limit
Standard offset pagination.
?page=2&limit=20
| Parameter | Default | Maximum |
|---|---|---|
page | 1 | unbounded |
limit | 20 | 200 |
Values above the maximum are clamped silently. Negative or non-numeric values
are rejected with 400 INVALID_QUERY.
The response carries meta.total, meta.page, meta.limit, and meta.pages
— see Response Envelope.
cursor (keyset pagination)
Offset pagination skips or duplicates rows when the dataset changes between page fetches — delete a row on page 1 and page 2 silently jumps a record. Keyset (cursor) pagination walks the data by a stable ordering key instead, so the window never shifts. Opt a model in by naming a sortable, effectively monotonic cursor column:
type Event struct {
maniflex.BaseModel `mfx:"cursor_field:created_at"` // created_at is sortable on BaseModel
Name string `json:"name" db:"name"`
}
Equivalently, set ModelConfig.CursorField: "created_at" at registration, or
put mfx:"...,cursor_field:<name>" on any of the model’s own fields.
The presence of ?cursor= switches the request into keyset mode (it supersedes
?page). Send an empty value for the first page, then the meta.next_cursor
from each response to fetch the next:
GET /events?cursor=&limit=20 → first page
GET /events?cursor=<next_cursor>&limit=20 → following page
The walk is ordered by (cursor_field, id) — id is the implicit tiebreaker so
the order is total even when the cursor column ties. The default direction is
ascending; sort on the cursor field to reverse it:
GET /events?cursor=&sort=created_at:desc
Any ?sort= on a different field is rejected with 400 in cursor mode, since
the keyset order is fixed to the cursor column.
Cursor responses carry a different meta shape — no total/page/pages
(the count is skipped, which is the point on large tables):
{ "data": [ ... ], "meta": { "limit": 20, "next_cursor": "eyJ2Ijoi...", "has_more": true } }
has_more is false and next_cursor is omitted on the last page. The token is
opaque — treat it as a string and pass it back verbatim.
filter
Each filter is a colon-separated triple — field, operator, value:
?filter=status:eq:published
?filter=views:gt:100
?filter=created_at:gte:2025-01-01
Multiple filters combine with AND:
?filter=status:eq:published&filter=views:gt:100
Filters reference a field by its json name. Only fields tagged
mfx:"filterable" may be used; unknown or non-filterable references abort the
request with 400 INVALID_QUERY.
Operators
| Operator | Effect | Value |
|---|---|---|
eq | field = value | one value |
neq | field ≠ value | one value |
gt, gte, lt, lte | numeric and date comparisons | one value |
like | SQL LIKE, case-sensitive | one value, % wildcards |
ilike | SQL ILIKE, case-insensitive | one value, % wildcards |
in | field IN (…) | comma-separated values |
not_in | field NOT IN (…) | comma-separated values |
between | field ≥ lo AND ≤ hi (inclusive) | exactly two comma-separated values lo,hi |
is_null | field IS NULL | no value |
not_null | field IS NOT NULL | no value |
?filter=tag:in:go,rust,zig
?filter=amount:between:100,500
?filter=created_at:between:2025-01-01,2025-03-31
?filter=archived_at:is_null
?filter=title:ilike:%intro%
Filtering on related fields
When a relation is declared on the model, you can filter by a field on the related table using dot notation:
?filter=user.role:eq:admin
?filter=posts.status:eq:published
The related field must itself be filterable. The framework joins the related
table for the query; no separate ?include= is required to filter on it (but
you still need ?include= to return the related row).
q (full-text search)
?q= runs a native full-text search over every field tagged mfx:"searchable"
and orders the results by match relevance:
?q=hello world
?q=postgres&filter=tag:eq:db
This is distinct from filter: full-text search uses the database’s own
ranking, stemming, and tokenisation rather than literal comparison, so ?q=run
also matches running, and the densest match ranks first. The backend’s native
machinery does the work — a tsvector column and GIN index on PostgreSQL, an
FTS5 index on SQLite — both provisioned automatically during migration.
- Only models with at least one
mfx:"searchable"field accept?q=; on any other model it aborts with400 INVALID_QUERY. Searchable fields must be text columns. ?q=combines with?filter=(ANDed) and the usual?page=/?limit=offset pagination. It cannot be combined with?cursor=, since keyset order and relevance order are mutually exclusive.- An empty value (
?q=) is ignored — the list is returned unfiltered. - On PostgreSQL the text-search configuration defaults to
english; override it per model withModelConfig.SearchLanguage.
type Article struct {
maniflex.BaseModel
Title string `json:"title" db:"title" mfx:"required,searchable"`
Body string `json:"body" db:"body" mfx:"searchable"`
}
// GET /articles?q=keyset+pagination → relevance-ranked matches
sort
Each sort is field:direction:
?sort=created_at:desc
?sort=title:asc
Multiple sorts compose left-to-right (primary, secondary, …):
?sort=status:asc&sort=created_at:desc
Only fields tagged mfx:"sortable" may be used. BaseModel’s created_at and
updated_at are sortable by default.
Sorting on a relation field
Use relation.field to sort by a column on a BelongsTo parent. The server
adds the LEFT JOIN automatically — no filter or include on that relation is
required:
?sort=user.name:asc
?sort=vendor.name:desc&filter=status:eq:open
The related field must be tagged mfx:"sortable" on the parent model. Only
BelongsTo relations are supported; relation.field on a HasMany or
ManyToMany returns 400, as does an unknown relation or a non-sortable
related field.
include
Loads related records inline. The value is a comma-separated list of relation keys:
?include=user
?include=user,comments
Each key becomes a nested object (for BelongsTo) or array (for HasMany and
ManyToMany) on the returned row. See Relations for how
relation keys are derived.
Includes are populated by separate queries after the main query — they do not multiply rows or affect pagination.
select
Request a subset of fields instead of the full row. Useful for wide tables (payroll, product catalogues with 40+ attributes) where most columns are irrelevant to the caller.
?select=id,name,department
?select=id,amount,status
The value is a comma-separated list of JSON field names. Unknown names
abort the request with 400 INVALID_QUERY. Fields tagged mfx:"hidden" or
mfx:"writeonly" are still stripped from the response even if explicitly
selected — the projection happens at the database layer, not as an ACL bypass.
?select= applies to both list (GET /:model) and read
(GET /:model/:id) endpoints. It can be combined freely with filter, sort,
and include.
Putting it together
A complete request that exercises all parameters:
GET /api/posts
?filter=status:eq:published
&filter=views:gte:100
&sort=created_at:desc
&include=user,comments
&select=id,title,views,status
&page=1
&limit=20
The framework parses the query string once in the Deserialize step into
ctx.Query (a *QueryParams), which middleware can read and modify before
the DB step. Tenant-scoping middleware, for example, appends a filter to
ctx.Query.Filters to enforce row-level access — see
Example 2.
Searching
maniflex has two layers of full-text search:
- Per-model search — the
?q=parameter on a model’s list endpoint, over itsmfx:"searchable"fields. Covered in Querying. - Cross-model search — search several models at once and merge the hits into
one relevance-ranked list. That is what this page documents: the
ctx.Searchprimitive and the built-inGET /searchendpoint.
Both use the database’s native full-text engine (PostgreSQL tsvector /
ts_rank, SQLite FTS5 / bm25), provisioned automatically for every model that
declares mfx:"searchable" fields. Free-form input is sanitised, so a query can
never be a syntax error.
The ctx.Search primitive
ctx.Search runs a cross-model search and returns the merged, relevance-ranked
hits. Use it from a custom Action to build search
endpoints scoped to exactly the models you choose, with your own authorisation:
server.Action(maniflex.ActionConfig{
Method: "GET", Path: "/search-community",
Middleware: []maniflex.MiddlewareFunc{communityAuth},
Handler: func(ctx *maniflex.ServerContext) error {
hits, err := ctx.Search(maniflex.SearchOptions{
Query: ctx.QueryParam("q"),
Models: []string{"Post", "Comment"}, // explicit, app-authorised set
Limit: 20,
})
if err != nil {
ctx.Abort(400, "SEARCH_ERROR", err.Error())
return nil
}
ctx.Response = &maniflex.APIResponse{StatusCode: 200, Data: hits}
return nil
},
})
type SearchOptions struct {
Query string // the search text; blank → no-op (no results)
Models []string // models to search; empty → all GlobalSearchable models
Limit int // max merged results; <= 0 → 20
PerModelLimit int // fairness cap (see Merge order); <= 0 → pure relevance
}
type SearchResult struct {
Model string `json:"model"` // the model the hit came from
ID string `json:"id"` // primary key of the matched row
Snippet string `json:"snippet"` // excerpt of the matched text
Score float64 `json:"score"` // relevance, higher = more relevant
}
func (c *ServerContext) Search(opts SearchOptions) ([]SearchResult, error)
With an explicit Models list each named model only needs mfx:"searchable"
fields — it does not need GlobalSearchable. That flag governs only the
built-in endpoint below; the Action path is yours to authorise. ctx.Search
participates in ctx.Tx when one is active and excludes soft-deleted rows.
The built-in GET /search endpoint
Enable it explicitly, then opt models in with ModelConfig.GlobalSearchable:
server.EnableGlobalSearch() // mounts GET {PathPrefix}/search
server.MustRegister(
Post{}, maniflex.ModelConfig{GlobalSearchable: true},
Comment{}, maniflex.ModelConfig{GlobalSearchable: true},
Product{}, maniflex.ModelConfig{GlobalSearchable: true},
)
GlobalSearchable requires the model to declare at least one mfx:"searchable"
field; registration fails otherwise.
GET /api/search?q=wireless+headphones
GET /api/search?q=invoice&limit=10&models=Product
| Parameter | Default | Notes |
|---|---|---|
q | — | Required. Blank → 400 INVALID_QUERY. |
limit | 20 | Clamped to the configured maximum (default 100). |
models | all | Comma-separated subset; each name must be a GlobalSearchable model, else 400. |
The response is the standard envelope with a flat array of hits, ordered by score descending:
{
"data": [
{"model": "Product", "id": "9f8…", "snippet": "wireless …", "score": 0.61},
{"model": "Post", "id": "1a2…", "snippet": "… wireless", "score": 0.18}
]
}
Configure the route and limits via EnableGlobalSearch:
server.EnableGlobalSearch(maniflex.GlobalSearchConfig{
Path: "/search",
DefaultLimit: 20,
MaxLimit: 100,
})
Authorization
The endpoint runs only the global Auth pipeline step — it does not apply
per-model auth or tenancy middleware. Gate it with Pipeline.Auth middleware,
either globally or scoped to the search operation:
server.Pipeline.Auth.Register(requireLogin, maniflex.ForOperation(maniflex.OpSearch))
Because per-model row-level rules are not applied, only set GlobalSearchable on
models that are safe to expose this way. When you need per-model authorisation,
build a scoped Action with ctx.Search instead (see above) and attach your own
middleware. Middleware registered for OpSearch on the Deserialize, Validate,
Service, or DB steps never runs (the endpoint skips them) and is reported with a
startup warning.
Merge order
A deployment uses one database driver, so every model shares one ranking function and the scores are directly comparable; results merge by score descending.
By default the merge is pure relevance — if one model’s hits dominate, the
result can be entirely from that model. Set PerModelLimit to give each model a
fair share: the merge first takes up to PerModelLimit of each model’s
top-scoring hits, then backfills any remaining slots up to Limit from the
leftovers (best score first, regardless of model). It is a fair-chance floor, not
a hard ceiling — the result still fills to Limit when some models have fewer
hits.
Cross-model scores are a heuristic: bm25 and
ts_rankdepend on each table’s own corpus statistics, so a common term can score higher in a table where it is rarer. UsePerModelLimitwhen you want guaranteed representation across models rather than a pure score ranking.
Response Envelope
Every response from a generated route follows one of two shapes — the data envelope or the error envelope. This page documents both, along with the status codes the framework emits.
Success envelope
A successful single-row response — OpRead, OpCreate, OpUpdate:
{
"data": {
"id": "8c1a…",
"title": "First post",
"created_at": "2026-05-19T12:34:56Z",
"updated_at": "2026-05-19T12:34:56Z"
}
}
A successful list response carries the same data key plus a meta block:
{
"data": [
{ "id": "8c1a…", "title": "First post", ... },
{ "id": "9d2b…", "title": "Second post", ... }
],
"meta": {
"total": 137,
"page": 1,
"limit": 20,
"pages": 7
}
}
meta field | Meaning |
|---|---|
total | total matching rows across all pages |
page | page number returned (1-based) |
limit | rows per page |
pages | total page count, computed as ceil(total/limit) |
When a request uses cursor (keyset) pagination
(?cursor=), the meta block takes a different shape — no total/page/pages
(the count is skipped):
{ "data": [ ... ], "meta": { "limit": 20, "next_cursor": "eyJ2Ijoi...", "has_more": true } }
DELETE returns 204 No Content with no body.
Error envelope
Every error response uses:
{
"error": {
"code": "VALIDATION_FAILED",
"message": "one or more fields failed validation",
"details": [
{ "field": "email", "message": "field \"email\" is required" },
{ "field": "password", "message": "must be at least 8 characters" }
]
}
}
| Field | Meaning |
|---|---|
code | machine-readable identifier (e.g. NOT_FOUND, CONFLICT) |
message | human-readable summary |
details | optional structured payload — per-field errors, raw driver detail, etc. |
The catalogue of built-in codes is in Error Handling.
Status codes
| Operation | Success | Notable errors |
|---|---|---|
OpList | 200 OK | 400 INVALID_QUERY |
OpRead | 200 OK | 404 NOT_FOUND |
OpCreate | 201 Created | 400 INVALID_JSON, 409 CONFLICT, 422 VALIDATION_FAILED |
OpUpdate | 200 OK | 404 NOT_FOUND, 409 CONFLICT, 422 VALIDATION_FAILED |
OpDelete | 204 No Content | 404 NOT_FOUND |
HEAD and OPTIONS return 200 with no body.
Headers
Every response carries:
| Header | Source |
|---|---|
Content-Type: application/json | always |
X-Request-Id | echoed from chi’s RequestID middleware |
X-Service-Name | when Config.ServiceName is set |
Custom middleware can add more — see Response Middleware
for AddHeader, CORSHeaders, Cache, and friends.
Computed (virtual) fields
Server.AddComputedField registers a derived field that appears in every
read response (create echo, single read, update echo, list rows) without
being stored:
server.MustAddComputedField("Product", "stock_level",
func(ctx context.Context, row map[string]any) (any, error) {
return stockService.CurrentLevel(ctx, row["id"].(string))
})
The function runs in the Response step after the DB row has been converted
to JSON keys, so row’s keys are JSON field names. For list responses each
row is processed in its own goroutine — a slow function does not serialise
a whole page.
Computed fields:
- Cannot be filtered or sorted — they’re materialised on output only.
- Are name-collision-checked at registration: a name that matches a real model field, or that’s already registered as computed, is rejected.
- Tolerate errors per-row — a non-nil error from the function is logged and the field is omitted from that row; the rest of the response is unaffected.
- Run on every read path that goes through the default Response step, including the create and update echoes.
Use them for derived values that change too often to denormalise (stock level, leave balance, account balance) or that depend on external systems.
Replacing the envelope
The default shape is good enough for most APIs, but if you integrate with a
client that expects a different layout, register response.Envelope from the
catalogue:
import "github.com/xaleel/maniflex/middleware/response"
server.Pipeline.Response.Register(
response.Envelope(func(ctx *maniflex.ServerContext, data any, meta *maniflex.ResponseMeta) any {
return map[string]any{
"result": data,
"paging": meta,
"trace_id": ctx.TraceID,
}
}),
)
Error responses are unaffected — they always use the {"error": {…}} shape so
clients can distinguish success from failure with a single key check.
OpenAPI Spec
maniflex generates an OpenAPI 3.1 specification from the registered models and
serves it at GET /openapi.json. The spec is derived from the same
struct tags that drive validation and querying, so it cannot drift from the
actual behaviour of the API.
The endpoint
The spec lives at /openapi.json under the configured PathPrefix:
curl localhost:8080/api/openapi.json
The response is a full OpenAPI 3.1 document — info, paths, components,
the lot. It updates automatically every time you register a model, change a
tag, or alter maniflex.Config. There is no separate codegen step.
What is generated
For every registered model, the spec includes:
- Five paths —
/<table>(GET,POST) and/<table>/{id}(GET,PATCH,DELETE). - One attachment path per
mfx:"file"field when storage is configured:GET /<table>/{id}/<file_field>withapplication/octet-stream(plus any MIME types from the field’saccept:list). See Per-model attachment routes. - Three schemas — a full response shape, a create body shape, and an
update (patch) body shape. The three differ by which fields are visible: the
create shape drops
readonlyfields; the update shape additionally dropsimmutablefields. - Standard query parameters for list endpoints —
page,limit,filter,sort,include. - Field metadata taken from
mfx:tags —enum,min,max,required,readOnly,writeOnly. - Relation fields — for each relation to a registered model, the full
response schema embeds the related schema by reference (
$ref), shown when?include=requests it. A relation whose target model is not registered — for example a bareRelatedIDfield with noRelatedmodel — is omitted rather than emitting a dangling reference that would break spec validators and client generators. - Error response shapes for
400,404,409,422, and500.
hidden fields are excluded entirely from every schema. writeonly fields
appear in the create and update schemas with writeOnly: true, but not in the
response shape.
Custom actions
Actions — custom endpoints registered with
server.Action — are included in the spec alongside the generated model
routes. Each contributes its method, path, and any {...} path parameters
automatically. Fill in ActionConfig.OpenAPI to document request and response
bodies (inferred directly from Go structs), extra query parameters, security
requirements, and a long-form description. See
Documenting an action in OpenAPI.
The OpenAPI pipeline
The spec endpoint has its own three-step pipeline, parallel to the model-route pipeline:
OpenAPI.Auth → OpenAPI.Generate → OpenAPI.Response
| Step | Purpose |
|---|---|
| Auth | Same shape as the model-route Auth step. Gate /openapi.json here. |
| Generate | Builds the spec from the registry. After-position middleware mutates it. |
| Response | Serialises the spec to JSON. |
This is reached via server.Pipeline.OpenAPI.*. See
OpenAPI Middleware for the catalogue of
spec-shaping helpers — SetTitle, AddServer, AddSecurityScheme,
AddExtension.
Securing the spec
The endpoint is public by default. To restrict it — say, to internal users only — register an Auth middleware on the OpenAPI pipeline:
import "github.com/xaleel/maniflex/middleware/auth"
server.Pipeline.OpenAPI.Auth.Register(
auth.JWTAuth("my-secret"),
)
server.Pipeline.OpenAPI.Auth.Register(
auth.RequireRole("internal"),
)
These run only for /openapi.json; they do not affect the model routes.
Viewing the spec
The framework ships a Scalar API Reference viewer at
static/openapi.html. When static/ is present in the
working directory, it is served at http://localhost:8080/static/openapi.html
and loads /api/openapi.json directly — no extra setup needed for human
browsing of the generated spec.
For tooling integration, the JSON document at /openapi.json is consumable by
any OpenAPI 3.1-compatible client generator, mock server, or contract testing
framework.
Customising the spec
Most customisation is one-line, through the OpenAPI Middleware helpers. For deeper edits, write your own middleware:
server.Pipeline.OpenAPI.Generate.Register(func(ctx *maniflex.OpenAPIContext, next func() error) error {
if err := next(); err != nil {
return err
}
// ctx.Spec is the just-generated *OpenAPISpec — mutate freely.
ctx.Spec.Info.Contact = &maniflex.OASContact{
Name: "API team",
Email: "api@example.com",
}
return nil
}, maniflex.After)
The full set of types (OpenAPISpec, OASInfo, OASSecurityScheme, …) is in
the maniflex package.
Database Backends
maniflex ships two database adapters, both built on database/sql and sharing a
single SQL core (db/sqlcore). They expose the same interface; switching
between them is one import line.
| Adapter | Module | Driver |
|---|---|---|
| SQLite | maniflex/db/sqlite | modernc.org/sqlite — pure Go, no CGo |
| PostgreSQL | maniflex/db/postgres | github.com/lib/pq |
Each adapter lives in its own Go module so a project only pulls in the driver it actually uses.
SQLite
The default choice for development, tests, and small deployments. The pure-Go
driver means no CGo and no external service — go run . is enough to start a
local server with a working database.
import "github.com/xaleel/maniflex/db/sqlite"
db, err := sqlite.Open("./app.db", server.Registry())
if err != nil {
log.Fatal(err)
}
defer db.Close()
server.SetDB(db)
Common DSNs:
| DSN | Effect |
|---|---|
./app.db | persistent file in the working directory |
:memory: | per-process in-memory database; vanishes on shutdown |
file:./app.db?_txlock=immediate | upgrade write locks to immediate — required for LockForUpdate to behave like Postgres |
SQLite is single-writer by design. The framework serialises writes through one connection internally; reads run on a pool. This is plenty for most internal tools and many production APIs.
PostgreSQL
The recommended adapter for any multi-process deployment. It supports
genuine concurrent writers, real FOR UPDATE locks, and read replicas.
import "github.com/xaleel/maniflex/db/postgres"
db, err := postgres.Open(postgres.Options{
WriteURL: "postgres://user:pass@host/db?sslmode=require",
ReadURL: "postgres://user:pass@read.host/db?sslmode=require", // optional
}, server.Registry())
if err != nil {
log.Fatal(err)
}
defer db.Close()
server.SetDB(db)
Pass an empty ReadURL to route reads to the primary. The adapter selects the
appropriate pool per request based on the operation — OpList and OpRead
go to the read pool, everything else to the write pool.
See PostgreSQL in Production for connection-pool tuning, replica lag handling, and SSL.
Switching between them
The adapter is the only thing that changes; nothing else in the application needs to know which database is in use:
- import "github.com/xaleel/maniflex/db/sqlite"
+ import "github.com/xaleel/maniflex/db/postgres"
- db, err := sqlite.Open("./app.db", server.Registry())
+ db, err := postgres.Open(postgres.Options{WriteURL: os.Getenv("DB_URL")},
+ server.Registry())
Models, middleware, and queries are portable across both backends because they
go through database/sql + the shared db/sqlcore adapter. Migrations
emitted by AutoMigrate use a portable subset of SQL.
AutoMigrate
When Config.AutoMigrate is true (the default), the adapter:
- Creates any table that does not yet exist for a registered model.
- Adds any column that exists on the struct but not in the table.
- Logs a warning for columns that exist in the table but not on the struct (the framework never drops columns automatically).
- Creates indexes declared in
ModelConfig.Indicesor auto-generated formfx:"scheduled"fields.
AutoMigrate is suitable for development and many small deployments. For
larger systems, set AutoMigrate: false and manage the schema with a
dedicated migration tool.
The DBAdapter interface
Both shipped adapters implement maniflex.DBAdapter. Custom backends — an HTTP
data service, a remote API, a different SQL database — implement the same
interface and inject the result through server.SetDB(myAdapter). The
interface is in db.go.
Per-model adapter routing
Config.DB sets the default adapter. Individual models can override it by
passing ModelConfig.Adapter:
ordersDB, _ := postgres.Open(ordersDSN, "", server.Registry())
inventoryDB, _ := postgres.Open(inventoryDSN, "", server.Registry())
server.MustRegister(
Order{}, maniflex.ModelConfig{Adapter: ordersDB},
InventoryItem{}, maniflex.ModelConfig{Adapter: inventoryDB},
User{}, // unrouted — falls back to Config.DB
)
The framework treats each distinct adapter as its own database:
- AutoMigrate runs once per adapter, with a filtered registry view so each
adapter only sees the models routed to it. Tables for
Orderare never created on the inventory DB and vice-versa. - CRUD requests (
GET /orders,POST /orders) route throughOrder.Adapter. The DB step picks the per-model adapter automatically. ctx.BeginTx/ctx.RawQuery/ctx.RawExecuse the request’s model adapter, so middleware and custom actions stay on the right DB.ctx.GetModel("OtherModel")uses the target model’s adapter — handy for cross-DB reads — but it cannot share a transaction across adapters: ifctx.Txwas opened ondbAand you callGetModel("X")whereXlives ondbB, the accessor falls back to a non-transactional read againstdbB.
Config.DB is optional when every registered model has its own Adapter.
The server starts cleanly with DB: nil and routes everything through the
per-model overrides. If any model is unrouted and Config.DB is also nil,
startup fails with a clear error naming the unrouted models.
Constraint: transactions are adapter-scoped
A single database transaction cannot span two adapters. Two consequences:
maniflex.Batchrejects ab.Create("X", ...)call whereXroutes to a different adapter than the batch transaction was opened on. The error message points topkg/sagaas the cross-adapter pattern.- Manually-opened
ctx.Txonly protects writes against the request’s own model adapter. Cross-adapter writes throughctx.GetModel(...)happen outside that transaction.
For coordinated writes across databases, use pkg/saga —
compensating transactions are the supported pattern.
Choosing
| Need | Pick |
|---|---|
| Quick start, tests, small single-process services | SQLite |
| Multi-process deployment, real concurrency, replicas | PostgreSQL |
| Both (the codebase will outgrow SQLite) | SQLite locally, Postgres in production — same code |
Configuration
maniflex.Config is the single struct passed to maniflex.New. Every field has a
sensible default; populate only the ones that differ from those defaults.
server := maniflex.New(maniflex.Config{
Port: 8080,
PathPrefix: "/api",
AutoMigrate: true,
})
Server
| Field | Default | Purpose |
|---|---|---|
Port | 8080 | TCP port the HTTP server binds to |
PathPrefix | /api | URL prefix prepended to every generated model route and /openapi.json |
ServiceName | "" | service identifier added to logs, audit records, and the X-Service-Name response header |
StaticDir | <cwd>/static | filesystem directory served as static files (relative paths resolve against cwd) |
StaticPrefix | /static | URL prefix the static directory is mounted under, at the router root |
StaticDisabled | false | turn static file serving off entirely, even when the directory exists |
PathPrefix does not affect /static, /files, or /health. Those are
mounted at the router root. See Static Files for the static
serving options.
Database
| Field | Default | Purpose |
|---|---|---|
DB | nil | the default DBAdapter. Usually set via server.SetDB(db) after MustRegister. Optional when every model has its own ModelConfig.Adapter — see Per-model adapter routing |
AutoMigrate | true | run schema migration on startup |
DBWriteURL | "" | DSN for the primary database (informational; populated by ConfigFromEnv) |
DBReadURL | "" | DSN for the read replica (informational) |
QueryTimeout | 0 (unlimited) | per-request deadline applied to all DB calls; exceeding it produces 504 TIMEOUT |
See Database Backends for adapter construction.
File storage and encryption
| Field | Purpose |
|---|---|
FileStorage | maniflex.FileStorage implementation for mfx:"file" fields and the /files endpoints. Required if any model uses file uploads. See File Fields & Uploads. |
FileMiddleware | []maniflex.MiddlewareFunc wrapping the standalone /files endpoints. Empty = no auth (backward-compatible default); production deployments should populate this with at least an auth middleware. See File Fields & Uploads. |
KeyProvider | maniflex.KeyProvider for mfx:"encrypted" fields. Without one, encrypted fields refuse writes with 500 ENCRYPTION_NOT_CONFIGURED. |
Logging
| Field | Default | Purpose |
|---|---|---|
Logger | slog.Default() | logger used for lifecycle, per-request, and adapter messages |
PanicLogger | falls back to Logger | sink for the panic recoverer’s structured panic records |
Trace | zero (off) | pipeline tracing — see below |
Logger is used by ctx.Logger(), which adds request_id, trace_id, and
service attributes per request. Route it to a JSON handler in production.
Pipeline tracing
Config.Trace enables verbose debug output of the request pipeline. All trace
output is at DEBUG level through Logger, so the handler must accept
DEBUG records to see anything.
| Sub-flag | Effect |
|---|---|
Enabled | shorthand for Steps + Timings + Aborts |
Steps | enter/exit record per middleware |
Timings | per-middleware elapsed time on exit records |
Aborts | the source file:line of every ctx.Abort call |
Bodies | log field names present in ctx.ParsedBody (opt-in; may expose sensitive field names) |
Skips | log middleware skipped by ForModel/ForOperation filters |
cfg.Trace = maniflex.PipelineTrace{Enabled: true, Skips: true}
Leave Bodies off in production.
Lifecycle
| Field | Default | Purpose |
|---|---|---|
ShutdownTimeout | 30s | maximum time Start() waits for in-flight requests to finish on SIGINT / SIGTERM before forcing the listener closed |
See Graceful Shutdown.
Health probe
| Field | Default | Purpose |
|---|---|---|
HealthCheckDB | false | when true, GET /health pings every distinct registered adapter (Config.DB plus any per-model overrides) and returns 503 on failure. Driver error messages are logged, not echoed in the response body, so DSN fragments can’t leak. |
HealthTimeout | 3s | maximum time the health handler waits for the DB ping |
Set HealthTimeout shorter than your probe’s timeoutSeconds so the handler
can return 503 cleanly before the probe times out.
Reading from environment
maniflex.ConfigFromEnv() populates a Config from a conventional set of
environment variables (PORT, PATH_PREFIX, DB_WRITE_URL, DB_READ_URL,
SERVICE_NAME, LOG_LEVEL, …). Use it for twelve-factor deployments, then
override individual fields in code where needed.
cfg := maniflex.ConfigFromEnv()
cfg.AutoMigrate = false // disable for production
server := maniflex.New(cfg)
Graceful Shutdown
server.Start() blocks on the HTTP listener and additionally listens for
SIGINT and SIGTERM. When either signal arrives, the server stops accepting
new connections and gives in-flight requests up to
Config.ShutdownTimeout to finish before forcing the listener closed.
How it works
- A signal arrives.
http.Server.Shutdown(ctx)is called with a deadline ofConfig.ShutdownTimeout(default: 30 seconds).- The listener stops accepting new connections immediately.
- In-flight requests are allowed to complete — including their pipeline middleware, transaction commits, and Response writes.
- When all requests have finished, or the deadline elapses, the database
adapter’s
Close()is called. Start()returns.
If the deadline passes with requests still running, the underlying TCP connections are closed — those requests fail mid-flight but the process exits cleanly.
Tuning ShutdownTimeout
Pick the value based on the longest legitimate request your service serves:
| Environment | Suggested ShutdownTimeout |
|---|---|
| Tests | 0–1s — exit instantly |
| Lambdas / fast-cycling containers | 5–10s |
| General OLTP API | 30s (default) |
| Bulk import or large file uploads | 60s+ |
Setting ShutdownTimeout shorter than your slowest request will sever it on
shutdown. Setting it longer makes deploys slower with no benefit beyond the
slowest real request.
Why graceful shutdown matters
Cutting a request mid-write produces inconsistent state at the boundary — a write that may or may not have committed, a webhook that may have fired but not been recorded, a client that may or may not have seen the response. The graceful path:
- ensures transactions commit or roll back cleanly,
- lets the Response step write its envelope before the connection drops,
- gives
maniflex.WithTransaction’s deferred rollback a chance to run.
For Kubernetes deployments, set terminationGracePeriodSeconds on the pod to
a value larger than ShutdownTimeout, otherwise the orchestrator will send
SIGKILL before the graceful handler completes.
Manual shutdown
For tests or custom lifecycle code, the same graceful path is available without waiting for a signal:
go server.Start()
// ... run tests ...
server.Shutdown(ctx)
Shutdown uses the supplied context as the deadline. Pass context.Background
for “wait as long as it takes”; pass a context.WithTimeout for an explicit
budget.
Background writes
Audit-log writes, cache invalidations (db.Invalidate), and async file
cleanups (Config.FileStorage with mfx:"auto_delete" fields) run on
goroutines tracked by the server. Shutdown waits for those to drain
within the same deadline as the HTTP listener. If the deadline elapses
with goroutines still in flight, the server logs a warning with the
in-flight count and proceeds — the goroutines see their context cancelled
and exit on the next checkpoint.
Custom middleware can opt into the same lifecycle via
ctx.GoBackground(fn func(context.Context)); the supplied context is
independent of the request (which has already returned) but IS cancelled
when shutdown’s deadline hits.
Supervised services & lifecycle hooks
Applications often own long-lived background components — a poller, cache
warmer, queue consumer, or an in-memory pool manager — that must start after
the database is ready and stop cleanly before the process exits. Register
them as services and the framework folds them into the boot and shutdown
lifecycle instead of you hand-supervising them around Start.
type Service interface {
Start(ctx context.Context) error // ctx is cancelled at shutdown
Stop(ctx context.Context) error // fresh deadline, bounded by ShutdownTimeout
}
server.AddService(pool) // a custom Service
server.AddService(maniflex.ServiceFunc(startFn)) // adapter for a bare start func
For app-scoped fire-and-forget work (e.g. a periodic reconciler) that doesn’t
need an ordered Stop, use server.Go. Its context is cancelled when shutdown
begins, and the goroutine is drained before Start returns:
server.Go(func(ctx context.Context) {
t := time.NewTicker(time.Minute)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
reconcile(ctx)
}
}
})
Callers that want a hook without defining a Service type can set the
lightweight Config.OnStart / Config.OnShutdown functions.
Boot order: migrate → OnStart → Service.Start (registration order) → listen. A Start (or OnStart) error aborts boot exactly like a failed
migration; services that already started are stopped in reverse first.
Shutdown order: http.Shutdown → Service.Stop (reverse order) → OnShutdown → drain server.Go + ctx.GoBackground goroutines. The Start context is
cancelled when shutdown begins so loops wind down on their own; Stop then
receives a fresh deadline context. Everything is bounded by ShutdownTimeout.
AddService, OnStart, and server.Go are inert for apps that register
nothing — there is no behavioural change unless you opt in.
Health probes during shutdown
Once shutdown begins, /health continues to respond for a brief window
because in-flight requests are honoured. Configure your readiness probe to
stop directing traffic to the pod as soon as termination begins (Kubernetes
does this automatically when it sends SIGTERM).
Satellite Modules
maniflex is a multi-module monorepo. The core module — also named maniflex —
carries only chi and uuid. Every heavy dependency (a database driver, a
message broker client, a crypto library) lives in its own satellite module
under the same repository, so a consumer pulls only the dependencies it
imports.
Layout
maniflex/ # core module — chi + uuid
├── maniflex/ # framework
├── storage/ # local disk file storage
└── ...
storage/
└── s3/ # aws-sdk-go-v2 — S3, MinIO, R2, Spaces, etc.
db/
├── sqlite/ # modernc.org/sqlite — pure-Go SQLite
├── postgres/ # lib/pq — PostgreSQL
└── sqlcore/ # shared SQL adapter used by both
events/
├── kafka/ # confluent-kafka-go
├── nats/ # nats.go
├── rabbitmq/ # streadway/amqp
└── redis/ # go-redis
jobs/
├── inproc/ # goroutine pool — tests and single-binary apps
├── sql/ # *sql.DB-backed (Postgres + SQLite) with transactional outbox
├── redis/ # Redis Streams / BRPOP — high-throughput worker fleets
├── cron/ # scheduled EnqueueAt ticker
└── maniflex/ # StatusModel + Mount helper — REST polling for job status
middleware/
├── auth/, body/, db/, … # catalogue middleware (see Middleware Catalogue)
├── service/bcrypt/ # golang.org/x/crypto for password hashing
└── db/redis/ # Redis cache invalidation
examples/ # runnable example apps — its own module
tests/ # e2e suite — its own module
Why split modules
The split keeps the core dependency graph minimal:
- A project that uses SQLite imports
maniflex/db/sqliteand gets the pure-Go driver. PostgreSQL’slib/pqis not in its build. - A project that publishes events to Kafka imports
maniflex/events/kafkaand pulls inconfluent-kafka-go. NATS and RabbitMQ stay out of the build. - A project that does not authenticate doesn’t import
middleware/authand pays nothing for the JWT library.
This matters most for binary size, attack surface, and CI build time. It also keeps the core stable: changing a database driver does not require a release of the framework itself.
Importing satellites
Each satellite is a normal Go module — add it with go get:
go get github.com/xaleel/maniflex # core
go get github.com/xaleel/maniflex/db/sqlite # SQLite adapter
go get github.com/xaleel/maniflex/middleware/auth # auth helpers
go get github.com/xaleel/maniflex/events/kafka # Kafka publisher
In code:
import (
"github.com/xaleel/maniflex"
"github.com/xaleel/maniflex/db/sqlite"
"github.com/xaleel/maniflex/middleware/auth"
"github.com/xaleel/maniflex/events/kafka"
)
There are no required satellites for the framework itself to function —
maniflex alone gives you the registry, pipeline, and HTTP layer. You need
at least a database adapter (sqlite or postgres) before server.Start() can
serve a request.
Workspace mode
The repository ships a go.work file that includes every satellite module.
For consumers, this is invisible — Go modules resolve normally through
go.mod. For contributors working across modules, go.work makes
cross-module changes possible without replace directives.
go build ./... and go test ./... operate per-module. To build or test
every module at once, use the helper scripts in scripts/:
bash scripts/test-all.sh
# or
powershell scripts/test-all.ps1
Versioning
Each satellite carries its own v0.x tags. The core module is the only one a
typical app depends on by name; satellites are usually pulled in transitively
or by direct import as needed. Pin satellite versions in go.mod when
reproducibility across machines is required.
Admin Panel
maniflex/admin is an opt-in satellite module that mounts a server-rendered
administration panel on top of any maniflex server. It introspects the model
registry to build its navigation and views, and reads/writes data by issuing
in-process HTTP requests against the server’s own REST API — so every
operation travels the full auth/validate/pipeline stack. The admin never
touches the database directly.
Adding the module
go get github.com/xaleel/maniflex/admin
Because it is a satellite, importing it is the only thing needed to bring the
admin into your binary. The core maniflex module has no dependency on it.
Quick start
package main
import (
"net/http"
"github.com/xaleel/maniflex"
"github.com/xaleel/maniflex/admin"
"github.com/xaleel/maniflex/db/sqlite"
"github.com/go-chi/chi/v5"
)
func main() {
server := maniflex.New(maniflex.Config{PathPrefix: "/api"})
server.MustRegister(User{}, Post{}, Comment{})
db, _ := sqlite.Open(":memory:", server.Registry())
server.SetDB(db)
adminHandler := admin.Mount(server, admin.Config{
Title: "My App Admin",
AllowUnauthenticated: true, // local dev only
})
r := chi.NewRouter()
maniflex.Mount(r, server)
r.Mount("/admin", http.StripPrefix("/admin", adminHandler))
http.ListenAndServe(":8080", r)
}
Mount must be called after all models are registered and the DB adapter
is set, and before the server starts handling requests. It panics early if
neither Config.Auth nor Config.AllowUnauthenticated is set, so an
unprotected panel can never be shipped by accident.
Config reference
| Field | Type | Default | Description |
|---|---|---|---|
PathPrefix | string | "/admin" | Mount path; the returned handler serves routes under this prefix |
Title | string | "github.com/xaleel/maniflex admin" | Displayed in the panel header |
Auth | func(http.Handler) http.Handler | — | Wraps the whole panel with an auth gate; required unless AllowUnauthenticated is set |
AllowUnauthenticated | bool | false | Skips the auth requirement; local dev only |
Models | []string | (all) | Struct names to show; empty means every registered model |
ReadOnly | bool | false | Hides create/edit/delete UI and unmounts those routes |
Templates | fs.FS | — | Override FS for custom templates (see Templates) |
StaticFS | fs.FS | — | Replaces the embedded CSS/asset bundle |
Authentication
Set Config.Auth to any func(http.Handler) http.Handler middleware — for
example the JWT middleware from maniflex/middleware/auth:
import "github.com/xaleel/maniflex/middleware/auth"
adminHandler := admin.Mount(server, admin.Config{
Title: "My Admin",
Auth: auth.JWTAuth(secret, auth.JWTOptions{}),
})
The Auth wrapper runs before every panel request. Because data reads and
writes go through the API pipeline, the upstream Auth middleware registered
on your model endpoints also enforces field-level and operation-level rules —
the admin doesn’t bypass them.
Never set
AllowUnauthenticated: truein a production deployment. Mount panics at startup if neither option is provided, so there is no way to accidentally omit auth and only discover it at runtime.
Views
Dashboard
GET /admin/ — one summary card per visible model showing its total row
count, fetched in-process from the API.
List
GET /admin/{model} — a paginated table of records.
- Pagination — 20 rows per page;
?page=Nnavigates. - Sorting — a dropdown built from fields tagged
mfx:"sortable". The current direction is preserved across filter changes. - Filtering — one input per field tagged
mfx:"filterable". Enum fields render a<select>; other fields render a text input. Active filters persist in the URL as?f_<field>=<value>.
Detail
GET /admin/{model}/{id} — all readable fields for one record.
- FK fields rendered as links to the related record (
/admin/{related}/{fk_id}). HasManyrelations shown as “View {related}” links (list pre-filtered to this record’s ID).- Edit and Delete actions, each protected by a CSRF token.
Create form
GET /admin/{model}/new — an empty form; POST /admin/{model} submits it.
Edit form
GET /admin/{model}/{id}/edit — the form pre-filled from the existing record;
POST /admin/{model}/{id} submits it.
Both forms share the same template and widget logic:
| Widget | When used |
|---|---|
text | default string fields |
textarea | long-text / text DB type |
number | integer and float fields |
checkbox | boolean fields |
select | fields with mfx:"enum:…" |
relation | BelongsTo FK fields — a <select> populated from the target model |
file | fields tagged mfx:"file" — includes a preview/download link when a file is already stored |
datetime | time.Time fields — rendered as an <input type="datetime-local"> |
Fields tagged mfx:"hidden" or mfx:"writeonly" are excluded from the list
and detail views. Fields tagged mfx:"readonly" appear on the edit form as
disabled inputs (they are server-managed). mfx:"immutable" fields are
editable on create but disabled on edit.
Delete
POST /admin/{model}/{id}/delete — deletes the record via the API and
redirects to the list. Requires a valid CSRF token (present on the detail
page’s Delete button).
CSRF protection
The panel uses double-submit cookies. On first form load a random 32-byte
hex token is written to a _csrf cookie and embedded in a hidden form field.
Every mutating POST verifies that both match before forwarding to the API.
There is nothing to configure — it is always on.
Model whitelist
To show only a subset of registered models:
admin.Mount(server, admin.Config{
Models: []string{"User", "Post"},
// "Comment" will not appear in the panel
})
Model names are Go struct names, not table names. Models omitted from the whitelist are hidden from navigation, list, and detail views — they are still served by the API.
Read-only mode
admin.Mount(server, admin.Config{
ReadOnly: true,
})
In read-only mode the create/edit/delete routes are not mounted at all, and the corresponding controls are hidden in the UI. Useful for support teams that need visibility without write access.
Templates
Drop in a replacement for any individual template by providing a fs.FS on
Config.Templates. Any file not present in the override FS falls back to
the embedded default. The template file names are:
| File | View |
|---|---|
layout.html | outer chrome (header, sidebar, <head>) |
dashboard.html | model summary cards |
list.html | paginated table |
detail.html | single-record field list |
form.html | shared create/edit form |
error.html | error page |
Example — override only the layout to inject custom branding:
//go:embed templates
var myTemplates embed.FS
admin.Mount(server, admin.Config{
Templates: myTemplates,
})
The templates receive the viewData struct. Consult the admin package source
(view.go) for the full shape of each page’s data.
Static assets
The embedded asset bundle is served under {PathPrefix}/static/. To replace
it entirely with a custom CSS file:
//go:embed assets
var myAssets embed.FS
admin.Mount(server, admin.Config{
StaticFS: myAssets,
})
StaticFS replaces the whole bundle — include any assets the templates
reference (or adjust the templates to match).
How it works
The panel is self-contained: it holds a reference to server.Handler() and
issues normal http.Request objects against it in-process. There is no
separate HTTP round-trip.
browser → GET /admin/users
→ admin handler
→ apiClient.list(r, "users", "limit=20&sort=…")
→ server.Handler().ServeHTTP(rw, r') // in-process
→ full pipeline (Auth → Validate → DB → Response)
← []map[string]any
← rendered list.html
This means:
- Pipeline middleware on the model (tenant isolation, field redaction, soft- delete visibility) is enforced on every admin read and write.
- Auth cookies or tokens present on the browser request are forwarded unchanged to the API, so per-user permission checks work automatically.
- The admin has no SQL access of its own and cannot bypass business rules.
→ Satellite Modules
→ Field Tags Reference
→ File Fields & Uploads
→ Pipeline Overview
Custom Endpoints (Actions)
The five generated REST routes per model cover the standard CRUD shape, but
some endpoints don’t fit that shape — POST /orders/{id}/cancel,
POST /invoices/{id}/send, GET /reports/revenue. Actions are maniflex’s
mechanism for adding these.
Registering an action
An action is a method, a path, and a handler:
server.Action(maniflex.ActionConfig{
Method: "POST",
Path: "/orders/{id}/cancel",
Handler: cancelOrder,
})
The handler receives the standard *maniflex.ServerContext:
func cancelOrder(ctx *maniflex.ServerContext) error {
orderID := ctx.URLParam("id")
if _, err := ctx.GetModel("Order").Update(orderID, map[string]any{
"status": "cancelled",
}); err != nil {
return err
}
ctx.Response = &maniflex.APIResponse{
StatusCode: http.StatusOK,
Data: map[string]any{"ok": true},
}
return nil
}
The trimmed pipeline
Action requests run a shorter pipeline than CRUD requests:
Auth → [per-action middleware...] → handler → Response
Deserialize, Validate, Service, and DB are skipped. The action handler
is responsible for parsing its own body (ctx.BindJSON) and performing its
own database work (via ctx.GetModel, ctx.RawExec, or directly).
ctx.Operation is OpAction inside the handler. Middleware registered on the
trimmed-out steps with ForOperation(maniflex.OpAction) does not run; only Auth
and Response middleware do.
Per-action middleware
Actions can carry their own middleware list, which runs between Auth and the
handler:
server.Action(maniflex.ActionConfig{
Method: "POST",
Path: "/orders/{id}/cancel",
Handler: cancelOrder,
Middleware: []maniflex.MiddlewareFunc{
auth.RequireRole("admin"),
idempotency.Key("Idempotency-Key"),
},
})
This is the equivalent of the Service step for an action — anything that should run before the handler but after authentication.
Reading input
The action handler does its own request parsing:
type RefundReq struct {
Amount float64 `json:"amount"`
Reason string `json:"reason"`
}
func refundOrder(ctx *maniflex.ServerContext) error {
var req RefundReq
if err := ctx.BindJSON(&req); err != nil {
return nil // ctx.Abort already called
}
// ... work ...
ctx.Response = &maniflex.APIResponse{StatusCode: http.StatusOK}
return nil
}
ctx.BindJSON enforces the same 4 MB body limit as the default Deserialize
step. ctx.URLParam and ctx.QueryParam read URL and query parameters.
Transactional actions
ctx.BeginTx works inside an action just as it does in middleware. For most
actions, wrap the handler body in a BeginTx / Commit block:
func cancelOrder(ctx *maniflex.ServerContext) error {
tx, err := ctx.BeginTx(ctx.Ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
ctx.Tx = tx
// ... transactional work via ctx.GetModel / ctx.RawExec ...
return tx.Commit()
}
Because the action does not pass through the Service step, maniflex.WithTransaction
registered there does not apply — actions manage their own transactions.
Documenting an action in OpenAPI
Actions appear in the generated OpenAPI spec automatically. By
default each one contributes its method, path (with path parameters extracted
from {...} segments), Summary, Tags, and Deprecated flag:
server.Action(maniflex.ActionConfig{
Method: "POST",
Path: "/orders/{id}/cancel",
Summary: "Cancel an order",
Tags: []string{"Orders"},
Handler: cancelOrder,
})
For request/response bodies, query parameters, and security, fill in the
optional OpenAPI block. Its most useful feature is schema inference: point
RequestSchema / ResponseSchema at a Go struct tagged with the same json
and mfx tags you already use on models, and maniflex reflects it into a JSON
schema — no hand-written OpenAPI types:
type RescheduleReq struct {
NewTime string `json:"new_time" mfx:"required"`
Reason string `json:"reason"`
}
type RescheduleResp struct {
ID string `json:"id"`
Status string `json:"status" mfx:"enum:scheduled|cancelled"`
}
server.Action(maniflex.ActionConfig{
Method: "POST",
Path: "/appointments/{id}/reschedule",
Summary: "Reschedule an appointment",
Handler: reschedule,
OpenAPI: maniflex.ActionOpenAPI{
Description: "Moves an appointment to a new time.",
RequestSchema: RescheduleReq{},
ResponseSchema: RescheduleResp{},
ResponseStatus: http.StatusOK, // status the response schema documents; defaults to 200
QueryParams: []maniflex.OASParameter{{
Name: "notify", In: "query",
Schema: &maniflex.OASSchema{Type: "boolean"},
}},
Security: []map[string][]string{{"bearerAuth": {}}},
},
})
The reflected schemas honour the field tags you already use on models —
required, enum, min, max, readonly, writeonly — and skip hidden
fields. RequestSchema and ResponseSchema each accept a struct value, a
pointer, or a reflect.Type.
Security names a scheme you register separately with
openapi.AddSecurityScheme.
If you’d rather build the OpenAPI types by hand, set RequestBody and
Responses directly on the ActionConfig — those take precedence over the
inferred schemas when both are present.
When to use an action
| Need | Use |
|---|---|
| Standard CRUD | The generated routes |
One-off state transitions (/cancel, /publish) | Action |
| Aggregations and reports | Action, or Raw Queries & Query Models |
| Bulk operations | Batch Operations & Sagas |
| Background processing | Events & Background Jobs |
Reserve actions for endpoints that genuinely don’t fit CRUD. Resist the temptation to use them as a general-purpose handler API — the framework’s strength is in the generated routes; every action is one more thing to test and document by hand.
Raw Queries & Query Models
The generated CRUD routes cover one-table reads. Anything that needs joins, aggregates, or custom SQL goes through one of the framework’s escape hatches: raw queries from middleware, or query models — read-only models backed by a hand-written SELECT.
Raw queries from middleware
ctx.RawQuery and ctx.RawExec run parameterised SQL through the active
transaction or the bare adapter:
rows, err := ctx.RawQuery(
`SELECT status, COUNT(*) AS n
FROM orders
WHERE organization_id = ?
GROUP BY status`,
ctx.Auth.TenantID,
)
rows is a []map[string]any with column-name keys. Use driver-appropriate
placeholders ($1, $2 for Postgres; ? for SQLite). Never interpolate
values into the query string — that’s a SQL injection.
ctx.RawExec is the same shape for non-SELECT statements and returns the
number of rows affected.
When ctx.Tx is non-nil, both methods participate in the active transaction
automatically.
Structured aggregation: ctx.Aggregate
For typed, validated aggregations there’s a structured builder that doesn’t require hand-written SQL:
rows, err := ctx.Aggregate("Order", maniflex.AggregateQuery{
Select: []maniflex.AggregateField{
{Op: maniflex.AggCount, As: "n"},
{Op: maniflex.AggSum, Field: "total", As: "revenue"},
},
GroupBy: []string{"status"},
Where: []*maniflex.FilterExpr{
{Field: "created_at", Operator: maniflex.OpGte, Value: "2026-01-01"},
},
Having: []maniflex.HavingClause{
{Alias: "revenue", Operator: maniflex.OpGt, Value: 1000},
},
OrderBy: []maniflex.SortExpr{{DBName: "revenue", Direction: maniflex.SortDesc}},
Limit: 100,
})
Each AggregateField.Op is one of AggCount, AggCountDistinct, AggSum,
AggAvg, AggMin, AggMax. Leave Field empty on AggCount to mean
COUNT(*). As overrides the alias used in the result row and in Having
or OrderBy; if omitted the default is <op>_<field> (or count for
COUNT(*)).
All DB column names — in Select.Field, GroupBy, and Where.Field — are
validated against the registered model. A typo fails fast with a clear
error rather than emitting bad SQL. OrderBy.DBName may reference either
an aggregate alias or a GroupBy column. Nested-relation filters are not
yet supported in Aggregate — use the raw-query escape hatch when you need
them.
When ctx.Tx is active the aggregate participates in the transaction,
matching RawQuery/QueryModel.
Auto-generated aggregate endpoint
Opt a model into a built-in HTTP aggregation route with
ModelConfig.AggregateEnabled:
server.MustRegister(Order{}, maniflex.ModelConfig{AggregateEnabled: true})
This mounts GET /:model/aggregate, which accepts a JSON body describing the
aggregation and returns the group rows under the usual {"data": [...]}
envelope:
GET /api/orders/aggregate
{
"select": [{"op": "count", "as": "n"}, {"op": "sum", "field": "amount", "as": "total"}],
"group_by": ["status"],
"where": [{"field": "created_at", "operator": "gte", "value": "2026-01-01"}],
"having": [{"alias": "total", "operator": "gt", "value": 1000}],
"order_by": [{"field": "total", "direction": "desc"}],
"limit": 100
}
op is one of count, count_distinct, sum, avg, min, max (omit
field on count for COUNT(*)). Field names use the same convention as
?filter=/?sort= — the JSON name (DB column name also accepted) — and every
referenced field must be mfx:"filterable" or mfx:"sortable", so the public
endpoint can never aggregate a hidden or sensitive column. WHERE operators are
the flat comparison set plus in/not_in/like/ilike/is_null/not_null
(no between).
The endpoint runs as the list operation: any auth or tenancy middleware you
registered for OpList applies unchanged (no separate registration needed), and
request ?filter= conditions — including middleware-injected tenancy
force-filters — are AND-ed into the aggregate WHERE alongside the body’s own
where.
Tree traversal: ctx.RecursiveQuery
For self-referential models — categories, org charts, threaded comments, bill of
materials — ctx.RecursiveQuery issues a WITH RECURSIVE CTE without hand-writing
SQL:
rows, err := ctx.RecursiveQuery("Category", maniflex.RecursiveQuery{
RootID: "some-uuid",
ParentField: "parent_id",
MaxDepth: 5,
})
// rows[0]["_depth"] == int64(0) is the root; rows[1..n] are descendants.
Every returned row is a map[string]any with all the model’s columns plus a
synthesised _depth integer (0 = the root node). Rows are ordered by _depth
ascending.
Fields
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
RootID | string | yes | — | Primary key of the starting node |
ParentField | string | yes | — | DB column that holds the parent’s ID, e.g. "parent_id" |
Direction | RecursiveDirection | no | RecursiveDescendants | Walk downward (RecursiveDescendants) or upward (RecursiveAncestors) |
MaxDepth | int | no | 0 (unlimited) | Stop after this many levels; 0 means traverse the whole subtree |
Where | []*FilterExpr | no | nil | Additional filters applied in both the anchor and recursive members |
Descendant vs. ancestor traversal
Descendants (default) — walks down the tree. Given a root category it returns all children, grandchildren, etc.:
rows, err := ctx.RecursiveQuery("Category", maniflex.RecursiveQuery{
RootID: rootID,
ParentField: "parent_id",
// Direction defaults to RecursiveDescendants
})
Ancestors — walks up the tree. Starting from a leaf, it returns the node itself, its parent, grandparent, and so on up to the root:
rows, err := ctx.RecursiveQuery("Category", maniflex.RecursiveQuery{
RootID: leafID,
ParentField: "parent_id",
Direction: maniflex.RecursiveAncestors,
})
Limiting depth
MaxDepth: 1 returns the root plus its immediate children only — no further
descendants:
rows, err := ctx.RecursiveQuery("Category", maniflex.RecursiveQuery{
RootID: rootID,
ParentField: "parent_id",
MaxDepth: 1, // depth 0 (root) + depth 1 (children)
})
Filtering nodes
Where filters are applied in both the anchor and recursive members, so a node
that fails the filter is excluded regardless of depth, and the traversal does
not continue through it:
rows, err := ctx.RecursiveQuery("Category", maniflex.RecursiveQuery{
RootID: rootID,
ParentField: "parent_id",
Where: []*maniflex.FilterExpr{
{Field: "status", Operator: maniflex.OpEq, Value: "active"},
},
})
Nested-relation filters are not supported in RecursiveQuery — use
ctx.RawQuery for those cases.
Soft-delete awareness
When a model uses WithDeletedAt or a boolean soft-delete field, the recursive
query automatically excludes deleted records from both the anchor and recursive
members. No extra filter is needed.
Transaction participation
RecursiveQuery participates in ctx.Tx exactly like RawQuery:
tx, _ := ctx.BeginTx(ctx.Ctx, nil)
ctx.Tx = tx
defer tx.Rollback()
rows, err := ctx.RecursiveQuery("Category", maniflex.RecursiveQuery{...})
tx.Commit()
Database support
Both Postgres ($N placeholders) and SQLite (since 3.8.3, ? placeholders)
are handled transparently.
Read-only query models
A query model is a struct registered with a SQL body instead of a table. The framework mounts the standard list/read routes, including filtering, sorting, and pagination, but every read runs the supplied SQL.
type RevenueByMonth struct {
maniflex.BaseModel
Month string `json:"month" mfx:"filterable,sortable"`
Total float64 `json:"total" mfx:"sortable"`
Orders int64 `json:"orders" mfx:"sortable"`
}
server.MustRegister(RevenueByMonth{}, maniflex.ModelConfig{
QueryModel: &maniflex.QueryModelSpec{
SQL: `SELECT to_char(created_at, 'YYYY-MM') AS month,
SUM(total) AS total,
COUNT(*) AS orders
FROM orders
WHERE status = 'paid'
GROUP BY month`,
},
})
Behaviour:
GET /revenue_by_monthsruns the SQL, applies any client-supplied?filter,?sort, and?page/?limitagainst the resulting columns, and paginates the result.POST/PATCH/DELETEare not mounted — query models are read-only.- The struct’s
mfx:tags still apply:filterableopens a column to?filter=,sortableto?sort=,hiddenandwriteonlyare honoured. - The model participates in OpenAPI generation, so the endpoint is documented
in
/openapi.jsonlike any other.
When to use which
| Need | Tool |
|---|---|
| One-off aggregate inside an action or middleware | ctx.RawQuery |
| Aggregation that should be a stable, paginated, filterable endpoint | Query model |
| Tree traversal (descendants, ancestors, depth limit) | ctx.RecursiveQuery |
| Bulk mutation inside a single request | ctx.RawExec (inside a transaction) |
| Per-row business logic across many rows | Batch Operations & Sagas |
Query models are the better choice when an external consumer needs to call the endpoint repeatedly — the API surface is stable, filterable, documented, and auto-generated alongside the rest. Raw queries are the better choice for one-shot work inside an action.
Performance notes
- Query models do not cache; each request executes the SQL. For frequently-hit
aggregates, wrap with
response.Cache(see Response Middleware) or maintain a summary table. - The framework treats the SQL as a subquery; client filters become
WHEREclauses against the result columns. Avoid unbounded scans — addWHEREandLIMITclauses to the SQL itself when the underlying table is large. - For Postgres, a materialised view often beats a query model for expensive
aggregates. The query model can then
SELECTfrom the materialised view.
Batch Operations & Sagas
The generated REST routes work on one row at a time. When the work fans out — inserting hundreds of rows from a CSV, fulfilling an order across inventory, payment, and shipping — two patterns appear: batch for atomic same-table work, and saga for multi-step workflows that span services.
Batch inside a single transaction
The simplest “bulk write” is a single transaction that issues many inserts or updates. Use an action endpoint so the request does not pass through the per-row Validate/Service hooks:
server.Action(maniflex.ActionConfig{
Method: "POST",
Path: "/users/import",
Handler: importUsers,
})
func importUsers(ctx *maniflex.ServerContext) error {
var req struct {
Users []map[string]any `json:"users"`
}
if err := ctx.BindJSON(&req); err != nil {
return nil
}
tx, err := ctx.BeginTx(ctx.Ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
ctx.Tx = tx
users := ctx.GetModel("User")
inserted := 0
for _, row := range req.Users {
if _, err := users.Create(row); err != nil {
ctx.Abort(http.StatusConflict, "IMPORT_FAILED",
fmt.Sprintf("row %d: %s", inserted, err.Error()))
return nil
}
inserted++
}
if err := tx.Commit(); err != nil {
return err
}
ctx.Response = &maniflex.APIResponse{
StatusCode: http.StatusCreated,
Data: map[string]any{"inserted": inserted},
}
return nil
}
Either every row commits or none does. Validation still runs because
ctx.GetModel(...).Create goes through the adapter — but per-row middleware
on the Service or Validate steps does not, since this is an action.
For larger imports, batch the inserts (INSERT … VALUES (…), (…), … via
ctx.RawExec) and commit every N rows.
Cross-service workflows: sagas
When a workflow touches more than one downstream — charge a payment provider, reserve inventory, notify a partner — a single database transaction is no longer enough. The standard pattern is a saga: a sequence of forward steps, each with a compensating undo step.
maniflex does not impose a saga framework. The mechanics fit naturally on the pipeline:
- Start a request transaction with
maniflex.WithTransaction. The local database changes commit or roll back atomically. - Make external calls from the Service step, recording an outbox row in the same transaction for each call that needs a follow-up.
- Process the outbox asynchronously with a background runner (from
jobs/redisor a similar) that performs the external call, marks the outbox row done, and triggers compensation on failure.
server.Pipeline.Service.Register(maniflex.WithTransaction(nil),
maniflex.ForModel("Order"), maniflex.ForOperation(maniflex.OpCreate))
server.Pipeline.Service.Register(func(ctx *maniflex.ServerContext, next func() error) error {
if err := next(); err != nil {
return err
}
// Both writes are in the same transaction as the Order insert.
_, err := ctx.GetModel("OutboxEvent").Create(map[string]any{
"kind": "charge-payment",
"payload": ctx.DBResult,
"status": "pending",
})
return err
}, maniflex.ForModel("Order"), maniflex.ForOperation(maniflex.OpCreate), maniflex.AtPosition(maniflex.After))
A separate worker reads pending OutboxEvent rows and processes them. If the
payment provider fails, the worker records the failure and enqueues a
compensating action (e.g. “cancel the order”).
This pattern — transactional outbox + asynchronous worker — is the practical alternative to two-phase commit. It costs you one table and one background job but gains durability and isolation that distributed transactions cannot offer.
When to use which
| Workload | Pattern |
|---|---|
| Bulk same-table writes | One transaction, one action endpoint |
| Multi-table writes touching only your database | maniflex.WithTransaction on the request |
| Writes that depend on external systems | Transactional outbox + saga |
| Long-running background work | Background job (see Events & Background Jobs) |
See also
- Events & Background Jobs — running the outbox worker and emitting domain events.
- Transactions —
maniflex.WithTransaction, manualBeginTx, andLockForUpdate. - Custom Endpoints (Actions) — the right place to host a bulk endpoint.
Events & Background Jobs
maniflex offers two complementary mechanisms for work that happens outside the request pipeline: an event bus for lightweight domain-event fan-out, and a job queue for durable, retriable background work.
| Mechanism | When to use |
|---|---|
Event bus (events/*) | Notify other services or modules that something happened. Fire-and-forget. |
Job queue (jobs/*) | Do something reliably after a request — report generation, email, reconciliation. Needs retry and status tracking. |
Event bus
The event bus lets pipeline middleware publish domain events that any number of
subscribers consume independently. A service.Emit call on the DB-After step
publishes user.created, order.placed, etc. to whichever bus is wired up:
import (
"github.com/xaleel/maniflex/events/redis"
"github.com/xaleel/maniflex/middleware/service"
)
bus := redis.New(redisClient)
server.Pipeline.DB.Register(
service.Emit(bus),
maniflex.ForModel("Order"),
maniflex.AtPosition(maniflex.After),
)
Subscribers call bus.Subscribe(ctx, "order.*", handler). For WebSocket
fan-out, connect a realtime.Hub to the bus — see
Realtime / WebSockets.
Available adapters: events/redis, events/kafka, events/nats,
events/rabbitmq. The in-process adapter (events.NewInProcessBus) ships in
the core module for tests.
Job queue
The jobs/ packages provide a producer/consumer queue with retries, dead-letter
routing, and optional status persistence through the REST layer.
Adapters
| Package | Backing store | Transactional enqueue | Best for |
|---|---|---|---|
jobs/inproc | goroutine pool | no (best-effort) | tests, single-binary dev |
jobs/sql | Postgres or SQLite | yes — enqueue in the same ctx.Tx | production (recommended) |
jobs/redis | Redis Streams / BRPOP | no | high-throughput fleets |
All three share the same jobs.Queue and jobs.Source interfaces so swapping
adapters is a one-line change.
Defining and enqueueing a job
import (
"github.com/xaleel/maniflex/jobs"
jobssql "github.com/xaleel/maniflex/jobs/sql"
)
// During startup, after opening the DB:
queue := jobssql.New(db)
if err := jobssql.Migrate(ctx, db); err != nil { /* ... */ }
// Inside a pipeline middleware or action handler:
id, err := queue.Enqueue(ctx, jobs.Job{
Type: "send_receipt",
ActorID: ctx.Auth.UserID,
TenantID: ctx.Auth.TenantID,
Payload: json.RawMessage(`{"order_id":"abc"}`),
})
Fields worth knowing:
| Field | Effect |
|---|---|
Type | Selects the handler on the Worker (required) |
MaxRetry | Max attempts before dead. Default 3. |
NotBefore | Delay execution until this time (use EnqueueAt as a shortcut) |
GroupKey | At most one job with this key runs at a time — useful for per-tenant serialisation |
TraceID | Propagated to the handler context for end-to-end trace correlation |
The Worker
import "github.com/xaleel/maniflex/jobs"
w, err := jobs.NewWorker(jobs.WorkerConfig{
Source: queue.(jobs.Source),
Handlers: map[string]jobs.Handler{
"send_receipt": func(ctx context.Context, j jobs.Job) (jobs.Result, error) {
var p struct{ OrderID string `json:"order_id"` }
json.Unmarshal(j.Payload, &p)
return jobs.Result{}, mailer.SendReceipt(ctx, p.OrderID)
},
},
Concurrency: 8, // goroutines; default = GOMAXPROCS
Logger: slog.Default(),
})
ctx, cancel := context.WithCancel(context.Background())
go w.Run(ctx)
// On shutdown:
cancel()
w.Shutdown(shutdownCtx)
Result carries an optional URL (pre-signed storage URL for file outputs)
and Output (small structured JSON). Both are surfaced through the status model
below.
StatusModel — REST polling
Mount the status model once, alongside other model registrations:
import jobsmaniflex "github.com/xaleel/maniflex/jobs/maniflex"
sink, queue, err := jobsmaniflex.Mount(server, rawQueue)
if err != nil { log.Fatal(err) }
// Pass sink to the worker:
w, _ := jobs.NewWorker(jobs.WorkerConfig{
Source: queue.(jobs.Source),
Status: sink,
Handlers: handlers,
})
Mount registers a StatusModel (table job_statuses) and returns:
sink— ajobs.StatusSinkto pass toWorkerConfig.Status; the worker writes a row for every lifecycle transition.queue— a wrappedjobs.Queue; everyEnqueuecall creates an initialenqueuedstatus row so clients can poll immediately.
The REST layer exposes these endpoints automatically (no extra code):
GET /api/job_statuses list (filterable, paginated)
GET /api/job_statuses/:id single row
POST /api/job_statuses → 405 (worker-only)
A typical client flow after an action returns {"job_id": "abc"}:
GET /api/job_statuses/abc
→ {"data": {"status": "enqueued", ...}}
GET /api/job_statuses/abc (poll until done)
→ {"data": {"status": "succeeded", "result_url": "https://...", "completed_at": "..."}}
Status values: enqueued → running → succeeded | failed | dead | cancelled.
Scope
By default, unauthenticated requests see all rows. When a caller is
authenticated, the built-in force-filter restricts the list to their own
actor_id; callers with the admin role see everything. Override the role
name with MountOptions.AdminRole.
Atomic enqueue with jobs/sql
When jobs/sql is the adapter and a maniflex.WithTransaction middleware is
active, queue.Enqueue runs its INSERT through the same *sql.Tx:
// Service step:
server.Pipeline.Service.Register(func(ctx *maniflex.ServerContext, next func() error) error {
if err := next(); err != nil { // DB write commits first
return err
}
_, err := queue.Enqueue(ctx.Ctx, jobs.Job{
Type: "reconcile_inventory",
Payload: json.RawMessage(`{"product_id":"` + productID + `"}`),
})
return err
}, maniflex.ForModel("Order"), maniflex.AtPosition(maniflex.After))
If the transaction rolls back, the job row never appears. If the process crashes after commit, the job row is durable and the worker will pick it up. This eliminates the “DB committed but job lost” race that an in-memory queue cannot prevent.
GroupKey — serialised execution
Set GroupKey to ensure at most one job for a given key runs at a time:
queue.Enqueue(ctx, jobs.Job{
Type: "generate_payroll",
GroupKey: "tenant:" + tenantID, // one payroll run per tenant at a time
})
The jobs/sql adapter enforces this via SELECT … SKIP LOCKED; jobs/inproc
tracks running keys in memory.
Retry and dead-letter
When a handler returns an error the worker re-queues the job after an
exponential backoff (base 1 s, cap 5 min). After Job.MaxRetry attempts the
job is marked dead and the status row records the final error. Set
WorkerConfig.DLQType to route dead jobs to a separate handler for inspection
or alerting.
Cancellation
When the inner queue implements jobs.Cancellable (both jobs/inproc and
jobs/sql do), the wrapped queue returned by Mount also implements it:
c := queue.(jobs.Cancellable)
c.Cancel(ctx, jobID) // marks the job cancelled in the queue and updates the status row
Only jobs that have not yet started can be cancelled; a running job must finish or fail before the status row moves.
Completion events (optional)
Set WorkerConfig.EventBus to publish job.{type}.completed and
job.{type}.failed events on every terminal transition. Pair with a
realtime.Hub to push completion notifications to connected clients without
polling:
w, _ := jobs.NewWorker(jobs.WorkerConfig{
// ...
EventBus: bus, // any value implementing Publish(ctx, type, payload) error
})
Scheduled jobs with jobs/cron
jobs/cron provides a minimal ticker that calls Queue.EnqueueAt on a fixed
interval. It does not offer durable cron (if a replica is down at fire time the
tick is missed); for durable scheduling, combine jobs/sql with a
next_fire_at column in your model. For field-based transitions (auto-publish,
auto-expire), see Scheduled Fields & Runner.
import "github.com/xaleel/maniflex/jobs/cron"
cr := cron.New(queue, cron.Config{
Jobs: []cron.Entry{
{Type: "daily_report", Schedule: "@daily"},
},
})
go cr.Run(ctx)
Realtime / WebSockets
maniflex is a synchronous request/response framework, but the realtime package
ships a first-class event hub that pushes domain events to browsers over
WebSocket and Server-Sent Events. It is a pure consumer of the
event bus: producers publish through events.Emit exactly as
they would for any other subscriber, and the hub fans those events out to
connected clients.
Nothing about realtime leaks into a CRUD-only app — the hub is mounted by your
own code outside server.Handler(), so a blog that never imports realtime
pays no websocket dependency, goroutine, or shutdown phase.
The shape of it
import (
"github.com/xaleel/maniflex"
"github.com/xaleel/maniflex/events"
"github.com/xaleel/maniflex/events/inproc"
"github.com/xaleel/maniflex/realtime"
)
bus := inproc.New() // or events/redis, events/nats, … for multi-replica
// Producer: every create/update/delete publishes a domain event.
server.Pipeline.DB.Register(
events.Emit(bus, events.EmitConfig{Source: "billing"}),
maniflex.ForOperation(maniflex.OpCreate, maniflex.OpUpdate, maniflex.OpDelete),
maniflex.AtPosition(maniflex.After),
)
// Consumer: the hub fans those events out to clients.
hub, err := realtime.NewHub(realtime.HubConfig{Bus: bus})
if err != nil {
log.Fatal(err)
}
r := chi.NewRouter()
r.Mount("/api", server.Handler())
r.Handle("/ws", hub.Handler()) // WebSocket upgrade
r.Handle("/sse", hub.SSEHandler()) // Server-Sent Events fallback
http.ListenAndServe(":8080", r)
Removing realtime is a one-line revert: drop the two r.Handle lines and the
events.Emit registration.
Topics
Events are addressed by their CloudEvents type — a dotted string like
invoice.created or queue.position_changed. Clients subscribe with glob
patterns (the same matcher the event bus uses):
| Pattern | Matches |
|---|---|
invoice.* | invoice.created, invoice.updated, … |
*.created | any ….created event |
* | every event |
HubConfig.AllowPatterns is an optional whitelist of subscribable patterns; an
empty list allows any. A client that asks for a forbidden pattern gets a
FORBIDDEN_PATTERN error (WS) or a 403 (SSE).
WebSocket protocol
The client speaks a tiny JSON protocol over the socket:
client → server server → client
{"op":"subscribe","patterns":["invoice.*"]} {"op":"ack","subId":"s_1"}
{"op":"unsubscribe","subId":"s_1"} {"op":"event","subId":"s_1","data":<event>}
{"op":"ping"} {"op":"pong"}
{"op":"error","code":"…","msg":"…"}
The data field is the full CloudEvents JSON document, so a
browser can parse it with any CE SDK.
SSE protocol
SSE is push-only and subscribes via query parameters — ideal for corporate networks that break WebSockets:
GET /sse?subscribe=invoice.*&subscribe=queue.position_changed
Each event arrives as a standard data: frame whose body is the same
CloudEvents JSON.
Authentication
Connections are authenticated once, on connect (never per message). Supply
an Authenticator; the default AnonymousOnly{} accepts everyone.
hub, _ := realtime.NewHub(realtime.HubConfig{
Bus: bus,
Authenticator: realtime.BearerToken(func(tok string) (*realtime.Principal, error) {
claims, err := verifyMyJWT(tok)
if err != nil {
return nil, err
}
return &realtime.Principal{UserID: claims.Sub, TenantID: claims.Tenant, Roles: claims.Roles}, nil
}),
})
BearerToken pulls the token from the Authorization: Bearer … header, the
?access_token= query parameter (browsers can’t set headers on WebSocket()),
or the Sec-WebSocket-Protocol: access_token.<token> subprotocol. Composite
tries several authenticators in order.
Per-event authorisation
AllowPatterns controls which topics a client may subscribe to; Visibility
controls which individual events it actually receives. The hook runs once per
(event, client) pair and can also redact the payload:
HubConfig{
Visibility: func(p *realtime.Principal, e events.Event) (bool, *events.Event) {
if e.TenantID != p.TenantID {
return false, nil // suppress cross-tenant events
}
return true, nil
},
}
Return (true, ©) to deliver a transformed event — the hub clones before
mutation so each client sees its own view.
Heartbeat
Idle connections are kept alive automatically so L7 proxies (ALB, NGINX, with their typical 30–60s idle timeouts) don’t drop them:
- WebSocket — the server sends a ping frame every
PingInterval(default 30s); compliant clients answer with a pong. - SSE — the server emits a
: keepalivecomment on the same interval.
Resumable streams (lastEventId)
By default delivery is ephemeral: a client that disconnects misses whatever was published while it was away. Enable resume to give clients a replay buffer.
hub, _ := realtime.NewHub(realtime.HubConfig{
Bus: bus,
ResumeBuffer: 1024, // retain the most recent 1024 events for replay
})
With resume enabled, every delivered event carries a cursor:
- SSE — the cursor is the standard
id:line. On reconnect the browser’sEventSourceautomatically sendsLast-Event-ID, and the hub replays everything after it before resuming the live stream. (You can also pass?lastEventId=<cursor>explicitly.) - WebSocket — events include a
"cursor"field; resume by adding it to your subscribe message:{"op":"subscribe","patterns":["invoice.*"],"after":"<cursor>"}.
If the cursor is older than the retained buffer (or the hub restarted), the
client receives a resync signal — event: resync on SSE, {"op":"resync"}
on WebSocket — telling it to refetch current state instead of silently missing
events. Across the reconnect seam delivery is at-least-once; because cursors are
monotonic, clients drop anything at or below their last applied cursor.
ResumeBuffer installs an in-process ring buffer, so resume works when the
client reconnects to the same replica (WebSocket affinity). For
cross-replica resume, supply your own ResumeStore (e.g. backed by a Redis
stream) via HubConfig.ResumeStore.
Schema-emitting events (AsyncAPI)
Just as /openapi.json lets clients codegen typed REST clients, the hub’s event
catalogue can be published as an AsyncAPI 2.6 document so clients codegen
typed event payloads. Declare it once:
server.RealtimeDoc(maniflex.AsyncAPIConfig{
Title: "Billing events",
Servers: []maniflex.AsyncAPIServerConfig{
{Name: "ws", URL: "ws://localhost:8080/ws", Protocol: "ws"},
},
// Derive invoice.created|updated|deleted channels from registered models:
AutoModelEvents: true,
// …and/or declare custom events with a Go struct payload:
Events: []maniflex.EventDoc{
{Type: "payment.received", Title: "Payment received", Payload: PaymentReceived{}},
},
})
This mounts GET {PathPrefix}/asyncapi.json. The payload struct is reflected
with the same json + mfx tags as models and actions
(Actions). The endpoint is opt-in — apps that never call
RealtimeDoc get no new route.
Backpressure & slow clients
Each connection has a bounded outbound queue (SendBuffer, default 64). If a
client can’t keep up within SendTimeout (default 5s) it is kicked — a WebSocket
close 1013 Try Again Later, or an SSE disconnect that triggers EventSource
reconnection. Hub.Stats() exposes the live connection count and cumulative
kick count for monitoring. A frame larger than MaxMessageSize (default 64 KiB)
is rejected with close 1009.
Scaling out
The hub is single-process by design; cross-replica fan-out is the bus’s job:
- inproc (single binary) — one hub, all clients local.
- redis / nats / kafka — every replica subscribes to the bus, so an event published anywhere reaches local clients on every replica. Pair with a sticky load balancer so each client stays on one replica (WebSocket affinity).
The hub does not create a consumer group per connection — per-client filtering happens server-side, downstream of one shared bus subscription, so broker load doesn’t scale with connection count.
Graceful shutdown
Hub.Shutdown(ctx) stops accepting connections, sends a 1001 Going Away close
to every client, drains in-flight writes until the deadline, then cancels the
bus subscription. Call it alongside *http.Server.Shutdown from the same signal
handler — the hub is mounted by your code, so it isn’t part of
server.Shutdown.
HubConfig reference
| Field | Default | Purpose |
|---|---|---|
Bus | — (required) | the events.Bus the hub consumes |
Authenticator | AnonymousOnly{} | connection auth |
Visibility | allow-all | per-event authorisation / redaction |
AllowPatterns | allow-all | subscribable topic whitelist |
ResumeStore | nil (disabled) | replay buffer for lastEventId resume |
ResumeBuffer | 0 (disabled) | shortcut: install an in-memory store of this size |
PingInterval | 30s | WS ping / SSE keepalive cadence |
SendBuffer | 64 | per-client outbound queue depth |
SendTimeout | 5s | slow-client kick threshold |
MaxMessageSize | 64 KiB | inbound frame size limit |
Origins | allow-all | allowed Origin values for the WS upgrade |
Encryption at Rest
A field tagged mfx:"encrypted" is automatically encrypted before it
reaches the database and decrypted on read. The plaintext never appears in
the table; the column stores a self-describing envelope. This page covers
the full subsystem: the tag, the key provider interface, the storage
format, unique-constraint handling, and key rotation.
Declaring an encrypted field
type Patient struct {
maniflex.BaseModel
Name string `json:"name" mfx:"required,filterable,sortable"`
SSN string `json:"ssn" mfx:"encrypted,key:patient-pii"`
}
| Sub-option | Effect |
|---|---|
encrypted | mark the field for envelope encryption |
key:NAME | the key identifier passed to the KeyProvider; defaults to "default" |
The column’s Go and DB types remain string. Storage is the prefix
enc: followed by a base64-encoded binary envelope:
enc:Aa1z... (envelope bytes embed the keyID)
The enc: prefix lets the framework distinguish ciphertext from any
legacy plaintext that may exist in the column — useful for incremental
migration of an existing table.
What encryption costs you
The trade-offs are deliberate and worth being explicit about:
- No filtering. A
WHERE ssn = ?would have to match an envelope that includes a random nonce. Encrypted fields cannot befilterable. - No sorting. Same reason. Encrypted fields cannot be
sortable. - Uniqueness via HMAC. A
mfx:"encrypted,unique"field gets a companion{field}_hmacTEXT UNIQUEcolumn. See the next section. - The KeyProvider is required. Reads degrade to returning the raw
stored ciphertext; writes are rejected with
500 ENCRYPTION_NOT_CONFIGUREDuntil a provider is configured.
For columns that need to be queryable but contain sensitive data, store a non-sensitive lookup key (a hashed identifier) in a separate field and encrypt only the payload.
Configuring a KeyProvider
maniflex.Config.KeyProvider must be set before any model with encrypted
fields is exercised. Two implementations ship in pkg/encryption, both
constructed as plain struct literals.
EnvKeyProvider — keys from environment variables
import "github.com/xaleel/maniflex/pkg/encryption"
server := maniflex.New(maniflex.Config{
KeyProvider: &encryption.EnvKeyProvider{Prefix: "MYAPP_KEY"},
// ...
})
The env var name for a given keyID is derived as
{Prefix}_{KEYID_UPPER}, with hyphens replaced by underscores and the
result uppercased:
Prefix | keyID | Env var read |
|---|---|---|
MYAPP_KEY | default | MYAPP_KEY_DEFAULT |
MYAPP_KEY | patient-pii | MYAPP_KEY_PATIENT_PII |
MFX_KEY (default) | billing | MFX_KEY_BILLING |
Each variable holds a base64-encoded 32-byte (256-bit) AES key. Generate one with:
openssl rand -base64 32
The provider accepts either standard or URL-safe base64.
VaultKeyProvider — HashiCorp Vault Transit
server := maniflex.New(maniflex.Config{
KeyProvider: &encryption.VaultKeyProvider{
Address: "https://vault.example.com",
Token: os.Getenv("VAULT_TOKEN"),
Mount: "transit", // optional, default "transit"
// Client: customHTTPClient, // optional, defaults to http.DefaultClient
},
})
keyID maps to a Vault Transit key name; the plaintext is sent to
/v1/{mount}/encrypt/{keyID} and Vault returns its own
vault:v1:... ciphertext, which the provider embeds in the envelope.
A Vault key rotation is transparent — Vault decrypts ciphertexts
encrypted with any prior version of the key automatically.
The shipped provider uses a static token. For production, wrap it with a refresher that obtains a fresh token from AppRole, Kubernetes auth, or JWT auth before each operation.
A custom backend implements the maniflex.KeyProvider interface:
type KeyProvider interface {
Encrypt(ctx context.Context, keyID string, plaintext []byte) ([]byte, error)
Decrypt(ctx context.Context, envelope []byte) ([]byte, error)
KeyIDOf(envelope []byte) (string, error)
HMAC(ctx context.Context, keyID string, data []byte) ([]byte, error)
}
Encrypt returns a self-describing binary envelope that embeds the
keyID. Decrypt reads the keyID from the envelope, so callers don’t
supply it. HMAC produces a deterministic keyed digest used for unique
indexes.
Unique encrypted fields
A normal UNIQUE constraint on an envelope is useless — each envelope
contains a random nonce, so two encryptions of the same plaintext are
different ciphertexts. The framework solves this with an HMAC companion
column.
Email string `json:"email" mfx:"encrypted,unique"`
AutoMigrate emits two columns:
| Column | Type | Purpose |
|---|---|---|
email | TEXT | the enc:<base64> envelope |
email_hmac | TEXT UNIQUE | a keyed HMAC of the plaintext |
On every write, the DB step calls KeyProvider.HMAC(ctx, keyID, plaintext)
and stores the result in the companion. The HMAC is deterministic for a
given (key, plaintext) pair, so the database can enforce uniqueness
without ever seeing the plaintext.
Reads strip the HMAC column from responses automatically; clients see only
the decrypted plaintext on email and never the digest.
When the unique check fires, the adapter returns *maniflex.ErrConstraint and
the DB step converts it to 409 CONFLICT — same path as any other unique
violation.
Per-domain keys
The key:NAME sub-option routes a field to a specific key identifier:
type Record struct {
maniflex.BaseModel
PaymentToken string `json:"payment_token" mfx:"encrypted,key:billing"`
MedicalNote string `json:"medical_note" mfx:"encrypted,key:medical"`
}
A KeyProvider that backs different keys with different secrets (or a Vault transit mount) lets you scope access by domain — the billing team holds the billing key; medical staff hold the medical key; the application process holds both. Rotating one does not affect the other.
When key: is omitted, the framework uses the keyID "default". Either
configure a key under that name or always tag with an explicit key.
Decryption on the read path
The DB step runs the decryption pass after every read:
- For list and read operations,
decryptFieldsreplaces everyenc:<base64>value with the decrypted plaintext. - HMAC companion columns are always stripped from the response.
- Values that do not have the
enc:prefix are left as-is — important for gradual adoption: enable encryption on a column whose existing rows are plaintext, and only new writes get encrypted.
If KeyProvider is nil but a model has encrypted fields, reads return
the raw stored ciphertext (so the application still functions in some
read-only sense), but writes are refused. Configuring a provider is the
only way to write encrypted columns.
Key rotation
maniflex.RotateEncryptionKey(ctx, server, modelName, oldKeyID, newKeyID)
re-encrypts every row of a model whose envelopes were encrypted with
oldKeyID:
n, err := maniflex.RotateEncryptionKey(ctx, server, "Patient", "v1", "v2")
if err != nil {
log.Fatal(err)
}
log.Printf("re-encrypted %d rows", n)
The function pages through the table 100 rows at a time, decrypts each
value with the old key, re-encrypts with the new key, and updates the
HMAC companion for any unique encrypted fields. Both keys must remain
available in the KeyProvider until the rotation completes — partial
rotations leave the table with a mix of old-key and new-key envelopes
until you finish.
The operation is not atomic across all rows. On failure, run it again —
it skips envelopes whose keyID already matches newKeyID, so a partial
rotation is safe to resume.
For large tables, run the rotation as a background job rather than at
startup. Each row is a separate UPDATE, so the operation is bound by
the database’s write throughput.
What an envelope looks like
The exact envelope format is the KeyProvider’s concern. The two
shipped providers use slightly different layouts, but both put a
self-describing header in front of the ciphertext so KeyIDOf can
extract the keyID without decrypting.
EnvKeyProvider — AES-256-GCM with an inline nonce:
[ version:1 ][ keyIDLen:2 (BE) ][ keyID:N ][ nonce:12 ][ gcmCiphertext+tag:M ]
VaultKeyProvider — Vault returns a vault:v1:... ciphertext that
embeds its own versioning, so the envelope carries no nonce:
[ version:1 ][ keyIDLen:2 (BE) ][ keyID:N ][ vaultCiphertext:M ]
In both cases version is 0x01 and the keyID length is a 16-bit
big-endian integer. The framework stores the binary envelope as the
string enc:<base64> in the column.
Encrypt produces the blob; Decrypt parses the header to recover the
keyID, then routes to the right key. KeyIDOf reads the keyID without
decrypting — useful for audit logging and for the rotation loop above.
A custom provider need not follow either format; the framework only
cares that Encrypt and Decrypt are inverses and that KeyIDOf works
on the output of Encrypt.
Compatibility with other features
| Feature | Interaction |
|---|---|
mfx:"encrypted" + unique | HMAC companion column; standard unique violation as 409 |
mfx:"encrypted" + filterable / sortable | not allowed — filterable/sortable tags are silently dropped at scan time |
mfx:"encrypted" + soft-delete | independent — soft-delete operates on a separate marker column |
mfx:"encrypted" + versioning | encrypted fields are excluded from diff and snapshot. History rows record metadata only, not plaintexts |
mfx:"encrypted" + audit log | the audit Changes diff excludes encrypted fields by default; use WithExcludeFields to add more |
mfx:"encrypted" + relations | a relation FK is never encrypted; relation joins remain unaffected |
Operational checklist
- Set
Config.KeyProviderbefore any encrypted-field model is registered. - Back keys with a secret store (env vars from a vault, HashiCorp Vault Transit, AWS KMS). Never commit a key to source control.
- For staged rollout, deploy the schema (
email_hmaccolumn) before the application change that starts encrypting — and the application change before the migration that backfills existing rows. - Keep both keys active throughout a rotation; remove the old key only
after
RotateEncryptionKeyhas reported every row migrated. - Treat
KeyIDOf(envelope)as the source of truth for “which key encrypted this row” — useful for auditing the rotation.
Versioning & History
A model marked Versioned keeps an immutable history of every write to it.
The framework creates a sibling {model}_history table at migration time
and appends one row per Create / Update / Delete. History rows are
queryable through the same REST surface as any other model, with one
restriction: they are read-only.
Opting in
Set Versioned: true in ModelConfig:
server.MustRegister(
Invoice{}, maniflex.ModelConfig{
Versioned: true,
},
)
Equivalent declaration on the embedded BaseModel:
type Invoice struct {
maniflex.BaseModel `mfx:"versioned"`
Number string `json:"number" mfx:"required,unique"`
Amount float64 `json:"amount" mfx:"required,min:0"`
}
Either form triggers two effects at registration:
- A synthetic
InvoiceHistorymodel is added to the registry — same as any other model, but read-only. - Three DB middlewares are attached to
Invoice: a pre-image capture beforeOpUpdate/OpDelete, and an After-DB writer for every write that succeeded.
The history table
The sibling table has a fixed schema, regardless of the source model’s columns:
| Column | Type | Notes |
|---|---|---|
id | TEXT | UUID, primary key of the history row itself |
record_id | TEXT | id of the source row this entry describes |
version | INTEGER | 1-based, monotonic per record_id |
operation | TEXT | "create", "update", or "delete" |
actor_id | TEXT | ctx.Auth.UserID at the time of the write; nullable |
timestamp | TIMESTAMP | UTC, set by the framework |
request_id | TEXT | the X-Request-Id of the producing request |
diff | TEXT | JSON {field: {old, new}} map |
snapshot | TEXT | full row state as JSON — omitted when VersionedDiffOnly is set |
AutoMigrate also adds an index idx_{table}_history_record_version on
(record_id, version DESC) for the standard “list history for one row”
query.
What gets diffed
diff records every changed scalar field. The format is:
{
"amount": {"old": 99.0, "new": 105.0},
"status": {"old": "draft", "new": "sent"}
}
OpCreate— every field is recorded as{"old": null, "new": value}.OpUpdate— only fields whose value differs between pre-image and post-image appear.OpDelete— every field is recorded as{"old": value, "new": null}.
Excluded by default:
- The primary key (
id). hiddenfields.writeonlyfields.encryptedfields and their{field}_hmaccompanions.
This avoids leaking secrets into history while still capturing the business-meaningful changes.
Snapshot vs. diff-only
By default each history row carries both the diff and the full
snapshot of the row state — convenient for “what did the record look
like on date X?” queries:
curl 'localhost:8080/api/invoice_histories?filter=record_id:eq:abc123&sort=version:desc&limit=1'
For high-write models the snapshot is the largest column by far.
VersionedDiffOnly: true skips the snapshot entirely:
server.MustRegister(
EventLog{}, maniflex.ModelConfig{
Versioned: true,
VersionedDiffOnly: true,
},
)
The trade-off: reconstructing the row state at version N requires walking all entries from version 1 to N and applying their diffs. For an audit trail used by humans (reading recent changes) this is fine; for point-in-time recovery, keep the snapshot.
Reading history
The history model is a normal registered model. The standard list and read endpoints work:
# All history rows for one invoice, newest first.
curl 'localhost:8080/api/invoice_histories
?filter=record_id:eq:abc123
&sort=version:desc'
# Recent activity by an actor.
curl 'localhost:8080/api/invoice_histories
?filter=actor_id:eq:user-alice
&sort=timestamp:desc
&limit=50'
record_id, operation, actor_id, and request_id are filterable;
version and timestamp are sortable. Write operations (POST,
PATCH, DELETE) on the history endpoint return 405 METHOD_NOT_ALLOWED
— the history is append-only by construction.
The history rows participate in OpenAPI generation, so /openapi.json
documents the endpoint alongside everything else.
Transactions and history
The history row is written in the same transaction as the source write — both succeed together or neither does. If the primary insert rolls back, no orphan history entry is left behind.
If the history write itself fails after a successful primary write, the
framework logs the error but does not fail the primary response.
Losing one history row is preferable to refusing a write that the user
already saw succeed. The error is logged via ctx.Logger() so an
operator can investigate.
Performance notes
- One additional
INSERTper write to a versioned model. Postgres handles this with a write multiplier of ~2x on the affected tables. - The
snapshotJSON is the dominant cost on row size. UseVersionedDiffOnlyfor verbose tables. - The
record_idindex is essential — every “history for one row” query uses it. Don’t drop it. - For very-high-write models, consider routing history to a separate table partition or a write-optimised store (TimescaleDB, ClickHouse) via a custom DB-After middleware instead of the built-in.
Comparison with audit logging
Audit Logging and Versioning solve different problems:
| Versioning | Audit Logging | |
|---|---|---|
| Storage | sibling DB table | configurable sink (DB, syslog, SIEM, …) |
| Granularity | per-row | per-row, optionally with diff |
| Transactional with the write | yes | yes (Before-DB) |
| Reconstruct prior state | yes — via snapshot or diff replay | no — only the change is recorded |
| Read API | the framework’s list/read on {model}_history | up to the sink |
| Best for | “what did this invoice look like a week ago?” | “who did what, when, across the whole system?” |
The two compose cleanly — turn on versioning for models that need reconstructable history, and audit-log everything for compliance.
Operational checklist
- Enable
Versionedon models whose change history matters for compliance, debugging, or undo. Don’t enable it on every model — the write multiplier adds up. - Choose
VersionedDiffOnly: truefor high-write tables where the diff alone is enough. - Plan storage growth: history is monotonic — older rows never go away unless you delete them out of band. Set up a retention job for very active models.
- Restrict access to the history endpoints with
auth.RequireRole— the diff and snapshot may contain values an end user shouldn’t see.
Scheduled Fields & the Runner
A mfx:"scheduled" tag on a *time.Time field declares a time-driven
transition: when the timestamp falls into the past, the framework applies
a configured action to the row. The mechanism is small but covers a
surprising number of real workflows — auto-publish, auto-archive,
soft-delete after expiry, scheduled status transitions.
This page covers both halves: the tag (declarative, per-model) and the runner (the background goroutine that actually applies transitions).
The tag
mfx:"scheduled" must appear on a *time.Time field (the pointer type
is required so “unset” is distinguishable from the zero time). The tag
takes one action and any number of qualifiers, separated by semicolons:
type Post struct {
maniflex.BaseModel
maniflex.WithDeletedAt
Title string `json:"title"`
Status string `json:"status" mfx:"required,enum:draft|published|archived,default:draft"`
// Auto-publish: set status=published when publish_at falls in the past.
PublishAt *time.Time `json:"publish_at" mfx:"scheduled;field=status;from=draft;to=published"`
// Auto-archive: set status=archived once archive_at falls in the past
// (no from= — applies regardless of current status).
ArchiveAt *time.Time `json:"archive_at" mfx:"scheduled;field=status;to=archived"`
// Auto-soft-delete: requires WithDeletedAt above.
ExpiresAt *time.Time `json:"expires_at" mfx:"scheduled;soft-delete"`
}
Actions
Exactly one action per scheduled field:
| Action | Effect when the timestamp passes |
|---|---|
soft-delete | sets the soft-delete marker — requires maniflex.WithDeletedAt or WithIsDeleted |
hard-delete | physically deletes the row, regardless of soft-delete config |
field=NAME;to=VALUE | sets the named field to the value |
Qualifiers
The field=...;to=... action accepts optional qualifiers:
| Qualifier | Effect |
|---|---|
from=VALUE | apply only when the named field currently equals this value |
to=VALUE | the value to assign (required for field=...) |
from= and to= are validated against the field’s enum (if any) at
registration time — a typo aborts the boot, not the first sweep.
Validation at registration
Every scheduled tag is resolved when ScanModel runs. Configurations
that don’t make sense are reported and the field is dropped from the
runner’s scope:
- Field type must be
*time.Time. - Exactly one of
soft-delete,hard-delete,field=is required. soft-deleterequires the model to be soft-deletable.field=requires ato=and references an existing column.from=/to=must be members of the target field’senum, if it has one.
A scheduled column automatically gets an IndexSpec added to the model
so the runner can locate due rows without a full scan.
The runner
The runner lives in maniflex/scheduled (its own satellite-style package).
It is opt-in — declaring scheduled tags makes the rows ready to be acted
on, but nothing happens until a runner is started.
import "github.com/xaleel/maniflex/scheduled"
runner, err := scheduled.New(server, scheduled.Config{
Interval: time.Minute,
BatchSize: 500,
})
if err != nil {
log.Fatal(err)
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
runner.Start(ctx)
defer runner.Stop()
scheduled.New walks the registry, picks up every model that declares a
scheduled field, and binds them to the runner. A registry with no
scheduled fields produces a usable no-op runner — callers can wire it
unconditionally and pay no cost.
Config
| Field | Default | Purpose |
|---|---|---|
Interval | 1m | how often the loop ticks |
BatchSize | 500 | maximum rows processed per (model, spec) per tick |
Logger | slog.Default() | structured log sink |
Clock | time.Now().UTC | injectable; tests override |
OnDelete | nil | callback func(model, id string) after a delete commits |
OnSetField | nil | callback func(model, id, field, to string) after a set-field commits |
The two hooks fire once per affected row, after the per-model transaction has committed. They run outside the transaction, so a hook panic does not roll back the write.
What one tick does
For each registered model with scheduled specs, in turn:
- Run a
SELECT id, <column>, <conditional fields> FROM <table> WHERE <column> <= now() AND ...to find rows due for action. Thefrom=qualifier becomes an additionalAND field = 'value'clause. - Open a per-model transaction.
- Apply the action to each row in the batch:
soft-delete→UPDATE table SET deleted_at = now() WHERE id = ?hard-delete→DELETE FROM table WHERE id = ?(via the adapter’sHardDeleteif available)field=NAME;to=VALUE→UPDATE table SET name = ? WHERE id = ?
- Commit the transaction.
- Fire
OnDelete/OnSetFieldhooks for each row, in order. - Move to the next model.
The per-model transaction means a single bad row aborts only that
model’s batch, not the whole sweep. Errors are appended to the tick’s
Report.Errors and logged.
Sweep for one-shot ticks
runner.Sweep(ctx) runs exactly one tick and returns the Report:
report, err := runner.Sweep(ctx)
log.Printf("deleted %d, updated %d across %d models",
report.Deleted, report.Updated, len(report.PerModel))
Useful in tests and for cron-driven deployments where the framework’s
internal ticker is the wrong fit. Sweep blocks until the pass completes.
Distributed runners
A single runner per cluster is enough for most workloads — the operations it performs (soft-delete, status flip) are idempotent. Two runners processing the same batch simultaneously would do redundant work but no incorrect work.
For at-most-once semantics in a multi-process deployment, run the
runner in only one replica (a leader-elected pod, a sidecar, a separate
deployment). Or use the scheduled/jobsx adapter, which bridges the
runner to a jobs queue so the sweep is enqueued as a durable job and
dispatched by the worker pool:
import (
"github.com/xaleel/maniflex/jobs"
"github.com/xaleel/maniflex/scheduled"
"github.com/xaleel/maniflex/scheduled/jobsx"
)
handler := jobsx.JobHandler(runner)
jobsQueue.Register("scheduled.sweep", handler)
cron := cron.New()
cron.Schedule("*/1 * * * *", func() {
jobsQueue.Enqueue("scheduled.sweep", nil)
})
In this setup the ticker drives the queue, not the runner directly — exactly one worker processes any given tick, even with many app replicas.
Hooks for events and audit
OnDelete and OnSetField are the natural place to emit events for
scheduled transitions, so downstream systems learn that a row’s status
changed even though no HTTP request caused the change:
runner, _ := scheduled.New(server, scheduled.Config{
OnSetField: func(model, id, field, to string) {
bus.Publish(events.Event{
Kind: "scheduled-transition",
Data: map[string]any{
"model": model, "id": id,
"field": field, "to": to,
},
})
},
})
The hook fires outside the database transaction. For at-least-once delivery semantics, write a row to an outbox table from inside the runner’s transaction (via a custom DB middleware on the affected models) rather than relying on the hook.
Interaction with versioning and audit
A scheduled transition is just an UPDATE (or DELETE) issued by the
runner. It flows through the model’s normal middleware:
Versionedmodels get a history row for the transition, withactor_id = NULL(noctx.Authexists in the runner).db.AuditLogrecords the write the same way.
This is intentional — a status change is a status change, regardless of whether a human or the runner triggered it.
When to use scheduled fields
| Need | Fit |
|---|---|
| Auto-publish at a fixed time | yes |
| Auto-archive / auto-expire | yes |
| Soft-delete on retention deadline | yes |
| Send an email at 9 AM tomorrow | not directly — use a job queue; the runner only mutates rows |
| Run a multi-step workflow at a deadline | not directly — hook into OnSetField to enqueue the workflow |
The runner is deliberately simple: timestamp + row-local change. For side-effecting work outside the database, use it as a trigger and delegate the actual work to a job queue.
Operational checklist
- One runner per cluster, started once, stopped on shutdown.
- Set
Intervalto the desired granularity —1mis plenty for most workflows; tighten if you have sub-minute deadlines. - Set
BatchSizeto a value the database can absorb in one transaction without blocking writers. 500 is a safe default; for very high-volume tables tune lower so each batch is shorter. - Use
OnDelete/OnSetFieldhooks for observability — emit events, increment metrics, log structured records. - For deployments with multiple app replicas, gate the runner to one
process or use
scheduled/jobsxto dispatch sweeps through your job queue. - Combine with
maniflex.WithDeletedAtfor the soft-delete-on-expiry pattern; the indexeddeleted_at IS NULLpredicate keeps the sweep query cheap as the table grows.
Audit Logging
db.AuditLog from the catalogue records every mutating operation to a
configured sink. Unlike Versioning — which writes to a
sibling table in the same database — audit log records are designed to be
shipped to an external system (a database table, a structured logger, a
SIEM). This page documents the record shape, the sink contract, and the
options that change what is captured.
Registering
The simplest registration captures the operation without per-field diffs:
import "github.com/xaleel/maniflex/middleware/db"
server.Pipeline.DB.Register(
db.AuditLog(mySink),
maniflex.ForOperation(maniflex.OpCreate, maniflex.OpUpdate, maniflex.OpDelete),
maniflex.AtPosition(maniflex.After),
)
mySink implements db.AuditSink:
type AuditSink interface {
Write(ctx context.Context, record AuditRecord) error
}
The audit record is emitted from a background goroutine with a 5-second timeout. Sink errors are logged but never fail the request — audit writes are fire-and-forget, by design. An audit pipeline that can fail the request is a liveness risk; an audit pipeline that occasionally drops a record is recoverable.
The record shape
Every audited write produces one AuditRecord:
type AuditRecord struct {
Timestamp time.Time `json:"timestamp"`
Model string `json:"model"`
Operation maniflex.Operation `json:"operation"`
ResourceID string `json:"resource_id,omitempty"`
Actor string `json:"actor,omitempty"`
TenantID string `json:"tenant_id,omitempty"`
RequestID string `json:"request_id,omitempty"`
TraceID string `json:"trace_id,omitempty"`
ServiceName string `json:"service_name,omitempty"`
Result any `json:"result,omitempty"`
Changes map[string]FieldChange `json:"changes,omitempty"`
}
type FieldChange struct {
From any `json:"from"`
To any `json:"to"`
}
| Field | Source |
|---|---|
Timestamp | UTC at the moment the record is built |
Model | ctx.Model.Name |
Operation | ctx.Operation |
ResourceID | ctx.ResourceID — empty on create until after the write |
Actor | ctx.Auth.UserID (empty for anonymous requests) |
TenantID | ctx.Auth.TenantID |
RequestID | ctx.RequestID (chi’s X-Request-Id) |
TraceID | ctx.TraceID (W3C traceparent) |
ServiceName | Config.ServiceName |
Result | ctx.DBResult — the row state returned by the adapter |
Changes | populated only when WithChanges() is set |
The minimum shape — Timestamp, Model, Operation, Actor,
RequestID — is enough to answer “who did what, when?” for compliance.
Adding Changes answers “what specifically was modified?”
Tracking changes
WithChanges() enables per-field diffs:
server.Pipeline.DB.Register(
db.AuditLog(sink, db.WithChanges()),
maniflex.ForOperation(maniflex.OpCreate, maniflex.OpUpdate, maniflex.OpDelete),
// No AtPosition — defaults to Before.
)
Important: WithChanges() requires the middleware to run at
maniflex.Before (the default position), not maniflex.After. The middleware
needs to read the row state before the DB step writes, so the diff has
both sides.
With WithChanges(), Changes is populated as:
| Operation | Changes map |
|---|---|
| Create | {field: {from: null, to: new_value}} for each non-default field |
| Update | {field: {from: old, to: new}} for each changed field |
| Delete | {field: {from: value, to: null}} for each field on the pre-image |
Fields that didn’t change between pre-image and post-image are omitted. Fields excluded from the diff (see below) are also omitted.
Excluding fields from the diff
WithExcludeFields("password", "api_key", "session_token") keeps named
fields out of the Changes map:
server.Pipeline.DB.Register(
db.AuditLog(sink, db.WithChanges(), db.WithExcludeFields(
"password", "ssn", "api_token",
)),
maniflex.ForOperation(maniflex.OpCreate, maniflex.OpUpdate, maniflex.OpDelete),
)
Use this for secrets that shouldn’t reach the audit pipeline even in
hashed form. Field names are matched against the DB column name (e.g.
api_token, not apiToken).
hidden, writeonly, and encrypted fields are excluded automatically;
WithExcludeFields is for things that don’t carry one of those tags but
still need to be redacted.
Common sinks
The sink interface is small enough to wire to anything that records structured events.
Database table
type DBAuditSink struct{ db *sql.DB }
func (s *DBAuditSink) Write(ctx context.Context, r db.AuditRecord) error {
changes, _ := json.Marshal(r.Changes)
_, err := s.db.ExecContext(ctx, `
INSERT INTO audit_logs
(timestamp, model, operation, resource_id, actor,
tenant_id, request_id, trace_id, service_name, changes)
VALUES (?,?,?,?,?,?,?,?,?,?)`,
r.Timestamp, r.Model, r.Operation, r.ResourceID, r.Actor,
r.TenantID, r.RequestID, r.TraceID, r.ServiceName, string(changes),
)
return err
}
A separate table — or a separate database — keeps audit volume from affecting the operational schema.
Structured logger
type LogSink struct{ log *slog.Logger }
func (s *LogSink) Write(ctx context.Context, r db.AuditRecord) error {
s.log.LogAttrs(ctx, slog.LevelInfo, "audit",
slog.String("model", r.Model),
slog.String("operation", string(r.Operation)),
slog.String("actor", r.Actor),
slog.String("request_id", r.RequestID),
slog.Any("changes", r.Changes),
)
return nil
}
The simplest sink — ships every audit event to the same log aggregator the rest of the app uses. Good for cold-storage compliance archives.
Async queue
For high-volume systems where the sink might back up, push records to a durable queue and process them out of band:
func (s *KafkaSink) Write(ctx context.Context, r db.AuditRecord) error {
b, _ := json.Marshal(r)
return s.producer.Produce(ctx, "audit-events", b)
}
A failed publish is logged but does not fail the request; the queue itself provides retry semantics.
Failure semantics
The middleware:
- Reads the pre-image (when
WithChanges()is set) before the DB step. - Calls
next(). - Checks the result. If
next()returned a non-nil error, the audit record is not written — we don’t audit failed operations. - Checks
ctx.Response. If status is>= 400, again no audit record. - Builds the record from the captured pre-image and
ctx.DBResult. - Spawns a goroutine that calls
sink.Writewith a 5-second background context.
This means:
- A failed write produces no audit entry. The framework’s other observability — request logs, error metrics — covers failed attempts.
- A successful write whose audit sink fails still succeeds. The audit record is lost.
- The audit write outlives the request context. A long-running audit write doesn’t block the HTTP response.
For at-least-once delivery, the sink must be backed by durable storage
(a database, a queue) — the in-process goroutine can be lost if the
process is killed before Write returns.
Audit log + versioning
Both record changes. Choose by where the records live and how they’re read:
| Concern | Audit log | Versioning |
|---|---|---|
| Storage | external sink | same DB, sibling table |
| Per-record reconstruction | no | yes (snapshot) |
| Compliance archive | yes | possible but awkward |
| Forensic forensics across the whole system | yes | per-model only |
| Cost | sink-dependent | one extra INSERT per write |
In a production system both are common: audit log feeds a SIEM for “who did what across everything”, versioning provides per-record history inside the app.
Operational checklist
- Pick a sink that matches your audit volume: database table for low volume, structured logs for medium, durable queue for high.
- Register at
maniflex.Beforewhen usingWithChanges(), atmaniflex.Afterotherwise. WithExcludeFieldsevery secret column that isn’t alreadywriteonly/hidden/encrypted.- Treat the sink as best-effort. Don’t rely on the in-process goroutine for legal-grade audit retention; use a sink whose own storage is durable.
- Index your audit table on
timestamp,actor,model, andresource_id— they are the columns most queries filter by. - Restrict who can read the audit table. The diffs may contain values
the original endpoint hid behind
RedactField.
Idempotency
Idempotency middleware makes POST requests safe to retry over a flaky
network. The first request runs the pipeline normally; the response is
cached keyed on the Idempotency-Key header. Subsequent requests with
the same key and the same body short-circuit the pipeline and replay
the cached response.
The pattern is borrowed from Stripe’s API. This page documents the
shipped implementation in maniflex/middleware/idempotency.
The contract
An Idempotency-Key identifies one logical operation. Sending the
same key twice with the same body means “I’m retrying — give me the
same result as last time, do not run the operation again.” Sending the
same key with a different body means “I am confused about my own
state and you should refuse me.” Sending no key means “do not apply
idempotency to this request.”
Idempotency-Key: e3b0c442-98fc-1c14-9afb-...
The key is opaque to the framework — any string the client chooses. A UUID per logical operation is the conventional choice.
Registering
The middleware lives on the Deserialize step at maniflex.After
position, scoped to the operations that should be retryable:
import (
"github.com/xaleel/maniflex"
"github.com/xaleel/maniflex/middleware/idempotency"
)
server.Pipeline.Deserialize.Register(
idempotency.Middleware(idempotency.Config{
Store: maniflex.NewMemoryCache(),
TTL: 24 * time.Hour,
}),
maniflex.ForOperation(maniflex.OpCreate),
maniflex.AtPosition(maniflex.After),
)
Why After on Deserialize: the middleware needs ctx.RawBody to compute
the body hash, and the default Deserialize handler populates that. Running
after the default ensures the body is present.
Config:
| Field | Default | Purpose |
|---|---|---|
Store | required | the cache backend (anything implementing maniflex.CacheStore) |
TTL | 24h | how long a cached response is replayable |
KeyFunc | ctx.Auth.UserID then ctx.Request.RemoteAddr | derives the per-caller scope |
HeaderRequired | false | when true, requests without Idempotency-Key are rejected with 400 |
Locker | in-process singleflight | serialises concurrent first-misses on the same key. Supply a Redis-SETNX implementation of idempotency.Locker for multi-replica deployments; see Concurrent first-misses |
The cache key
The cache key is composed of four parts:
<KeyFunc(ctx)>:<model>:<operation>:<idempotency-key>
KeyFunc(ctx)— the per-caller scope. Defaults to the authenticated user ID, falling back to the remote IP for anonymous requests. Override to use the API token or any other identifier.model:operation— limits a key’s effect to one (model, op) pair. The sameIdempotency-Keycan be reused safely for, say,POST /api/ordersandPOST /api/refunds— they are different cache keys.idempotency-key— the client-supplied value.
The body hash is not part of the key — it is part of the cached
entry and compared on lookup. This is intentional: it lets the
middleware detect “same key, different body” and respond with
422 IDEMPOTENCY_KEY_REUSED.
What gets cached
Only successful responses (2xx). Failed responses are not cached, on purpose — retrying a failed write is the whole point of idempotency. A first attempt that 5xx’d should be re-run on the retry, not replayed.
The cached entry carries:
type Entry struct {
maniflex.APIResponse // StatusCode, Data, Error, Meta
BodyHash string
StoredAt time.Time
}
StatusCode— replayed verbatim.Data,Meta— replayed verbatim.BodyHash— SHA-256 ofctx.RawBody, used to detect body mismatch.
The replayed response carries the header Idempotent-Replayed: true
so the client can tell a replay from a fresh execution.
What happens on each call
| Request | Effect |
|---|---|
First request with Idempotency-Key: K | runs the pipeline; if 2xx, caches the response |
| Repeat with same key, same body | skips the pipeline; replays cached response; adds Idempotent-Replayed: true |
| Repeat with same key, different body | 422 IDEMPOTENCY_KEY_REUSED |
| Repeat with same key after TTL | runs the pipeline as if it were the first time |
Request with no Idempotency-Key | passes through (unless HeaderRequired is true) |
Choosing a store
Two implementations cover the common cases.
maniflex.NewMemoryCache
In-process, per-replica:
idempotency.Config{Store: maniflex.NewMemoryCache(), TTL: time.Hour}
Suitable for single-replica development. In a multi-replica deployment each replica has its own cache — a retry routed to a different replica gets a fresh run, defeating the purpose.
Redis (or any shared store)
A shared cache backs the middleware across replicas:
import "github.com/xaleel/maniflex/middleware/db/redis"
store := redis.NewCacheStore(redisClient, "idempotency:")
server.Pipeline.Deserialize.Register(
idempotency.Middleware(idempotency.Config{
Store: store,
TTL: 24 * time.Hour,
}),
maniflex.ForOperation(maniflex.OpCreate),
maniflex.AtPosition(maniflex.After),
)
CacheStore is a four-method interface; any backend that can store a
TTL’d key/value (Redis, Memcached, DynamoDB with TTL) is a drop-in.
Concurrent first-misses
Two requests carrying the same Idempotency-Key and identical bodies that
arrive at exactly the same moment both miss the cache. Without
serialisation, both would run the full pipeline and both would write —
silently breaking the contract that one key represents one logical
operation. The middleware uses a Locker to serialise these first-misses.
The default Locker is in-process (singleflight-style): the second
goroutine blocks on a channel until the first releases, then re-checks
the cache and replays. This handles single-replica deployments correctly
out of the box.
For multi-replica deployments, supply Config.Locker with a backend that
synchronises across processes — typically Redis SETNX with a short TTL:
type Locker interface {
Acquire(ctx context.Context, key string, ttl time.Duration) (acquired bool, release func(), err error)
}
Acquire returns acquired=true to exactly one caller per key per TTL
window. The caller must invoke release once the cache entry is written
(or the work has failed). acquired=false means another caller holds (or
held) the lock — singleflight-style lockers block first, then return
false so the loser can replay from cache; SETNX-style lockers return
immediately and the loser re-checks the cache itself.
A Locker error (e.g. Redis network blip, request context cancelled)
fails open: the middleware runs the pipeline directly, mirroring the
pre-Locker behaviour rather than returning 503. This trades correctness
under partial outages for availability — appropriate for a feature whose
whole purpose is “make retries safe.”
Scoping to specific endpoints
For most APIs, idempotency belongs only on a handful of write endpoints —
payment, order placement, account creation. Scope with ForModel so
unrelated POST requests are unaffected:
server.Pipeline.Deserialize.Register(
idempotency.Middleware(idempotency.Config{Store: store}),
maniflex.ForModel("Payment", "Order"),
maniflex.ForOperation(maniflex.OpCreate),
maniflex.AtPosition(maniflex.After),
)
The middleware passes through for unscoped requests with no measurable cost.
Requiring the header
For endpoints where retries without a key are dangerous, set
HeaderRequired: true:
idempotency.Middleware(idempotency.Config{
Store: store,
HeaderRequired: true,
})
A scoped registration is the right shape — make the header mandatory on payment but optional on lower-stakes resources:
server.Pipeline.Deserialize.Register(
idempotency.Middleware(idempotency.Config{
Store: store, HeaderRequired: true,
}),
maniflex.ForModel("Payment"), maniflex.ForOperation(maniflex.OpCreate),
maniflex.AtPosition(maniflex.After),
)
A missing header on a covered endpoint returns
400 IDEMPOTENCY_KEY_REQUIRED.
Use with custom actions
Action endpoints run a trimmed pipeline that skips Deserialize, so
pipeline-level idempotency does not apply automatically. To get the
same behaviour for an action, include the middleware in the action’s
Middleware list:
server.Action(maniflex.ActionConfig{
Method: "POST",
Path: "/orders/place",
Handler: placeOrder,
Middleware: []maniflex.MiddlewareFunc{
auth.JWTAuth(secret),
idempotency.Middleware(idempotency.Config{Store: store}),
},
})
The middleware reads ctx.RawBody, so the action handler must call
ctx.BindJSON after the middleware runs — or read the body bytes
from ctx.RawBody directly.
Edge cases
- Same key, different body. Returns
422 IDEMPOTENCY_KEY_REUSED. The contract is that one key represents one logical operation; reusing it for a different payload is almost certainly a client bug. - Request currently in flight when retry arrives. The default
in-process
Locker(singleflight-style) holds the second goroutine until the first finishes, at which point it replays from cache — only one pipeline execution runs per process. For multi-replica deployments, supply aConfig.Lockerthat uses RedisSETNXso two replicas don’t both run the pipeline. See Concurrent first-misses below. - Cache eviction before TTL. The retry runs the pipeline again. This is correct behaviour: the cache is a replay mechanism, not a deduplication mechanism. The application’s own uniqueness constraints handle “this thing was already created.”
- Operation that mutates external state. Idempotency caches the
response, not the side effect. A payment that charged a card once on
the first request will return the same
paidresponse on a retry without charging again — because the first request returned with the payment recorded as committed. The action handler is responsible for being idempotent against the external system; the middleware just prevents the framework from issuing duplicate writes.
Operational checklist
- One shared
Storeacross replicas. Don’t useMemoryCachein multi-replica deployments. - TTL longer than your client’s longest retry window. 24h is generous; for mobile clients on flaky networks, 7d is reasonable.
- Scope to the endpoints that benefit. Don’t blanket-apply.
- Pair with
HeaderRequired: trueon payment-like endpoints where client correctness depends on it. - Surface the
Idempotent-Replayedheader in client SDKs so consumers can tell a replay from a fresh execution. - Combine with a uniqueness constraint at the DB level for defence-in-depth. A retry that hits a different replica after cache eviction will run the pipeline; the DB constraint catches the duplicate.
Outbound Integrations: pkg/integration
maniflex/pkg/integration is a small toolkit for the integration patterns
that sit beside the framework: calling third-party HTTP APIs, polling
hardware, and receiving signed webhooks. It’s not a feature of the
framework — it’s three composable types you call from your own code.
Caller — JSON-over-HTTP with retry
import "github.com/xaleel/maniflex/pkg/integration"
var billing = &integration.Caller{
BaseURL: "https://api.billing.example.com",
Timeout: 10 * time.Second,
MaxRetry: 3,
Headers: map[string]string{
"Authorization": "Bearer " + secrets.Billing,
},
}
// Inside a handler / job / cron tick:
var resp struct {
InvoiceID string `json:"invoice_id"`
}
err := billing.Post(ctx, "/invoices", map[string]any{
"amount": total,
"patient": id,
}, &resp)
- Always JSON in, JSON out. Pass
out=nilto discard the response body. - Passing
[]byteorstringas the body skips JSON encoding — useful for upstreams that demand a specific wire format. - Retries fire on network errors, HTTP 5xx, and HTTP 429 with a
configurable backoff (
BackoffFn; default linear up to 1s). 4xx (other than 429) is final. - Non-2xx final responses surface as
*integration.ErrHTTPStatus. Useerrors.Asto inspectStatusCode, the parsed JSONBody, or the rawRawBody. - Always honours the request context — cancel ctx to abort an in-flight retry loop.
Poller — periodic background work
p := &integration.Poller{
Interval: 30 * time.Second,
Fn: func(ctx context.Context) error {
return terminal.SyncFingerprints(ctx)
},
}
go p.Start(server.ShutdownContext()) // dies cleanly on shutdown
A failed tick is logged and the schedule continues — Poller is for
best-effort background work, not workflows where missing a tick is a bug.
For those, use pkg/jobs. Set RunOnStart: true to fire immediately rather
than waiting one Interval.
WebhookReceiver — HMAC-signed inbound
wh := &integration.WebhookReceiver{
Secret: secrets.PaymentWebhook,
Algorithm: "sha256", // or "sha512"
// Defaults are GitHub-style: X-Hub-Signature-256 + X-Event-Type
}
http.HandleFunc("/hooks/payments", wh.Handler(map[string]integration.WebhookHandler{
"payment.succeeded": handlePaymentSucceeded,
"payment.refunded": handlePaymentRefunded,
}))
The handler:
- Reads at most
MaxBodyBytes(default 1 MiB) from the request body. - Computes HMAC over the raw body and compares it constant-time to the
value in
HeaderKey. Commonalgo=hexprefixes (GitHub, Stripe) are tolerated. - Looks up the handler by the
EventHeaderKeyvalue. - Calls the handler with the raw body so it can decode whatever shape the upstream sends.
Failure modes:
- 400 — body read error
- 401 — missing or mismatching signature
- 404 — no handler registered for that event
- 500 — handler returned a non-nil error
WebhookReceiver.Handler panics if Secret is empty or Algorithm is
neither sha256 nor sha512 — both are configuration mistakes worth
catching at startup.
CSV / XLSX Export
Models can opt into an auto-generated export endpoint that streams CSV or XLSX of the same data the standard list endpoint returns, with the same filter and sort query parameters:
server.MustRegister(Invoice{}, maniflex.ModelConfig{
ExportEnabled: true,
MaxExportRows: 50_000, // optional cap; defaults to 100,000
})
This mounts:
GET /invoices/export → CSV (default)
GET /invoices/export?format=xlsx → XLSX
The endpoint reuses the full request pipeline — Auth, tenancy, soft-delete,
and any other middleware registered on ForOperation(maniflex.OpList) should
also list OpExport:
server.Pipeline.Auth.Register(
auth.JWTAuth(secret, auth.JWTOptions{}),
maniflex.ForOperation(maniflex.OpList, maniflex.OpExport),
)
Query parameters
The same filter, sort, and include parameters work as on GET /invoices.
page and limit are ignored — the export reads every row that matches the
filters, up to MaxExportRows.
GET /invoices/export?filter=status:eq:posted&sort=created_at:desc
Column selection
Each model field appears as one column, named by its json tag (the same
identifier you see in API responses). Hidden, writeonly, and file-typed
columns are excluded:
| Field tag | In export? |
|---|---|
| (default) | yes |
hidden | no |
writeonly | no |
file | no (raw storage keys are useless to the recipient) |
Computed fields registered via Server.AddComputedField are not included
yet — they require runtime evaluation per row and the export is read-only at
the storage layer.
Row cap
MaxExportRows (default 100,000) bounds the result. Requests whose filtered
result would exceed the cap return 413 Request Entity Too Large with a
suggestion to tighten the filters; no partial data is written. Increase the
cap if you have a deliberate need; consider an async job (pkg/jobs) for
multi-million-row exports.
Response shape
| Format | Content-Type | Content-Disposition |
|---|---|---|
| CSV | text/csv; charset=utf-8 | attachment; filename="<model>-<ts>.csv" |
| XLSX | application/vnd.openxmlformats-officedocument.spreadsheetml.sheet | attachment; filename="<model>-<ts>.xlsx" |
The XLSX writer produces a minimal-but-valid .xlsx workbook with one sheet
named Data. Strings are written as inline string cells — no shared-string
table, no styles, no formulas — which keeps the writer dependency-free
(stdlib archive/zip and encoding/xml only) at the cost of slightly
larger files than a heavyweight library would produce.
Unsupported ?format= values return 400 INVALID_FORMAT.
What’s not in v1
- Async / job-backed exports for multi-million-row datasets. Today’s
endpoint streams synchronously; the upper bound is
MaxExportRows. - Localised column headers. Headers always use the JSON field name.
- Style hints (number formats, frozen headers, autofilter). The XLSX is intentionally plain.
Open an issue if any of these matter for your use case.
Auth & Security Hardening
The defaults are safe to deploy, but production APIs benefit from a few extra layers. This page collects the practical checklist.
Authentication
- Use
auth.JWTAuthwith an asymmetric algorithm (RS256/ES256) when tokens are issued by an external provider. SymmetricHS256works when the signing service and the API share infrastructure. - Set
JWTOptions.IssuerandAudienceso tokens issued for another audience are rejected. - Set
JWTOptions.TenantClaimfor multi-tenant APIs — the verified value ends up onctx.Auth.TenantIDand feedsdb.Tenancy. - Never accept anonymous writes by default. Register
auth.JWTAuth(orauth.APIKeyAuth) on the Auth step scoped toOpCreate,OpUpdate,OpDelete. Useauth.AllowPublicReadwhen reads are truly public.
server.Pipeline.Auth.Register(auth.AllowPublicRead())
server.Pipeline.Auth.Register(auth.JWTAuth(secret, auth.JWTOptions{
Issuer: "https://accounts.example.com",
Audience: "https://api.example.com",
TenantClaim: "org_id",
}), maniflex.ForOperation(maniflex.OpCreate, maniflex.OpUpdate, maniflex.OpDelete))
Authorisation
- Gate sensitive operations with
auth.RequireRole. Don’t rely on the UI to hide them. - Use
db.Tenancyordb.ForceFilterfor row-level scoping. These run on the DB step so they apply to lists, reads, and writes uniformly — UI code cannot accidentally bypass them. - Strip privileged values with
validate.ForbiddenValuesfor role fields and similar — prevent a normal user from promoting themselves by including"role": "admin"in a payload.
Secrets and PII
- Hash passwords with
service.HashField, never store them raw. - Use
writeonlyon credential fields so they are accepted on input but never returned in responses. - Encrypt sensitive columns with
mfx:"encrypted"and a configuredKeyProvider. Pair with thekey:sub-option for per-domain keys. - Redact in responses with
response.RedactFieldwhen a column is visible to some callers and hidden from others.
Input
- Set
Config.QueryTimeoutso a slow query can’t tie up a connection indefinitely. - Cap body sizes with
body.MaxBodySizewhere you know the upper bound. The default 4 MB limit catches accidents, but a 10 KB endpoint should enforce 10 KB. - Strip unknown fields with
body.StripUnknownFieldsin environments where you want a strict contract — every accepted field appears on the model. - Validate beyond tags with
validate.RegexField,validate.UniqueField, andvalidate.CrossFieldValidate. The built-inmfx:rules cover the common cases; everything else belongs in middleware.
Output
- Set security headers globally via
response.AddHeader:Strict-Transport-Security,X-Content-Type-Options,Referrer-Policy. - Configure CORS explicitly with
response.CORSHeaders(opts)— the defaults are permissive for development. - Cap rate-sensitive endpoints with
db.RateLimitso password resets and similar can’t be brute-forced.
Transport
- Terminate TLS at the load balancer or reverse proxy, not in the maniflex
process. The framework is HTTP/1.1 + HTTP/2 ready and trusts the
X-Forwarded-*headers set upstream. - Set
Config.PathPrefixto a non-default value if the proxy mounts the API at a custom path. Don’t rewrite paths inside the application.
Operations
- Use a JSON-emitting
sloghandler in production so logs are structured and ingestable by your aggregator. - Set
Config.ServiceName— every log line and audit record carries it. - Enable
Config.HealthCheckDBfor Kubernetes readiness probes; tuneConfig.HealthTimeoutshorter than the probe timeout. - Use
Config.PanicLoggerto route panics to a different sink than the rest of the framework logs, so they are easier to alert on.
Audit
- Register
db.AuditLogatmaniflex.Afterfor mutating operations. The records carry actor, model, operation, and a diff of the affected row. - Use
maniflex.ModelConfig{Versioned: true}on sensitive models. Every change writes a row to a sibling{model}_historytable.
Checklist
A reasonable production stack:
// Auth
server.Pipeline.Auth.Register(auth.JWTAuth(secret, jwtOpts))
server.Pipeline.Auth.Register(auth.RequireRole("admin"),
maniflex.ForModel("User"), maniflex.ForOperation(maniflex.OpDelete))
// Body
server.Pipeline.Deserialize.Register(body.MaxBodySize(32<<10),
maniflex.ForModel("PasswordReset"))
server.Pipeline.Validate.Register(body.StripUnknownFields())
// DB
server.Pipeline.DB.Register(db.Tenancy("org_id", tenantFromAuth))
server.Pipeline.DB.Register(db.RateLimit(db.RateLimitConfig{
RequestsPerMinute: 10,
Key: keyByIP,
}), maniflex.ForModel("PasswordReset"))
server.Pipeline.DB.Register(db.AuditLog(auditSink),
maniflex.ForOperation(maniflex.OpCreate, maniflex.OpUpdate, maniflex.OpDelete),
maniflex.AtPosition(maniflex.After))
// Response
server.Pipeline.Response.Register(response.CORSHeaders(corsOpts))
server.Pipeline.Response.Register(
response.AddHeader("Strict-Transport-Security", "max-age=63072000"))
server.Pipeline.Response.Register(response.Logging(slog.Default()),
maniflex.AtPosition(maniflex.After))
PostgreSQL in Production
The maniflex/db/postgres adapter is the recommended backend for any deployment
beyond a single process. This page collects production-relevant details that
go beyond the Database Backends overview.
Opening the adapter
import "github.com/xaleel/maniflex/db/postgres"
// Single primary (no replica) — pass "" for the read DSN.
db, err := postgres.Open(
os.Getenv("DB_WRITE_URL"), // primary / write DSN (required)
os.Getenv("DB_READ_URL"), // replica / read DSN ("" → reuse the primary)
server.Registry(),
)
if err != nil {
log.Fatal(err)
}
server.SetDB(db)
Open takes the write DSN, the read DSN, and the registry. The write DSN is
required; pass "" for the read DSN to route reads at the primary. Both are
standard libpq connection strings or URLs
(postgres://user:pass@host:5432/dbname?sslmode=require). MustOpen is the
panic-on-error variant for package-level initialisation.
Tuning pools and session settings
Open applies production defaults. To override them, use OpenWithConfig,
which takes a separate PoolConfig for the write and read pools plus one
SessionConfig. Any zero-value field is replaced by the default Open uses, so
you set only what you want to change:
schema := "orders"
db, err := postgres.OpenWithConfig(
writeDSN, readDSN, server.Registry(),
postgres.PoolConfig{MaxOpenConns: 20}, // write pool
postgres.PoolConfig{MaxOpenConns: 60}, // read pool
postgres.SessionConfig{
StatementTimeout: 10 * time.Second,
ApplicationName: "orders-api",
SchemaName: &schema, // search_path; auto-created on connect if absent
},
)
Connection-pool tuning
The defaults suit a small service. OpenWithConfig exposes them as PoolConfig
fields, set independently for the write and read pools:
PoolConfig field | Default (write / read) | Considerations |
|---|---|---|
MaxOpenConns | 20 / 40 | keep the sum ≤ max_connections / number_of_app_instances |
MaxIdleConns | half of MaxOpenConns | enough open to absorb bursts, not so many you waste server slots |
ConnMaxLifetime | 30 min | rotate connections to pick up failover or DNS changes |
ConnMaxIdleTime | 5 min | close idle connections so PgBouncer can recycle |
If you front Postgres with PgBouncer in transaction-pooling mode:
- Set
MaxOpenConnson the client to roughly match the bouncer’sdefault_pool_size. - Avoid prepared statements that span transactions (the framework doesn’t use any; your raw queries should follow suit).
LISTEN/NOTIFYis not supported under transaction pooling — use the event-bus satellites instead.
Session settings
SessionConfig carries session-level parameters the adapter re-applies (SET …)
on every new physical connection — Postgres does not persist them across
reconnects, so they must be set per connection:
SessionConfig field | Default | Effect |
|---|---|---|
StatementTimeout | 30s | cancels any statement that runs longer (0 = server default) |
LockTimeout | 5s | aborts a statement that waits too long for a lock |
IdleInTransactionTimeout | 60s | aborts transactions left idle — guards against hung app code |
ApplicationName | maniflex | shown in pg_stat_activity and server logs |
TimeZone | UTC | session time zone for TIMESTAMPTZ rendering |
SchemaName | public | schema set as search_path (see below) |
Schema isolation (search_path)
By default the adapter operates in the public schema. Set
SessionConfig.SchemaName to scope every connection to a dedicated schema via
SET search_path — handy for multi-tenant deployments or co-locating several
apps in one database. The schema is created on connect when it does not yet
exist (CREATE SCHEMA IF NOT EXISTS), so AutoMigrate has somewhere to place
its tables; an existing schema is left untouched (a role with USAGE but not
CREATE still connects). The name must be a plain SQL identifier
([A-Za-z_][A-Za-z0-9_$]*); public is assumed to always exist and is never
re-created.
Read replicas
When a read DSN is supplied, OpList and OpRead operations are routed to the
read pool; everything else uses the write pool. Trade-offs:
- Reads inside an active write transaction route to the write pool, even when a read replica is configured — read-your-writes is preserved.
- Pure read endpoints get the replica’s spare capacity without any code change.
- The application sees the replica’s normal lag for non-transactional reads. If a workflow depends on read-your-writes outside a transaction, run it inside a write transaction so the read lands on the primary.
FOR UPDATE and pessimistic locking
ctx.LockForUpdate translates to SELECT … FOR UPDATE on Postgres. The lock
is held until the enclosing transaction commits or rolls back. Typical use:
row, err := ctx.LockForUpdate("StockBalance", stockID)
if err != nil {
return err
}
if row["quantity"].(int64) < 1 {
ctx.Abort(http.StatusConflict, "OUT_OF_STOCK", "no inventory")
return nil
}
// safe to subtract — concurrent writers are blocked
Combine with maniflex.WithTransaction (or manual BeginTx) so the lock has a
transaction to scope it.
Isolation levels
maniflex.WithTransaction(&maniflex.TxOptions{Isolation: sql.LevelSerializable}) opens
the request in SERIALIZABLE isolation. Postgres serialisation failures
produce 40001 errors, surfaced as *maniflex.ErrConstraint from the DB step —
which the default DB step converts to 409 CONFLICT. Clients are expected to
retry on this code.
Most APIs do fine with the default READ COMMITTED plus LockForUpdate on
the contested rows; reach for SERIALIZABLE when the contention pattern is
more complex than a single row.
AutoMigrate at scale
AutoMigrate is enabled by default. For larger production databases, prefer:
server := maniflex.New(maniflex.Config{AutoMigrate: false, ...})
…and run schema changes through a dedicated migration tool (sqlc-migrate, golang-migrate, Atlas, etc.). The framework’s auto-migrator is conservative — it never drops columns and emits straightforward DDL — but coordinating schema changes across replicas, dropped indexes, and rolling deploys is the migration tool’s job.
If you keep AutoMigrate enabled, run the first instance to completion
before scaling out; later instances will see all-up-to-date schema and skip
the work.
TLS and connectivity
- Use
sslmode=require(or stricter) on both the write and read DSN for any production connection. The driver respects the URL parameter. - For Cloud SQL / RDS, the connection string is generated by the cloud console; copy it verbatim and store it as a secret.
- Resolve DNS lookups inside the process — don’t pre-resolve at process
start. The
ConnMaxLifetimesetting then picks up the new endpoint automatically during failover.
Observability
- The adapter exposes pool statistics via
sql.DB.Stats(); export them with theresponse.Metricsmiddleware or a separate collector. - Set
Config.QueryTimeoutto bound slow queries; offending requests return504 TIMEOUTrather than holding a connection open. - Postgres logs (
log_min_duration_statement) andpg_stat_statementsare the canonical way to identify slow queries; the framework does not duplicate that.
Example 3: Order Processing System
This example assembles every advanced topic into one application — actions, raw queries, query models, a transactional outbox, and a background worker. The domain is small (orders and inventory) so the integration is visible.
Domain
type Product struct {
maniflex.BaseModel
Name string `json:"name" mfx:"required,filterable,sortable"`
Price float64 `json:"price" mfx:"required,min:0"`
Stock int64 `json:"stock" mfx:"required,min:0,filterable"`
}
type Order struct {
maniflex.BaseModel
maniflex.WithDeletedAt
CustomerID string `json:"customer_id" mfx:"required,filterable,immutable"`
Total float64 `json:"total" mfx:"required,min:0,filterable,sortable"`
Status string `json:"status" mfx:"required,enum:pending|paid|shipped|cancelled,default:pending,filterable,sortable"`
Lines []OrderLine `json:"lines,omitempty"`
}
type OrderLine struct {
maniflex.BaseModel
OrderID string `json:"order_id" mfx:"required,filterable,immutable"`
ProductID string `json:"product_id" mfx:"required,filterable,immutable"`
Quantity int64 `json:"quantity" mfx:"required,min:1"`
UnitPrice float64 `json:"unit_price" mfx:"required,min:0"`
}
// Transactional outbox row — appended in the same transaction as the order.
type OutboxEvent struct {
maniflex.BaseModel
Kind string `json:"kind" mfx:"required,filterable"`
Payload map[string]any `json:"payload" mfx:"required"`
Status string `json:"status" mfx:"required,enum:pending|done|failed,default:pending,filterable"`
ErrorMsg string `json:"error_msg" mfx:"filterable"`
}
Action: place an order atomically
POST /orders would normally just insert a row. Real order placement needs:
locking the products, decrementing stock, creating the order and lines,
queueing payment — all in one transaction. An action endpoint
handles this explicitly:
server.Action(maniflex.ActionConfig{
Method: "POST",
Path: "/orders/place",
Handler: placeOrder,
Middleware: []maniflex.MiddlewareFunc{auth.JWTAuth(secret)},
})
func placeOrder(ctx *maniflex.ServerContext) error {
var req struct {
Lines []struct {
ProductID string `json:"product_id"`
Quantity int64 `json:"quantity"`
} `json:"lines"`
}
if err := ctx.BindJSON(&req); err != nil {
return nil
}
tx, err := ctx.BeginTx(ctx.Ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
ctx.Tx = tx
// Reserve stock under a row lock for each product.
var total float64
type line struct {
productID string
qty int64
unit float64
}
var lines []line
for _, l := range req.Lines {
p, err := ctx.LockForUpdate("Product", l.ProductID)
if err != nil {
return err
}
stock := p["stock"].(int64)
if stock < l.Quantity {
ctx.Abort(http.StatusConflict, "OUT_OF_STOCK",
fmt.Sprintf("product %s has %d in stock", l.ProductID, stock))
return nil
}
if _, err := ctx.GetModel("Product").Update(l.ProductID, map[string]any{
"stock": stock - l.Quantity,
}); err != nil {
return err
}
unit := p["price"].(float64)
total += unit * float64(l.Quantity)
lines = append(lines, line{l.ProductID, l.Quantity, unit})
}
order, err := ctx.GetModel("Order").Create(map[string]any{
"customer_id": ctx.Auth.UserID,
"total": total,
"status": "pending",
})
if err != nil {
return err
}
for _, l := range lines {
if _, err := ctx.GetModel("OrderLine").Create(map[string]any{
"order_id": order["id"],
"product_id": l.productID,
"quantity": l.qty,
"unit_price": l.unit,
}); err != nil {
return err
}
}
// Outbox row — picked up by the background worker after commit.
if _, err := ctx.GetModel("OutboxEvent").Create(map[string]any{
"kind": "charge-payment",
"payload": map[string]any{"order_id": order["id"], "amount": total},
"status": "pending",
}); err != nil {
return err
}
if err := tx.Commit(); err != nil {
return err
}
ctx.Response = &maniflex.APIResponse{
StatusCode: http.StatusCreated,
Data: order,
}
return nil
}
If any step fails, the deferred Rollback reverts the order, the lines, and
the stock decrement together.
Query model: revenue report
The dashboard wants GET /revenue to return revenue per day. A
query model makes this a regular API endpoint:
type Revenue struct {
maniflex.BaseModel
Day string `json:"day" mfx:"filterable,sortable"`
Total float64 `json:"total" mfx:"sortable"`
}
server.MustRegister(Revenue{}, maniflex.ModelConfig{
QueryModel: &maniflex.QueryModelSpec{
SQL: `SELECT date(created_at) AS day, SUM(total) AS total
FROM orders
WHERE status IN ('paid', 'shipped')
GROUP BY day`,
},
})
Clients call GET /revenues?sort=day:desc&limit=30 and get the last 30 days
of revenue, paginated and filterable.
Background worker: process the outbox
A separate goroutine (or process) sweeps OutboxEvent, processes each event,
and updates its status. The worker uses the same registered models:
func runOutboxWorker(server *maniflex.Server) {
events := server.ModelAccessor("OutboxEvent")
for range time.Tick(2 * time.Second) {
rows, _ := events.List(&maniflex.QueryParams{
Filters: []*maniflex.FilterExpr{{
Field: "status", Operator: maniflex.OpEq, Value: "pending",
}},
Limit: 20,
})
for _, ev := range rows {
if err := process(ev); err != nil {
events.Update(ev["id"].(string), map[string]any{
"status": "failed",
"error_msg": err.Error(),
})
continue
}
events.Update(ev["id"].(string), map[string]any{"status": "done"})
}
}
}
The worker is part of the same binary in this example. In production, a
satellite from jobs/redis (see Events & Background Jobs)
replaces the polling loop with a durable queue and at-least-once delivery.
What this example tied together
- Actions for endpoints that don’t fit standard CRUD (
/orders/place). LockForUpdateto safely decrement stock under contention.maniflex.BeginTxso the order, lines, and stock change commit atomically.- The transactional outbox pattern for crossing the boundary between the request transaction and external side effects.
- A query model for the revenue report — a stable, filterable read endpoint built from raw SQL.
- A background worker consuming a registered model as its work queue.
Each piece is documented on its own page in the Advanced section: actions, raw queries, batch-saga, and events-jobs.
1. Overview & Scaffolding
This is the start of a ten-part walkthrough. The end product is a small but realistic bookstore API — users sign in, browse books and reviews, place orders, and the system notifies them when an order ships. Each step introduces one capability of the framework; nothing is hand-waved.
Where the reference pages describe a feature in isolation, the tutorial shows how the features compose in a real application.
What you’ll build
bookstore — an HTTP API with:
| Endpoint family | Capability |
|---|---|
/api/users | sign-up, JWT auth, role-based access |
/api/books, /api/authors, /api/genres | the catalogue, with relations and full querying |
/api/reviews | per-book ratings with custom validation |
Book cover upload via multipart/form-data | a file field |
POST /api/orders/place | a transactional action with stock locking |
| Outbox + background worker | email a receipt after each order |
By the end of part 10 the same code base will be deployed to production with PostgreSQL, env-driven configuration, and a health probe.
Prerequisites
- Go 1.25 or newer.
- A text editor and a terminal. Nothing else; the development database is pure-Go SQLite, no CGo needed.
Project layout
The app follows the layer-based layout described in App Anatomy. It grows over the tutorial; this is the shape at the end:
bookstore/
├── go.mod
├── main.go # wiring: create, register, set DB, serve
├── config.go # maniflex.Config assembly
├── models/ # one file per model
│ ├── user.go
│ ├── book.go
│ ├── author.go
│ ├── genre.go
│ ├── review.go
│ ├── order.go
│ └── outbox.go
├── middleware/ # custom middleware
│ ├── auth.go
│ ├── validate.go
│ └── register.go
├── actions/ # custom endpoints
│ └── orders.go
├── jobs/ # background workers
│ └── outbox.go
└── static/
└── openapi.html # bundled API viewer
Bootstrap
Create the directory, initialise a module, and add the framework:
mkdir bookstore && cd bookstore
go mod init bookstore
go get github.com/xaleel/maniflex github.com/xaleel/maniflex/db/sqlite
main.go starts as the smallest maniflex app:
package main
import (
"log"
"github.com/xaleel/maniflex"
"github.com/xaleel/maniflex/db/sqlite"
)
func main() {
server := maniflex.New(maniflex.Config{
Port: 8080,
PathPrefix: "/api",
AutoMigrate: true,
})
db, err := sqlite.Open("./bookstore.db", server.Registry())
if err != nil {
log.Fatal(err)
}
defer db.Close()
server.SetDB(db)
log.Fatal(server.Start())
}
Run it:
go run .
The server starts on :8080. Nothing is registered yet, so the only endpoint
that responds is /health — but GET /api/openapi.json already serves a
valid (empty) OpenAPI document.
Why the four-step shape
The framework’s lifecycle never deviates from the same four steps — create → register → set DB → serve. Everything we add in the next nine parts plugs into one of those steps:
- Create:
maniflex.Configgrows with logger, file storage, query timeout, and so on. - Register: more models, each carrying their
mfx:tags. - Set DB: SQLite for development, PostgreSQL by part 10.
- Serve: more pipeline middleware, but
Start()itself stays the same.
The structure of main.go will not change between this part and part 10. The
file simply grows new lines.
Next
In Part 2 — Users & Auth we add the User model, a
sign-up endpoint, and JWT-based authentication. By the end of part 2 every
write request will require a valid token.
2. Users & Auth
We start with the User model and the auth layer. By the end of this part,
the API has a sign-up endpoint, password hashing, JWT-based authentication on
all writes, and a role-based admin gate on user deletion.
The model
Create models/user.go:
package models
import "github.com/xaleel/maniflex"
type User struct {
maniflex.BaseModel
Email string `json:"email" mfx:"required,filterable,unique,immutable"`
Password string `json:"password" mfx:"required,writeonly,min:8"`
Name string `json:"name" mfx:"required,filterable,sortable"`
Role string `json:"role" mfx:"required,enum:admin|customer,default:customer,filterable"`
}
A few tag choices to notice:
emailisuniqueandimmutable— once a user signs up, the address is the account identity.passwordiswriteonlyso it is accepted on input but never appears in responses, andmin:8enforces a minimum length.roleis an enum with a safe default; we’ll gateadminwrites separately in middleware.
Register it from main.go:
import "bookstore/models"
server.MustRegister(models.User{})
That alone gives you POST /api/users (sign-up), GET /api/users/{id},
PATCH /api/users/{id}, DELETE /api/users/{id}, and GET /api/users. But
right now anyone can call any of them — we need to hash passwords on the way
in and gate the writes.
Hashing passwords
Add maniflex/middleware/service/bcrypt:
go get github.com/xaleel/maniflex/middleware/service/bcrypt
Then register the hashing middleware on the Service step, scoped to User
create and update:
import "github.com/xaleel/maniflex/middleware/service"
server.Pipeline.Service.Register(
service.HashField("password"),
maniflex.ForModel("User"),
maniflex.ForOperation(maniflex.OpCreate, maniflex.OpUpdate),
)
The middleware reads the password field (ctx.Field), replaces it with the
bcrypt hash via ctx.SetField, and lets the DB step write the hash. Nothing else in the application
needs to know that the column is hashed.
JWT authentication
Pull in maniflex/middleware/auth:
go get github.com/xaleel/maniflex/middleware/auth
Register JWTAuth on the Auth step, scoped to writes — we’ll let reads stay
public for now:
import "github.com/xaleel/maniflex/middleware/auth"
server.Pipeline.Auth.Register(
auth.JWTAuth("dev-secret", auth.JWTOptions{Issuer: "bookstore"}),
maniflex.ForOperation(maniflex.OpCreate, maniflex.OpUpdate, maniflex.OpDelete),
)
JWTAuth verifies the Authorization: Bearer <token> header, parses the
claims, and populates ctx.Auth with the user ID and roles. Tokens fail with
401 UNAUTHORIZED; missing tokens fail the same way.
Sign-up (POST /api/users) is itself a write — and a write that should
not require a token, since the user does not exist yet. Add an exception:
server.Pipeline.Auth.Register(
auth.AllowPublicWrite(),
maniflex.ForModel("User"), maniflex.ForOperation(maniflex.OpCreate),
)
AllowPublicWrite returns immediately for matching requests, bypassing the
JWT check. Registering it before JWTAuth (which we did) means it runs
first.
Role-gated deletes
Only admins should be able to delete users. auth.RequireRole does exactly
that:
server.Pipeline.Auth.Register(
auth.RequireRole("admin"),
maniflex.ForModel("User"), maniflex.ForOperation(maniflex.OpDelete),
)
It runs after JWTAuth, so by the time it fires ctx.Auth.Roles is
populated. Non-admin users receive 403 FORBIDDEN.
Issuing tokens
JWTAuth only verifies tokens — it does not issue them. For development we
add a tiny token endpoint as a custom action:
server.Action(maniflex.ActionConfig{
Method: "POST",
Path: "/auth/login",
Handler: login,
})
func login(ctx *maniflex.ServerContext) error {
var req struct {
Email string `json:"email"`
Password string `json:"password"`
}
if err := ctx.BindJSON(&req); err != nil {
return nil
}
rows, err := ctx.RawQuery(
`SELECT id, password, role FROM users WHERE email = ?`, req.Email,
)
if err != nil || len(rows) == 0 {
ctx.Abort(http.StatusUnauthorized, "INVALID_CREDENTIALS", "bad email or password")
return nil
}
user := rows[0]
if !checkBcrypt(user["password"].(string), req.Password) {
ctx.Abort(http.StatusUnauthorized, "INVALID_CREDENTIALS", "bad email or password")
return nil
}
token := signJWT("dev-secret", user["id"].(string), []string{user["role"].(string)})
ctx.Response = &maniflex.APIResponse{
StatusCode: http.StatusOK,
Data: map[string]any{"token": token},
}
return nil
}
signJWT and checkBcrypt are small helpers built on github.com/golang-jwt/jwt/v5
and maniflex/middleware/service/bcrypt. In production this endpoint would
issue a refresh token too — for now, a single bearer token is enough.
Trying it out
# Sign up
curl -X POST localhost:8080/api/users \
-H 'Content-Type: application/json' \
-d '{"email":"alice@example.com","password":"hunter22!","name":"Alice"}'
# Log in
TOKEN=$(curl -s -X POST localhost:8080/api/auth/login \
-H 'Content-Type: application/json' \
-d '{"email":"alice@example.com","password":"hunter22!"}' \
| jq -r .data.token)
# Authenticated read (lists are public, but writes need the token)
curl -X PATCH localhost:8080/api/users/<id> \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d '{"name":"Alice A."}'
What we built
| Capability | How |
|---|---|
| Sign-up | POST /api/users + AllowPublicWrite exception |
| Password hashing | service.HashField("password") on the Service step |
| Bearer-token auth on writes | auth.JWTAuth on the Auth step |
| Admin-only delete | auth.RequireRole("admin") |
| Token issuance | /api/auth/login action |
Next
In Part 3 — Modeling Domain Entities & Relations we add
the catalogue: Author, Genre, Book, and Review, wired up with
BelongsTo, HasMany, and many-to-many relations.
3. Modeling Domain Entities & Relations
With users in place, we model the catalogue. A book belongs to one author and many genres, and accumulates many reviews — three relations, two flavours.
The catalogue
Create the models. Each one lives in its own file under models/.
// models/author.go
type Author struct {
maniflex.BaseModel
Name string `json:"name" mfx:"required,filterable,sortable"`
Bio string `json:"bio"`
Books []Book `json:"books,omitempty"` // HasMany
}
// models/genre.go
type Genre struct {
maniflex.BaseModel
Label string `json:"label" mfx:"required,filterable,sortable,unique"`
Books []Book `json:"books,omitempty" mfx:"through:BookGenre"`
}
// models/book.go
type Book struct {
maniflex.BaseModel
Title string `json:"title" mfx:"required,filterable,sortable"`
ISBN string `json:"isbn" mfx:"required,filterable,unique"`
Price float64 `json:"price" mfx:"required,min:0,filterable,sortable"`
Stock int64 `json:"stock" mfx:"required,min:0,filterable"`
PublishedAt string `json:"published_at" mfx:"filterable,sortable"`
AuthorID string `json:"author_id" mfx:"required,filterable"` // BelongsTo Author
Genres []Genre `json:"genres,omitempty" mfx:"through:BookGenre"` // ManyToMany
Reviews []Review `json:"reviews,omitempty"` // HasMany
}
// models/book_genre.go — the junction model for Book ↔ Genre.
type BookGenre struct {
maniflex.BaseModel
BookID string `json:"book_id" mfx:"required,filterable,immutable"`
GenreID string `json:"genre_id" mfx:"required,filterable,immutable"`
}
// models/review.go
type Review struct {
maniflex.BaseModel
maniflex.WithDeletedAt
BookID string `json:"book_id" mfx:"required,filterable,immutable"` // BelongsTo Book
UserID string `json:"user_id" mfx:"required,filterable,immutable"` // BelongsTo User
Rating int `json:"rating" mfx:"required,min:1,max:5,filterable,sortable"`
Body string `json:"body" mfx:"required"`
}
Three relation styles in one place:
- BelongsTo (convention) —
Book.AuthorID→Author,Review.BookID→Book,Review.UserID→User. No tags required; the framework reads theIDsuffix. - HasMany —
Author.Books,Book.Reviews. A slice of the related struct, not a column on this table. - ManyToMany —
Book.Genres↔Genre.Booksthrough the explicitBookGenrejunction model. Thethrough:tag names the junction; both sides declare it.
Registering
All five models go to MustRegister:
server.MustRegister(
models.User{},
models.Author{},
models.Genre{},
models.Book{},
models.BookGenre{},
models.Review{},
)
AutoMigrate creates the tables. The junction book_genres carries
book_id and genre_id; the framework wires the Book ↔ Genre relation
from the through: tag.
Trying the relations
Create an author, a genre, and a book:
AUTH=$(curl -s -X POST localhost:8080/api/authors -H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' -d '{"name":"Ursula K. Le Guin"}' | jq -r .data.id)
SCIFI=$(curl -s -X POST localhost:8080/api/genres -H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' -d '{"label":"Science Fiction"}' | jq -r .data.id)
BOOK=$(curl -s -X POST localhost:8080/api/books -H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d "{\"title\":\"The Dispossessed\",\"isbn\":\"9780061054884\",\"price\":12.99,\"stock\":10,\"author_id\":\"$AUTH\"}" \
| jq -r .data.id)
# Tag it as sci-fi via the junction model.
curl -X POST localhost:8080/api/book_genres -H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d "{\"book_id\":\"$BOOK\",\"genre_id\":\"$SCIFI\"}"
Now include the related rows in a read:
curl "localhost:8080/api/books/$BOOK?include=author,genres,reviews"
The response carries author (a single object), genres (an array), and
reviews (an empty array for now). Each include is a separate query against
the related table.
Filtering through relations
Filters can traverse relations using dot notation. The related field must be
filterable:
# All books written by anyone whose name starts with "Ursula"
curl "localhost:8080/api/books?filter=author.name:ilike:Ursula%25"
# All books in the "Science Fiction" genre
curl "localhost:8080/api/books?filter=genres.label:eq:Science+Fiction&include=genres"
Filtering does not require an include; including merely returns the related
rows. You can join one and not the other freely.
Cascading deletes
A deleted author should not orphan books. Update the Book.AuthorID tag to
declare a cascade:
AuthorID string `json:"author_id" mfx:"required,filterable,relation:Author;onDelete:cascade"`
Author Author `json:"author,omitempty"`
The companion Author field is now needed because the explicit relation:
tag must name a companion field of the target type. We also gain a slightly
better OpenAPI: the spec carries the relation explicitly.
onDelete:setNull and onDelete:restrict are the alternatives — see
Relations.
What we built
| Concept | Where |
|---|---|
| BelongsTo (convention) | Book.AuthorID, Review.BookID, Review.UserID |
| HasMany | Author.Books, Book.Reviews |
| ManyToMany | Book.Genres ↔ Genre.Books via BookGenre |
| Explicit relation with cascade | Book.AuthorID after the cascade edit |
| Soft delete | maniflex.WithDeletedAt on Review |
| Filtering through relations | ?filter=author.name:ilike:Ursula% |
Next
In Part 4 — Validation & Business Rules we tighten the rules: ISBNs follow a specific format, a user may not review the same book twice, and reviews on out-of-stock books are blocked.
4. Validation & Business Rules
The mfx: tag rules from Part 3 cover the common case — required fields,
numeric ranges, enums, uniqueness hints. Anything that goes beyond a single
field belongs in Validate-step middleware. This part adds three rules:
- ISBNs must be 13 digits with hyphens optional.
- A user may not review the same book twice.
- A user must have bought a book before reviewing it.
Field-format validation
validate.RegexField is enough for the ISBN check:
import "github.com/xaleel/maniflex/middleware/validate"
server.Pipeline.Validate.Register(
validate.RegexField("isbn", `^(?:97[89])?\d{10}$`),
maniflex.ForModel("Book"),
)
The middleware runs after the mfx: tag rules. A malformed ISBN aborts the
request with 422 VALIDATION_FAILED and the field in details.
We strip hyphens before validating so the client can send the human-readable form. A small Service-step middleware does the rewrite:
server.Pipeline.Service.Register(func(ctx *maniflex.ServerContext, next func() error) error {
if raw, ok := ctx.Field("isbn"); ok {
if v, ok := raw.(string); ok {
ctx.SetField("isbn", strings.ReplaceAll(v, "-", ""))
}
}
return next()
}, maniflex.ForModel("Book"), maniflex.ForOperation(maniflex.OpCreate, maniflex.OpUpdate))
Order matters: Validate runs before Service in the pipeline. We’re rewriting
after validation has confirmed the cleaned-up format would pass. To
validate the cleaned value, swap the registration so the cleanup is on
Validate at maniflex.Before (the default).
“One review per book per user”
Two flavours of uniqueness:
- Schema uniqueness (
mfx:"unique") — adds aUNIQUEconstraint on a single column. Good for an email address. - Cross-column uniqueness — needs custom validation, because no single column is unique.
For reviews we need both book_id and user_id to be unique together. A
small middleware that consults the database:
server.Pipeline.Validate.Register(func(ctx *maniflex.ServerContext, next func() error) error {
bookID, _ := ctx.Field("book_id")
rows, err := ctx.RawQuery(
`SELECT id FROM reviews
WHERE book_id = ? AND user_id = ? AND deleted_at IS NULL`,
bookID, ctx.Auth.UserID,
)
if err != nil {
return err
}
if len(rows) > 0 {
ctx.Abort(http.StatusConflict, "ALREADY_REVIEWED",
"you have already reviewed this book")
return nil
}
return next()
}, maniflex.ForModel("Review"), maniflex.ForOperation(maniflex.OpCreate))
Two things to notice:
- We use
ctx.RawQueryrather thanctx.GetModel(...).Listbecause we need a count, not the rows. Either works. - The
user_idwe check isctx.Auth.UserID, not the body’suser_id. In the next section we’ll force the body field to match.
“Body owner must match authenticated user”
Letting a client supply user_id is asking for impersonation. service.OwnerScope
from the catalogue forces the field on every create:
import "github.com/xaleel/maniflex/middleware/service"
server.Pipeline.Service.Register(
service.OwnerScope("user_id"),
maniflex.ForModel("Review"), maniflex.ForOperation(maniflex.OpCreate),
)
OwnerScope reads ctx.Auth.UserID and sets it on the body via ctx.SetField,
overwriting whatever the client sent. A client who omits the field gets it
filled in; a client who sets it to someone else’s ID has the value
overwritten silently.
This is a good place to apply Forbidden values
on role for User too — defence in depth against a privilege-escalation
payload:
server.Pipeline.Validate.Register(
validate.ForbiddenValues("role", "admin"),
maniflex.ForModel("User"), maniflex.ForOperation(maniflex.OpCreate),
)
A normal sign-up cannot self-promote to admin; an admin still can, because
they go through PATCH not POST, and the rule is scoped to create only.
Cross-field rules
The third rule — review only books you’ve bought — depends on a model
(Order) we haven’t built yet. We come back to it in Part 7 once orders
exist, using the same Validate.Register shape with a join query:
server.Pipeline.Validate.Register(func(ctx *maniflex.ServerContext, next func() error) error {
bookID, _ := ctx.Field("book_id")
rows, _ := ctx.RawQuery(
`SELECT 1
FROM order_lines ol
JOIN orders o ON o.id = ol.order_id
WHERE o.customer_id = ?
AND ol.book_id = ?
AND o.status IN ('paid','shipped')`,
ctx.Auth.UserID, bookID,
)
if len(rows) == 0 {
ctx.Abort(http.StatusForbidden, "PURCHASE_REQUIRED",
"you may only review books you have bought")
return nil
}
return next()
}, maniflex.ForModel("Review"), maniflex.ForOperation(maniflex.OpCreate))
We leave this in the codebase as a stub for now and complete it in Part 7.
Where each rule lives
| Rule | Where | Why |
|---|---|---|
| ISBN format | Validate (catalogue) | format check on one field |
| One-review-per-book | Validate (custom) | queries another row |
user_id belongs to caller | Service (catalogue) | mutates body |
role cannot be admin on sign-up | Validate (catalogue) | rejects body value |
| Must have purchased | Validate (custom, deferred) | cross-model query |
The general rule: field-level rules go in Validate; rules that mutate the body go in Service; rules that need the row to exist go in After-DB.
Next
In Part 5 — File Uploads we add a cover image to each book, served from local storage during development and ready to swap for S3 in production.
5. File Uploads
A book needs a cover image. In this part we add a file field to the Book
model, configure local storage for development, and learn how the same code
handles a swap to S3 in production.
Adding the file field
Edit models/book.go:
type Book struct {
maniflex.BaseModel
Title string `json:"title" mfx:"required,filterable,sortable"`
ISBN string `json:"isbn" mfx:"required,filterable,unique"`
Price float64 `json:"price" mfx:"required,min:0,filterable,sortable"`
Stock int64 `json:"stock" mfx:"required,min:0,filterable"`
PublishedAt string `json:"published_at" mfx:"filterable,sortable"`
AuthorID string `json:"author_id" mfx:"required,filterable,relation:Author;onDelete:cascade"`
Author Author `json:"author,omitempty"`
Cover string `json:"cover" mfx:"file,max_size:2MB,accept:image/png|image/jpeg"`
Genres []Genre `json:"genres,omitempty" mfx:"through:BookGenre"`
Reviews []Review `json:"reviews,omitempty"`
}
The cover field is a string in Go and a string in the database, but the
mfx:"file" tag opts the model into multipart uploads. The column stores the
storage key — a path under whichever backend you have configured.
max_size and accept are enforced in the framework, before the upload
reaches storage. A 5 MB JPEG or a application/pdf is rejected with
400 BAD_REQUEST and never written.
Configuring storage
For development we use local disk. The maniflex/storage package ships a ready
implementation:
import "github.com/xaleel/maniflex/storage"
fs, err := storage.NewLocalStorage("./uploads")
if err != nil {
log.Fatal(err)
}
server := maniflex.New(maniflex.Config{
Port: 8080,
PathPrefix: "/api",
AutoMigrate: true,
FileStorage: fs,
})
./uploads is created if it doesn’t exist. Every uploaded file lands under
uploads/<uuid>/<sanitised-filename> so collisions are impossible.
Uploading a cover
There are two ways to attach a cover, both supported out of the box.
1. Multipart upload alongside create
The client sends multipart/form-data with one part per field:
curl -X POST localhost:8080/api/books \
-H "Authorization: Bearer $TOKEN" \
-F 'title=The Dispossessed' \
-F 'isbn=9780061054884' \
-F 'price=12.99' \
-F 'stock=10' \
-F "author_id=$AUTH" \
-F 'cover=@./covers/dispossessed.jpg;type=image/jpeg'
The framework parses the multipart envelope, streams cover into the
storage backend, writes the resulting key into the column, and persists the
row. The response is the usual JSON envelope:
{
"data": {
"id": "...",
"title": "The Dispossessed",
"cover": "uploads/3f2b.../dispossessed.jpg",
...
}
}
2. Two-step upload + reference
For large files or out-of-band uploads, hit the standalone
/files endpoint first:
KEY=$(curl -s -X POST localhost:8080/files \
-H "Authorization: Bearer $TOKEN" \
-F 'file=@./covers/dispossessed.jpg;type=image/jpeg' \
| jq -r .data.key)
curl -X POST localhost:8080/api/books \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d "{\"title\":\"The Dispossessed\",\"isbn\":\"9780061054884\",\"price\":12.99,\"stock\":10,\"author_id\":\"$AUTH\",\"cover\":\"$KEY\"}"
The file field accepts a plain string in JSON — the storage key returned
by /files. The framework recognises that the value is already a key (not a
new upload) and stores it as-is.
Downloading a cover
Storage keys are served at /files/{key...}:
curl 'localhost:8080/files/uploads/3f2b.../dispossessed.jpg' --output cover.jpg
The handler sets Content-Type, Content-Disposition: inline, and
Content-Length from the metadata stored alongside the file.
For a permission layer in front of downloads — say, only registered users can fetch covers — add Auth middleware to the file route just as you would for any model route.
Automatic cleanup
The framework tracks the row that owns each key. A file is deleted from storage when:
- the owning row is hard-deleted, or
- the field is overwritten by a
PATCHthat supplies a new file or key.
Book does not embed WithDeletedAt, so a delete is a hard-delete and the
cover goes away too. If you want covers to outlive book deletions (for an
audit trail), tag the field with auto_delete:false:
Cover string `json:"cover" mfx:"file,max_size:2MB,accept:image/*,auto_delete:false"`
Swapping in S3
FileStorage is a four-method interface — Store, Retrieve, Delete,
Exists. A drop-in S3 implementation looks like:
type S3Storage struct{ client *s3.Client; bucket string }
func (s *S3Storage) Store(ctx context.Context, key string, r io.Reader, meta maniflex.FileMeta) error {
_, err := s.client.PutObject(ctx, &s3.PutObjectInput{
Bucket: &s.bucket,
Key: &key,
Body: r,
ContentType: &meta.ContentType,
})
return err
}
// Retrieve, Delete, Exists similarly.
Swap storage.NewLocalStorage(...) for the new type in main.go and nothing
else changes. The same model code, the same endpoints, the same multipart
parser. The model never knows.
What we built
| Capability | How |
|---|---|
| File field on Book | mfx:"file,max_size:...,accept:..." |
| Local storage backend | storage.NewLocalStorage("./uploads") |
| Multipart upload | The framework auto-detects multipart/form-data on create/update |
| Pre-uploaded key reference | Plain string in the JSON body |
| Standalone upload | POST /files, returns a key |
| Backend-agnostic | maniflex.FileStorage interface — swap to S3 with no model change |
Next
In Part 6 — Filtering, Sorting & Pagination we build a catalogue browser: lookup books by title, sort by price or publication date, paginate the results, and combine includes with filters.
6. Filtering, Sorting & Pagination
The catalogue is in place. This part stitches together the query parameters
exposed by every list endpoint — filter, sort, include, page,
limit — to build a real browse experience.
Recap: opt-in fields
Every queryable field carries the relevant mfx: tag. Book’s fields are
already tagged from Part 3:
| Field | Tags |
|---|---|
title | filterable,sortable |
isbn | filterable,unique |
price | filterable,sortable |
stock | filterable |
published_at | filterable,sortable |
author_id | filterable |
Untagged fields are deliberately invisible to clients — a query string that
references them is rejected with 400 INVALID_QUERY.
Filter operators
All the operators on one model:
# Title contains "wind" (case-insensitive)
curl 'localhost:8080/api/books?filter=title:ilike:%25wind%25'
# Priced between $10 and $20
curl 'localhost:8080/api/books?filter=price:gte:10&filter=price:lte:20'
# Out of stock
curl 'localhost:8080/api/books?filter=stock:eq:0'
# In any of three genres
curl 'localhost:8080/api/books?filter=genres.label:in:Fantasy,Sci-Fi,Mystery'
# Published after a date, returned newest first
curl 'localhost:8080/api/books?filter=published_at:gte:2020-01-01&sort=published_at:desc'
Multiple filters compose with AND. The framework parses each filter once in
the Deserialize step into ctx.Query.Filters, then the DB step translates
the slice into a WHERE clause.
Relation filters
?filter=genres.label:in:... filters through the many-to-many junction.
Dot-notation works on any relation whose target field is filterable:
# Books by an author whose name contains "Le Guin"
curl 'localhost:8080/api/books?filter=author.name:ilike:%25Le+Guin%25&include=author'
# Reviews left on books with a specific ISBN
curl 'localhost:8080/api/reviews?filter=book.isbn:eq:9780061054884&include=book'
The include is independent of the filter — you can filter on a relation without returning it, and vice versa.
Sorting
?sort=field:direction for one column, repeat for tie-breakers:
# Cheapest first, oldest among ties
curl 'localhost:8080/api/books?sort=price:asc&sort=published_at:asc'
Only sortable fields work. BaseModel’s created_at and updated_at are
sortable by default.
Pagination
The defaults — page 1, 20 per page — work everywhere. Override per request:
curl 'localhost:8080/api/books?page=2&limit=50'
limit is clamped at 200; oversize requests are silently reduced. List
responses carry pagination metadata in meta:
{
"data": [ ... ],
"meta": { "total": 137, "page": 2, "limit": 50, "pages": 3 }
}
For models where the rows are expensive to render — full audit logs,
analytics tables — register db.Paginate from the catalogue to lower the
ceiling per model:
import "github.com/xaleel/maniflex/middleware/db"
server.Pipeline.DB.Register(db.Paginate(50), maniflex.ForModel("AuditLog"))
Includes
?include=relation1,relation2 populates nested objects in the response.
Includes are separate queries — they do not multiply rows or affect
pagination of the primary list:
curl 'localhost:8080/api/books/<id>?include=author,genres,reviews'
The relation keys come from the model declarations — see
Relations for how they are derived. For a BelongsTo the
result is a single nested object; for HasMany and ManyToMany, an array.
Combining everything
A realistic “browse” call:
curl 'localhost:8080/api/books?filter=genres.label:eq:Science+Fiction
&filter=stock:gt:0
&filter=price:lte:20
&sort=published_at:desc
&include=author,genres
&page=1
&limit=12'
The framework executes this as:
- Parse query →
ctx.Query.Filters,Sorts,Includes,Page,Limit. - Run the main
SELECTwith the WHERE + ORDER BY + LIMIT/OFFSET. - Issue follow-up queries for each include, batched by foreign key.
- Compose the JSON envelope.
Hardcoding tenant scope
In some applications a request from one customer should never see another
customer’s rows. We don’t have multi-tenancy in the bookstore, but the
mechanism is worth knowing. db.Tenancy enforces row-level scoping
unconditionally:
server.Pipeline.DB.Register(
db.Tenancy("organization_id", func(ctx *maniflex.ServerContext) string {
return ctx.Auth.TenantID
}),
)
Once registered, every list, read, update, and delete is silently filtered to
organization_id = ctx.Auth.TenantID. The client cannot override or escape
it.
Custom filters in middleware
ctx.Query.Filters is a slice — middleware can append to it before the DB
step runs. We’ll use this in Part 7 when we want logged-in customers to see
only their own orders:
server.Pipeline.Service.Register(func(ctx *maniflex.ServerContext, next func() error) error {
ctx.Query.Filters = append(ctx.Query.Filters, &maniflex.FilterExpr{
Field: "customer_id",
Operator: maniflex.OpEq,
Value: ctx.Auth.UserID,
})
return next()
}, maniflex.ForModel("Order"), maniflex.ForOperation(maniflex.OpList))
The pattern is the same as the bigger Tenancy middleware — write a filter,
let the DB step honour it.
Next
In Part 7 — Custom Endpoints & Actions we add order placement: a transactional action that locks stock, creates the order and its lines, and writes an outbox row that Part 8’s background worker will consume.
7. Custom Endpoints & Actions
Customers need to place orders. A simple POST /api/orders would just
insert one row, but real order placement also has to lock stock, create
line items, and queue a downstream notification — all atomically. This is
the textbook use case for a custom action.
The models
Two new entities. Both opt into soft-delete so an audit trail survives.
// models/order.go
type Order struct {
maniflex.BaseModel
maniflex.WithDeletedAt
CustomerID string `json:"customer_id" mfx:"required,filterable,immutable"`
Total float64 `json:"total" mfx:"required,min:0,filterable,sortable"`
Status string `json:"status" mfx:"required,enum:pending|paid|shipped|cancelled,default:pending,filterable,sortable"`
Lines []OrderLine `json:"lines,omitempty"`
}
// models/order_line.go
type OrderLine struct {
maniflex.BaseModel
OrderID string `json:"order_id" mfx:"required,filterable,immutable"`
BookID string `json:"book_id" mfx:"required,filterable,immutable"`
Quantity int64 `json:"quantity" mfx:"required,min:1"`
UnitPrice float64 `json:"unit_price" mfx:"required,min:0"`
}
// models/outbox.go — Part 8 will consume rows from here.
type OutboxEvent struct {
maniflex.BaseModel
Kind string `json:"kind" mfx:"required,filterable"`
Payload map[string]any `json:"payload" mfx:"required"`
Status string `json:"status" mfx:"required,enum:pending|done|failed,default:pending,filterable"`
ErrorMsg string `json:"error_msg"`
}
Register them. We also tenancy-scope reads of Order to the calling
customer so one user cannot list another’s orders:
server.MustRegister(models.Order{}, models.OrderLine{}, models.OutboxEvent{})
server.Pipeline.Service.Register(func(ctx *maniflex.ServerContext, next func() error) error {
ctx.Query.Filters = append(ctx.Query.Filters, &maniflex.FilterExpr{
Field: "customer_id", Operator: maniflex.OpEq, Value: ctx.Auth.UserID,
})
return next()
}, maniflex.ForModel("Order"), maniflex.ForOperation(maniflex.OpList))
Why an action
The standard POST /api/orders would insert one Order row and stop. We
need:
- Lock the books so concurrent buyers don’t oversell stock.
- Decrement stock on every line.
- Create the order.
- Create one
OrderLineper book. - Append an outbox row describing the order, in the same transaction.
A single transaction must cover all five. The Service step on POST /orders
sees only the order body — the lines come from the client. We could write
five middleware functions, but a custom action keeps
the transaction obvious and the trimmed pipeline lighter:
Auth → action handler → Response
Deserialize, Validate, Service, and DB are skipped. Our handler does
its own parsing and database work.
The handler
actions/orders.go:
package actions
func PlaceOrder(ctx *maniflex.ServerContext) error {
var req struct {
Lines []struct {
BookID string `json:"book_id"`
Quantity int64 `json:"quantity"`
} `json:"lines"`
}
if err := ctx.BindJSON(&req); err != nil {
return nil
}
if len(req.Lines) == 0 {
ctx.Abort(http.StatusBadRequest, "EMPTY_ORDER", "an order must contain at least one line")
return nil
}
tx, err := ctx.BeginTx(ctx.Ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
ctx.Tx = tx
// 1+2: lock each book row and decrement stock.
type planned struct {
bookID string
quantity int64
unitPrice float64
}
var plan []planned
var total float64
for _, l := range req.Lines {
book, err := ctx.LockForUpdate("Book", l.BookID)
if err != nil {
ctx.Abort(http.StatusNotFound, "BOOK_NOT_FOUND",
fmt.Sprintf("book %s does not exist", l.BookID))
return nil
}
stock := book["stock"].(int64)
if stock < l.Quantity {
ctx.Abort(http.StatusConflict, "OUT_OF_STOCK",
fmt.Sprintf("book %s has %d in stock", l.BookID, stock))
return nil
}
if _, err := ctx.GetModel("Book").Update(l.BookID, map[string]any{
"stock": stock - l.Quantity,
}); err != nil {
return err
}
price := book["price"].(float64)
total += price * float64(l.Quantity)
plan = append(plan, planned{l.BookID, l.Quantity, price})
}
// 3: the Order row.
order, err := ctx.GetModel("Order").Create(map[string]any{
"customer_id": ctx.Auth.UserID,
"total": total,
"status": "pending",
})
if err != nil {
return err
}
// 4: one OrderLine per book.
for _, p := range plan {
if _, err := ctx.GetModel("OrderLine").Create(map[string]any{
"order_id": order["id"],
"book_id": p.bookID,
"quantity": p.quantity,
"unit_price": p.unitPrice,
}); err != nil {
return err
}
}
// 5: outbox row — picked up by the worker in Part 8.
if _, err := ctx.GetModel("OutboxEvent").Create(map[string]any{
"kind": "order-placed",
"payload": map[string]any{
"order_id": order["id"],
"customer_id": ctx.Auth.UserID,
"total": total,
},
"status": "pending",
}); err != nil {
return err
}
if err := tx.Commit(); err != nil {
return err
}
ctx.Response = &maniflex.APIResponse{
StatusCode: http.StatusCreated,
Data: order,
}
return nil
}
Three things worth pointing out:
ctx.LockForUpdateacquires a row-level write lock that lasts until the transaction ends. A concurrent buyer hitting the same book waits at that line until we commit or roll back.- All five inserts share
ctx.Tx.ctx.GetModel(...).Createroutes through the transaction automatically — there is no separate “transactional client” to thread. defer tx.Rollback()is safe after a successfulCommit— rollback becomes a no-op once the transaction has been finalised.
Registering the action
In main.go:
server.Action(maniflex.ActionConfig{
Method: "POST",
Path: "/orders/place",
Handler: actions.PlaceOrder,
Middleware: []maniflex.MiddlewareFunc{
auth.JWTAuth("dev-secret"), // identity → ctx.Auth
},
})
auth.JWTAuth is the same middleware registered globally on Auth in Part 2,
but action middleware runs only for the action itself — handy when the action
needs different auth from the generated routes.
Trying it
curl -X POST localhost:8080/api/orders/place \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d "{\"lines\":[{\"book_id\":\"$BOOK\",\"quantity\":2}]}"
Response:
{
"data": {
"id": "abc123…",
"customer_id": "user-alice",
"total": 25.98,
"status": "pending",
...
}
}
Re-run it until the book runs out, and the next request gets a clean
409 OUT_OF_STOCK instead of a partial write.
Finishing the “must have bought” review check
Part 4 left a stub: only customers who have bought a book may review it. The
join query needs order_lines and orders — which we now have:
server.Pipeline.Validate.Register(func(ctx *maniflex.ServerContext, next func() error) error {
bookID, _ := ctx.Field("book_id")
rows, _ := ctx.RawQuery(
`SELECT 1
FROM order_lines ol
JOIN orders o ON o.id = ol.order_id
WHERE o.customer_id = ?
AND ol.book_id = ?
AND o.status IN ('paid','shipped')`,
ctx.Auth.UserID, bookID,
)
if len(rows) == 0 {
ctx.Abort(http.StatusForbidden, "PURCHASE_REQUIRED",
"you may only review books you have bought")
return nil
}
return next()
}, maniflex.ForModel("Review"), maniflex.ForOperation(maniflex.OpCreate))
Next
In Part 8 — Events & Background Jobs we build the background worker that consumes outbox rows and emails order receipts.
8. Events & Background Jobs
Part 7 added a custom action for placing orders. This part defers the post-order work — sending a receipt email — to a background job so that a slow or failing mailer never blocks the purchase response or rolls back the order transaction.
Why a background worker
Running side-effects inside the request transaction creates two problems:
- If the email fails, the whole order rolls back — a transient mail outage breaks the purchase flow entirely.
- If the order rolls back after a successful email, the email can’t be unsent.
Decoupling fixes both: the transaction writes a small job description; a worker outside the transaction carries it out. If the worker crashes mid-task the job is retried automatically.
Wiring up the job queue
The jobs/ package family provides a durable queue with retries and REST-based
status polling. For the bookstore we use jobs/sql, which enqueues inside the
same database transaction as the business write.
Install the queue alongside the server setup in main.go:
import (
"github.com/xaleel/maniflex"
jobsmaniflex "github.com/xaleel/maniflex/jobs/maniflex"
"github.com/xaleel/maniflex/jobs"
jobssql "github.com/xaleel/maniflex/jobs/sql"
)
server := maniflex.New(maniflex.Config{ /* ... */ })
server.MustRegister(Order{}, User{} /* ... */)
db, _ := sqlite.Open(":memory:", server.Registry())
server.SetDB(db)
// Create the SQL-backed queue (uses the same DB as the server).
queue := jobssql.New(db)
jobssql.Migrate(ctx, db)
// Mount registers the StatusModel and returns a wrapped queue + sink.
// After this, GET /api/job_statuses/:id is available automatically.
sink, queue, err := jobsmaniflex.Mount(server, queue)
if err != nil { log.Fatal(err) }
// Wire up the worker.
w, _ := jobs.NewWorker(jobs.WorkerConfig{
Source: queue.(jobs.Source),
Status: sink,
Handlers: map[string]jobs.Handler{
"send_receipt": sendReceiptHandler(mailer),
},
})
go w.Run(ctx)
log.Fatal(server.Start())
Enqueueing from the order action
Modify the order placement action from Part 7 to enqueue a job instead of calling the mailer directly:
server.Action(maniflex.ActionConfig{
Method: "POST",
Path: "/orders",
Handler: func(ctx *maniflex.ActionContext) error {
// ... validate, insert order, etc. ...
jobID, err := queue.Enqueue(ctx.Ctx, jobs.Job{
Type: "send_receipt",
ActorID: ctx.Auth.UserID,
Payload: mustJSON(map[string]any{"order_id": orderID}),
})
if err != nil {
return err
}
ctx.Response = &maniflex.APIResponse{
StatusCode: http.StatusAccepted,
Data: map[string]any{
"order_id": orderID,
"job_id": jobID, // clients can poll /api/job_statuses/:job_id
},
}
return nil
},
})
The wrapped queue creates an enqueued status row before returning, so the
client can poll immediately — no race between enqueue and the first GET.
The handler
func sendReceiptHandler(mailer Mailer) jobs.Handler {
return func(ctx context.Context, j jobs.Job) (jobs.Result, error) {
var p struct {
OrderID string `json:"order_id"`
}
if err := json.Unmarshal(j.Payload, &p); err != nil {
return jobs.Result{}, err
}
return jobs.Result{}, mailer.SendReceipt(ctx, p.OrderID)
}
}
Handlers return (jobs.Result, error). On error the worker retries with
exponential backoff (default up to 3 attempts). After all retries the job is
marked dead and the status row records the final error message.
Polling for completion
The client receives job_id in the response and polls until done:
POST /api/orders
← 202 {"data": {"order_id": "xyz", "job_id": "01JABC..."}}
GET /api/job_statuses/01JABC...
← 200 {"data": {"status": "enqueued", ...}}
GET /api/job_statuses/01JABC... (retry after a tick)
← 200 {"data": {"status": "succeeded", "completed_at": "2025-01-15T09:01:02Z"}}
No extra endpoint or custom table — the StatusModel is wired up automatically
by Mount.
Emitting events from the pipeline
For lighter-weight fan-out — “notify other services every time an Order is
created” — the service.Emit middleware is a simpler fit than the job queue:
import (
"github.com/xaleel/maniflex/events/redis"
"github.com/xaleel/maniflex/middleware/service"
)
bus := redis.New(redisClient)
server.Pipeline.DB.Register(
service.Emit(bus),
maniflex.ForModel("Order"),
maniflex.AtPosition(maniflex.After),
)
Emit publishes order.created (and order.updated, order.deleted) to the
bus on the DB-After step — only when the write succeeded. Subscribers in the
same or other processes consume events independently. For WebSocket fan-out to
connected clients, wire a realtime.Hub to the bus (see
Realtime / WebSockets).
Webhooks
service.Webhook delivers events to external URLs with an HMAC signature —
useful for one-off partner integrations:
server.Pipeline.DB.Register(
service.Webhook(service.WebhookConfig{
URL: "https://partner.example.com/orders",
Secret: os.Getenv("WEBHOOK_SECRET"),
}),
maniflex.ForModel("Order"),
maniflex.AtPosition(maniflex.After),
)
What we built
| Capability | How |
|---|---|
| Decoupled post-order email | jobs.Queue — enqueue in action, process in worker |
| Status polling | jobs/maniflex.Mount → GET /api/job_statuses/:id |
| Automatic retries | jobs.WorkerConfig.MaxRetry + exponential backoff |
| Transactional enqueue | jobs/sql inserts the job row in the same DB transaction |
| Domain event fan-out | service.Emit on DB-After → event bus subscribers |
| External webhook delivery | service.Webhook on DB-After |
Next
In Part 9 — Testing the API we test the whole app end to end, including the job worker and the polling flow.
9. Testing the API
A useful test suite for a maniflex app exercises the HTTP layer, not just the
database. The framework is built on net/http, so the standard
httptest.Server is enough — start an in-memory SQLite, register everything,
hit the routes, assert on responses.
A test harness
tests/setup.go:
package tests
import (
"context"
"log"
"net/http/httptest"
"github.com/xaleel/maniflex"
"github.com/xaleel/maniflex/db/sqlite"
"bookstore/middleware"
"bookstore/models"
)
// newTestServer returns a running httptest.Server backed by an in-memory
// SQLite. Each test gets a fresh database.
func newTestServer(t *testing.T) (*httptest.Server, *maniflex.Server) {
t.Helper()
server := maniflex.New(maniflex.Config{
Port: 0,
PathPrefix: "/api",
AutoMigrate: true,
})
server.MustRegister(
models.User{}, models.Author{}, models.Genre{},
models.Book{}, models.BookGenre{}, models.Review{},
models.Order{}, models.OrderLine{}, models.OutboxEvent{},
)
db, err := sqlite.Open(":memory:", server.Registry())
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { db.Close() })
server.SetDB(db)
// Handler() does NOT migrate — only Start() does, and tests mount Handler()
// directly. MigrateOnly runs the migration and honours AutoMigrate.
if err := server.MigrateOnly(context.Background()); err != nil {
t.Fatal(err)
}
middleware.Register(server)
ts := httptest.NewServer(server.Handler())
t.Cleanup(ts.Close)
return ts, server
}
Three notes:
:memory:opens an in-memory SQLite database. There is no file to clean up; closing the connection discards it.server.MigrateOnly(...)creates the tables.server.Handler()builds the router but does not migrate — onlyStart()does that. When you mountHandler()yourself (tests, embedding), migrate explicitly first or every request fails against missing tables.server.Handler()returns the chi router —httptest.NewServerwraps it and serves requests in-process.
A small JSON helper keeps tests readable:
func do(t *testing.T, ts *httptest.Server, method, path, token string, body any) (int, map[string]any) {
t.Helper()
var rdr io.Reader
if body != nil {
b, _ := json.Marshal(body)
rdr = bytes.NewReader(b)
}
req, _ := http.NewRequest(method, ts.URL+path, rdr)
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
req.Header.Set("Content-Type", "application/json")
resp, err := ts.Client().Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
var out map[string]any
json.NewDecoder(resp.Body).Decode(&out)
return resp.StatusCode, out
}
Happy-path sign-up + login
func TestSignupAndLogin(t *testing.T) {
ts, _ := newTestServer(t)
code, body := do(t, ts, "POST", "/api/users", "", map[string]any{
"email": "alice@example.com",
"password": "hunter22!",
"name": "Alice",
})
if code != 201 {
t.Fatalf("signup: %d %v", code, body)
}
code, body = do(t, ts, "POST", "/api/auth/login", "", map[string]any{
"email": "alice@example.com",
"password": "hunter22!",
})
if code != 200 || body["data"].(map[string]any)["token"] == "" {
t.Fatalf("login: %d %v", code, body)
}
}
A new in-memory database for each t.Run keeps the tests isolated; nothing
to truncate, nothing to seed beyond the test’s own writes.
Validation failures
The exact response shape matters because clients depend on it. Pin it down:
func TestInvalidEmail(t *testing.T) {
ts, _ := newTestServer(t)
code, body := do(t, ts, "POST", "/api/users", "", map[string]any{
"password": "hunter22!",
"name": "Alice",
// email missing
})
if code != 422 {
t.Fatalf("got %d, want 422", code)
}
if body["error"].(map[string]any)["code"] != "VALIDATION_FAILED" {
t.Fatalf("code = %v", body["error"])
}
}
Stock contention
The order-placement transaction is the most interesting code path. The test starts two goroutines that race for the last unit:
func TestStockContention(t *testing.T) {
ts, _ := newTestServer(t)
tok, bookID := seedOneBookOneCustomer(t, ts, 1) // stock = 1
var wg sync.WaitGroup
results := make([]int, 2)
for i := range results {
wg.Add(1)
go func(i int) {
defer wg.Done()
results[i], _ = do(t, ts, "POST", "/api/orders/place", tok, map[string]any{
"lines": []map[string]any{{"book_id": bookID, "quantity": 1}},
})
}(i)
}
wg.Wait()
// Exactly one 201 Created, exactly one 409 Conflict.
sort.Ints(results)
if results[0] != 201 || results[1] != 409 {
t.Fatalf("expected one 201 and one 409, got %v", results)
}
}
LockForUpdate ensures only one of the two transactions wins; the other
sees the decremented stock and aborts with OUT_OF_STOCK.
Worker tests
The background worker is plain Go. Inject a stub mailer and assert on it:
func TestOutboxWorker(t *testing.T) {
ts, server := newTestServer(t)
tok, bookID := seedOneBookOneCustomer(t, ts, 5)
// Place an order — should write an outbox row.
do(t, ts, "POST", "/api/orders/place", tok, map[string]any{
"lines": []map[string]any{{"book_id": bookID, "quantity": 1}},
})
stub := &captureMailer{}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go jobs.RunOutboxOnce(ctx, server, stub) // single iteration
// Give the worker a moment.
if !waitFor(time.Second, func() bool { return len(stub.Sent) == 1 }) {
t.Fatalf("expected 1 email, got %d", len(stub.Sent))
}
if stub.Sent[0].Subject != "Thank you for your order" {
t.Fatalf("wrong subject: %q", stub.Sent[0].Subject)
}
}
Run worker logic in a one-shot variant (RunOutboxOnce) for testability —
or accept a context.Context and cancel it after the assertions pass. Both
patterns avoid time.Sleep in tests.
The framework’s own test suite
The framework ships an end-to-end suite under tests/e2e/. It is the
canonical reference for what a thorough maniflex test looks like — every step
of the pipeline, every adapter, every middleware option. Run it with:
go test ./tests/e2e/...
…and look at the test files for patterns you can lift into your own suite.
Coverage strategy
For a typical bookstore-shaped app, a useful test split:
- Per model: happy-path create + read + update + delete.
- Per
mfx:tag rule: at least one negative test (required,enum,min/max,unique). - Per custom middleware: at least one happy-path and one rejection.
- Per action: happy path, a representative failure, and a contention test.
- The outbox worker: receives an event, processes it, marks it done.
That covers the surface area without exploding into combinatorial tests of every filter operator and every relation include — those are exercised by the framework’s own e2e suite, which you depend on transitively.
Next
In Part 10 — Deploying to Production we swap SQLite for PostgreSQL, drive configuration from environment variables, enable the health probe, and produce a single binary suitable for a container image.
10. Deploying to Production
The bookstore runs cleanly on SQLite + go run . for development. Production
needs three additional things: a real database, configuration from the
environment, and a sensible operational contract — health probes,
structured logs, graceful shutdown. Code changes are minimal; the framework
was already built for this.
Swapping SQLite for PostgreSQL
Add the satellite:
go get github.com/xaleel/maniflex/db/postgres
Change one import in main.go:
- import "github.com/xaleel/maniflex/db/sqlite"
+ import "github.com/xaleel/maniflex/db/postgres"
…and the adapter open call:
db, err := postgres.Open(postgres.Options{
WriteURL: os.Getenv("DB_WRITE_URL"),
ReadURL: os.Getenv("DB_READ_URL"), // optional
MaxOpenConns: 25,
MaxIdleConns: 5,
ConnMaxLifetime: 30 * time.Minute,
}, server.Registry())
Models, middleware, actions, and tests all carry over unchanged. The shared
db/sqlcore adapter means SQL emitted by AutoMigrate is portable between
the two backends. See PostgreSQL in Production for
pool tuning, read replicas, and migration choices.
Configuration from environment
A single Config populated from os.Getenv:
// config.go
func loadConfig() maniflex.Config {
return maniflex.Config{
Port: envInt("PORT", 8080),
PathPrefix: envStr("PATH_PREFIX", "/api"),
ServiceName: envStr("SERVICE_NAME", "bookstore"),
AutoMigrate: envStr("AUTO_MIGRATE", "true") == "true",
QueryTimeout: envDuration("QUERY_TIMEOUT", 30*time.Second),
ShutdownTimeout: envDuration("SHUTDOWN_TIMEOUT", 30*time.Second),
HealthCheckDB: true,
HealthTimeout: 3 * time.Second,
Logger: slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
Level: slog.LevelInfo,
})),
}
}
maniflex also ships a helper, maniflex.ConfigFromEnv(), that reads a conventional
set of environment variables (PORT, PATH_PREFIX, DB_WRITE_URL, …).
Pick whichever style fits your team — both produce the same maniflex.Config.
Production-safe migrations
AutoMigrate: true is convenient in development. In production, the
prevailing pattern is:
- Disable
AutoMigrateon every instance. - Run schema changes through a dedicated migration tool
(
golang-migrate, Atlas, sqlc-migrate) executed as a separate one-shot step in your deploy pipeline. - Roll out the new application code afterwards.
AUTO_MIGRATE=false
The framework’s auto-migrator never drops columns, but it isn’t aware of your release strategy — splitting “deploy” and “migrate” into two steps lets you stage them deliberately.
Health probes
HealthCheckDB: true enables a real probe — GET /health calls
db.Ping() with a HealthTimeout budget. Kubernetes:
livenessProbe:
httpGet:
path: /health
port: 8080
periodSeconds: 10
timeoutSeconds: 5
readinessProbe:
httpGet:
path: /health
port: 8080
periodSeconds: 5
timeoutSeconds: 5
Set HealthTimeout (default 3s) shorter than the probe’s timeoutSeconds
so the handler can return a clean 503 before the probe gives up.
terminationGracePeriodSeconds on the pod should be longer than
Config.ShutdownTimeout, otherwise Kubernetes will SIGKILL the process
before in-flight requests have finished. With the defaults (30s shutdown),
60s grace is a comfortable buffer.
Logging and tracing
The JSON handler above turns every line into a structured record that an
aggregator can index. ctx.Logger() automatically adds request_id and
trace_id per request, so a single trace can be reconstructed end-to-end.
Set Config.ServiceName so every log line and every audit record carries
the service identifier — invaluable when a single aggregator collects logs
from several services.
For a debugging spike, enable pipeline tracing:
TRACE=1
if envStr("TRACE", "") != "" {
cfg.Trace = maniflex.PipelineTrace{Enabled: true, Skips: true}
}
Steps, Timings, and Aborts produce DEBUG-level records that show
every middleware enter/exit and the file:line of every Abort call.
Disable in normal operation — they are high-volume.
The Dockerfile
A typical Dockerfile for the binary:
FROM golang:1.25-alpine AS build
WORKDIR /src
COPY . .
RUN CGO_ENABLED=0 go build -trimpath -o /out/bookstore ./
FROM gcr.io/distroless/static-debian12
COPY --from=build /out/bookstore /bookstore
COPY static /static
EXPOSE 8080
USER 65532:65532
ENTRYPOINT ["/bookstore"]
CGO_ENABLED=0 works because maniflex/db/postgres uses lib/pq (pure Go) and
nothing else in the framework requires a C toolchain.
static/ is copied so the Scalar OpenAPI viewer is reachable at
/static/openapi.html.
Production checklist
| Setting | |
|---|---|
| Database | maniflex/db/postgres with WriteURL and optional ReadURL |
| Migrations | AutoMigrate: false + external migration tool |
| Logger | JSON handler |
Config.ServiceName | the service name |
Config.QueryTimeout | bounded (e.g. 30s) |
Config.ShutdownTimeout | matches the slowest legitimate request |
Config.HealthCheckDB | true |
K8s terminationGracePeriodSeconds | larger than ShutdownTimeout |
| TLS | terminated at the load balancer |
| Auth | auth.JWTAuth with an asymmetric key from your IdP |
| Rate limits | db.RateLimit on password-reset / sign-up / login |
| Audit log | db.AuditLog on OpCreate / OpUpdate / OpDelete |
| File storage | swap LocalStorage for S3 / R2 / GCS |
| Outbox worker | run alongside the API, or as a separate deployment |
Where to go from here
The tutorial finishes here. From this point, the reference pages cover everything in more depth, and the code base is small enough to grow in any direction:
- More middleware from the Middleware Catalogue.
- Customisation via Writing Middleware.
- Advanced workflows in Custom Endpoints, Raw Queries & Query Models, and Batch Operations & Sagas.
- Hardening with Auth & Security Hardening.
The shape of main.go has not changed in ten parts. Add models, add
middleware, add actions — the wiring is the same.
Glossary
Every framework term used in these docs, in one place. Links point to the page where each term is defined in depth.
A
Action. A custom endpoint registered with server.Action(...). Runs a
trimmed pipeline (Auth → action middleware → handler → Response); the
Deserialize, Validate, Service, and DB steps are skipped. See
Custom Endpoints.
Adapter. An implementation of the maniflex.DBAdapter interface. Two ship:
db/sqlite and db/postgres. Custom backends implement the same
interface. See Database Backends.
After (position). A middleware position that places the function after
the step’s default handler. The middleware sees the result of the default
(ctx.DBResult, ctx.Response). See Writing Middleware.
AuditRecord. The structured record produced by db.AuditLog — fields
include Timestamp, Model, Operation, ResourceID, Actor,
TenantID, RequestID, TraceID, ServiceName, and optionally a
per-field Changes diff. See Audit Logging.
AuthInfo. The struct populated by Auth middleware and stored on
ctx.Auth. Carries UserID, Roles, Claims, TenantID,
IdentityType, Scopes, SessionID, AuthMethod. See
ServerContext.
AutoMigrate. The startup phase that creates and alters tables to match
registered models. Enabled by default via Config.AutoMigrate. See
Database Backends.
B
BaseModel. The struct every registered model must embed. Contributes
id (UUID, framework-assigned), created_at, and updated_at. See
Models & BaseModel.
Batch. Multiple inserts or updates inside a single request, usually through a custom action that opens one transaction. See Batch Operations & Sagas.
Before (position). The default middleware position; the function runs before the step’s default handler. See Writing Middleware.
BelongsTo. A relation where this model carries the foreign key. Declared
either by convention (UserID field → User) or explicitly with
mfx:"relation:Name" plus a companion field of the target type. See
Relations.
C
Cache (CacheStore). The generic key/value cache interface used by
several middlewares (idempotency, rate limit, response cache). maniflex
ships MemoryCache; satellite modules provide Redis-backed
implementations. See cache.go.
Catalogue. The set of ready-made middleware shipping under
maniflex/middleware/. See Middleware Catalogue.
ServerContext. The single per-request struct threaded through every
pipeline step. Fields include Request, Writer, Ctx, Model,
Operation, ResourceID, RawBody, ParsedBody, Query, DBResult,
Response, Auth, Tx. See ServerContext.
Companion field. On an explicit BelongsTo relation, the struct field
of the target type that accompanies the FK column. Required by
mfx:"relation:Name"; the field is named Name and typed as the target
model. See Relations.
Config (maniflex.Config). The single struct passed to maniflex.New. Every
field has a sensible default. See Configuration.
D
DBAdapter. The interface implemented by every database backend.
Methods: FindByID, FindMany, Create, Update, Delete, BeginTx,
Raw, Ping. See Database Backends.
Diff (versioning). The per-field {old, new} map written into the
diff column of a history row. Hidden, writeonly, and encrypted fields
are excluded. See Versioning & History.
E
Embed. A BaseModel, WithDeletedAt, or WithIsDeleted value
included in a model struct as an anonymous field. Embeds contribute
columns and turn on framework behaviour. See Models & BaseModel
and Soft Delete.
Envelope (storage). The binary blob produced by KeyProvider.Encrypt,
stored in the database as enc:<base64(envelope)>. The envelope embeds
its keyID so Decrypt can route to the right key. See
Encryption at Rest.
Envelope (response). The JSON shape {"data": ...} (success) or
{"error": {...}} (failure). Customisable via response.Envelope. See
Response Envelope.
F
FieldMeta. The framework’s per-field metadata, derived from a struct
field’s json / db / mfx tags by parseFieldTags. Stored on
ModelMeta.Fields. See Models & BaseModel.
FileStorage. The interface for file backends. maniflex/storage ships
LocalStorage; custom implementations cover S3, R2, GCS. See
File Fields & Uploads.
Filter / FilterExpr. A parsed ?filter= expression. A slice of
these on ctx.Query.Filters becomes the SQL WHERE clause. See
Querying.
G
Generate (OpenAPI step). The middle step of the OpenAPI pipeline that
builds the *OpenAPISpec from the registry. After-position middleware
customises the spec. See OpenAPI Spec.
H
Handler (action). The func(ctx *maniflex.ServerContext) error registered
with server.Action(...) as an action’s body. See
Custom Endpoints.
HasMany. A relation declared as a slice field of the related struct. No column on this table; the related table carries the FK. See Relations.
HMAC column. A {field}_hmac companion column auto-created for
mfx:"encrypted,unique" fields, storing a keyed digest so uniqueness can
be enforced without comparing ciphertexts. See
Encryption at Rest.
History table. The {model}_history sibling table created for a
maniflex.ModelConfig{Versioned: true} model. Receives one row per write to
the source model. See Versioning & History.
I
Idempotency-Key. The HTTP header consumed by
middleware/idempotency to deduplicate retries. The first request runs;
subsequent requests with the same key replay the cached response. See
Idempotency.
IdentityType. The AuthInfo field classifying the principal — Human,
ServiceAccount, or Anonymous. See ServerContext.
Immutable (tag). A mfx: directive that accepts a value on create but
strips it on update. See Field Tags Reference.
Include. The ?include=relationKey,... query parameter that populates
related rows inline in the response. See Relations and
Querying.
Index (IndexSpec). A declared database index, created during
AutoMigrate. Declared in ModelConfig.Indices or auto-generated for
mfx:"scheduled" columns. See model.go.
J
Junction (model). The third model in a many-to-many relation, carrying
the two FKs. Named via mfx:"through:JunctionModel" on both sides. See
Relations.
K
KeyProvider. The interface that the encryption subsystem uses to
encrypt, decrypt, and HMAC field values. Implementations: EnvKeyProvider,
VaultKeyProvider. See Encryption at Rest.
L
LockForUpdate. A *ServerContext method that acquires a row-level
write lock inside an active transaction. SELECT ... FOR UPDATE on
Postgres; transaction-level lock on SQLite. See Transactions.
M
MiddlewareFunc. The signature every pipeline middleware must
satisfy: func(ctx *maniflex.ServerContext, next func() error) error. See
Writing Middleware.
ModelAccessor. The CRUD helper returned by ctx.GetModel(name).
Exposes List, Read, Create, Update, Delete for any registered
model, routed through ctx.Tx when set. See ServerContext.
ModelConfig. The per-model options passed alongside a struct in
MustRegister. Fields: TableName, SoftDelete, Middleware,
Versioned, VersionedDiffOnly, Indices. See Models & BaseModel.
ModelMeta. The framework’s runtime description of a registered
model. Carries Name, TableName, Fields, Relations, SoftDelete,
Config, Indices, and resolved scheduled specs.
O
OnDelete. The referential action attached to a foreign key —
cascade, setNull, restrict, or unset. See Relations.
Operation (maniflex.Operation). The CRUD verb identifying a request:
OpList, OpRead, OpCreate, OpUpdate, OpDelete, OpHead,
OpOptions, OpAction. See Pipeline Overview.
Outbox. The transactional outbox pattern: a row written in the same transaction as the primary write, consumed by a background worker for external side effects (emails, webhooks, events). See Batch Operations & Sagas.
P
Pipeline. The six-step request pipeline (Auth → Deserialize → Validate → Service → DB → Response) and its sibling OpenAPI pipeline.
See Pipeline Overview.
Position. Where in a step’s chain a middleware sits — Before
(default), After, or Replace. See Writing Middleware.
Q
Query model. A read-only model registered with a SQL body
(ModelConfig.QueryModel) instead of a table. Generates filterable,
sortable list endpoints from a custom SELECT. See
Raw Queries & Query Models.
QueryParams. The parsed ?page=&limit=&filter=&sort=&include= of
a request, stored on ctx.Query. See Querying.
R
Registry. The in-memory map of every registered *ModelMeta. Built
by MustRegister, consumed by the adapter and the router. See
Architecture.
Relation. A connection between two models — BelongsTo, HasMany,
or ManyToMany. See Relations.
Replace (position). A middleware position that substitutes the step’s
default handler entirely. The last matching Replace middleware wins.
See Writing Middleware.
S
Saga. A multi-step workflow composed of forward steps and compensating undos, usually implemented via a transactional outbox and a worker. See Batch Operations & Sagas.
Scheduled (tag / runner). mfx:"scheduled;..." declares a
time-driven transition on a *time.Time column. The
scheduled.Runner sweeps the rows and applies the transition. See
Scheduled Fields.
Service name. Config.ServiceName. Identifies the service in logs,
audit records, and the X-Service-Name response header. See
Configuration.
Soft delete. Marking a row as deleted instead of removing it. Opt in
via maniflex.WithDeletedAt (timestamp) or maniflex.WithIsDeleted (boolean). See
Soft Delete.
Step (StepRegistry). One of the six pipeline steps. Exposes
Register(fn, opts...) to attach middleware. See
Pipeline Overview.
T
Tag (mfx: directive). A comma-separated list in a struct field’s
mfx tag declaring per-field behaviour. See
Field Tags Reference.
TenantID. The AuthInfo field that scopes a principal to one tenant.
Populated by JWT auth (TenantClaim) or by custom Auth middleware. See
Auth Middleware.
Through (tag). mfx:"through:JunctionModel" declares a many-to-many
relation via a named junction model. See Relations.
Trace (PipelineTrace). Debug-level pipeline tracing controlled by
Config.Trace. Sub-flags: Steps, Timings, Aborts, Bodies,
Skips. See Configuration.
Tx. A transaction handle returned by ctx.BeginTx (or the underlying
adapter). Stored on ctx.Tx; the default DB step routes through it
automatically. See Transactions.
V
Versioned (config). ModelConfig.Versioned = true writes a row to
the sibling {model}_history table on every change to the source model.
See Versioning & History.
W
WithTransaction. The catalogue middleware that wraps the DB step in
a transaction, committing on success and rolling back on error. See
Transactions.
writeonly. A tag directive that accepts the field on input but
strips it from responses. Standard choice for passwords. See
Field Tags Reference.
FAQ & Troubleshooting
Common questions and the pitfalls that catch new users. Each entry links to the page that covers the underlying concept in depth.
Registration & startup
“I registered a model after SetDB and nothing happens.”
The adapter is built from the registry; once it’s open, later registrations don’t reach it. Register every model before opening the database:
server.MustRegister(User{}, Order{}, Invoice{}) // 1. registry populated
db, _ := sqlite.Open("./app.db", server.Registry()) // 2. adapter built
server.SetDB(db) // 3. adapter wired in
See Architecture and Models & BaseModel.
“AutoMigrate didn’t add my new column.”
AutoMigrate adds missing columns but never drops or alters existing ones.
If your struct says string and the table column is INTEGER, the
migrator leaves it alone and logs a drift warning. Inspect the warning
log, then either fix the struct or run a manual ALTER TABLE.
“Why does the framework panic when a struct doesn’t embed BaseModel?”
Because every model needs an id and timestamp columns, and BaseModel
provides them. The check is deliberate; embedding BaseModel is one
line. See Models & BaseModel.
“Can I rename the id column?”
No. The framework hard-codes id as the primary-key column name across
the adapter, the relation resolver, and the OpenAPI generator. Pick a
table prefix or rename the table instead.
Pipeline & middleware
“My middleware doesn’t fire — I scoped it to OpAction.”
OpAction requests run a trimmed pipeline: Auth → action middleware → handler → Response. Middleware registered on Validate, Service, or DB
with ForOperation(OpAction) is never reached. Move per-action logic
into the action’s own middleware list:
server.Action(maniflex.ActionConfig{
Method: "POST",
Path: "/orders/place",
Handler: placeOrder,
Middleware: []maniflex.MiddlewareFunc{auth.JWTAuth(secret), checkStock},
})
See Custom Endpoints.
“ctx.Abort was called but the database still got written to.”
Probably called next() after Abort. Abort only populates
ctx.Response; it does not stop the chain. Return without next() and
the chain unwinds. See the “Calling next() after Abort” section of
ServerContext.
“Why doesn’t After-position middleware see my error?”
It does — through ctx.Response:
func afterDB(ctx *maniflex.ServerContext, next func() error) error {
if err := next(); err != nil { return err }
if ctx.Response != nil && ctx.Response.StatusCode >= 400 {
return nil // skip the side effect
}
record(ctx)
return nil
}
A non-nil error from next() and a 4xx/5xx ctx.Response are distinct
signals — both mean “the default step refused to succeed.”
“I want the same middleware on every model except one.”
Register it without ForModel, then register a passthrough that
short-circuits for the excluded model — or guard inside the middleware:
server.Pipeline.Service.Register(func(ctx *maniflex.ServerContext, next func() error) error {
if ctx.Model.Name == "PublicResource" {
return next()
}
// ... usual work ...
return next()
})
Transactions
“LockForUpdate returned an error: ‘requires an active transaction’.”
Pessimistic locks only make sense inside a transaction. Wrap the call:
tx, err := ctx.BeginTx(ctx.Ctx, nil)
if err != nil { return err }
ctx.Tx = tx
defer tx.Rollback()
row, err := ctx.LockForUpdate("StockBalance", id)
// ...
return tx.Commit()
…or register maniflex.WithTransaction(nil) on the Service step so the
transaction is already open by the time the lock fires.
“WithTransaction registered twice on SQLite — got a ‘tx already active’ error.”
SQLite does not support nested transactions. WithTransaction is
idempotent — registering it twice is fine — but calling ctx.BeginTx
manually inside an already-open SQLite transaction will fail. Reuse
ctx.Tx instead of starting a new one.
“My transaction committed even though I returned an error.”
Three things to check:
- The error was returned from
next(), not swallowed inside the middleware. - The middleware did not call
next()and return its own error — the framework treats those independently. ctx.Response.StatusCodeis>= 400ornext()returned non-nil. Both abort the commit.
Querying
“?filter=foo:eq:bar returns 400 INVALID_QUERY.”
The field isn’t filterable. Add the tag:
Foo string `json:"foo" mfx:"filterable"`
The check is intentional — exposing every column to client filters lets clients build arbitrary indexes against you. Opt in per field.
“Nested filter ?filter=author.name:eq:X doesn’t work.”
Two conditions:
- The relation must be declared on the current model (
AuthorIDfor convention,relation:Authorfor explicit). - The target field must itself be
filterableon the related model.
“I want a default sort order.”
There isn’t a built-in “default sort” tag. Register a Deserialize
middleware that appends to ctx.Query.Sorts when the client doesn’t
supply one:
server.Pipeline.Deserialize.Register(func(ctx *maniflex.ServerContext, next func() error) error {
if err := next(); err != nil { return err }
if len(ctx.Query.Sorts) == 0 {
ctx.Query.Sorts = append(ctx.Query.Sorts, maniflex.SortExpr{
Field: "created_at", Direction: maniflex.SortDesc,
})
}
return nil
}, maniflex.ForModel("Article"), maniflex.ForOperation(maniflex.OpList), maniflex.AtPosition(maniflex.After))
“Soft-deleted rows show up in my admin tool list.”
They don’t — the framework filters them out everywhere. To see them, filter on the marker explicitly:
curl '...?filter=deleted_at:not_null'
See Soft Delete.
Files & encryption
“My multipart upload returns 501 NO_STORAGE.”
Config.FileStorage is nil. Configure a backend:
fs, _ := storage.NewLocalStorage("./uploads")
server := maniflex.New(maniflex.Config{FileStorage: fs, ...})
“Encrypted field rejected my write: ENCRYPTION_NOT_CONFIGURED.”
Same shape — Config.KeyProvider is nil. Configure one (e.g.
encryption.EnvKeyProvider) before any model with mfx:"encrypted" is
exercised. See Encryption at Rest.
“I added mfx:"unique" to an encrypted field and got a duplicate-key error on legit data.”
The framework stores a keyed HMAC of the plaintext in a {field}_hmac
companion column. Two plaintexts that hash to the same digest is a
collision — astronomically unlikely with SHA-based HMAC. More likely you
re-encrypted under a new key and the old HMACs are still in the table;
re-run maniflex.RotateEncryptionKey and the digests are refreshed.
Auth
“I added auth.JWTAuth and sign-up stopped working.”
Sign-up is a write — auth.JWTAuth rejects it because no token exists
yet. Add an exception:
server.Pipeline.Auth.Register(
auth.AllowPublicWrite(),
maniflex.ForModel("User"), maniflex.ForOperation(maniflex.OpCreate),
)
server.Pipeline.Auth.Register(
auth.JWTAuth(secret),
maniflex.ForOperation(maniflex.OpCreate, maniflex.OpUpdate, maniflex.OpDelete),
)
The order matters: AllowPublicWrite registers first, so it runs first
and short-circuits the JWT check for matching requests.
“JWT keeps returning 401 — the token validates manually.”
Three usual suspects:
- Algorithm mismatch —
HS256token verified against anRS256public key (or vice versa). SetJWTOptions.PublicKeyfor asymmetric keys. Issuer/Audiencemismatch — the framework rejects tokens whoseiss/auddoesn’t match the configured value.- Clock skew — the token’s
nbfis in the future orexpis in the past on the server clock. Sync NTP.
Database
“Why does Tenancy middleware not apply to my reads?”
It does, including reads — Tenancy filters every operation, including
list and read. If you’re seeing rows from another tenant, check that the
middleware is registered without a ForOperation filter and that
ctx.Auth.TenantID is being populated by the upstream Auth middleware.
“My Postgres reads have stale data after a write.”
Read replicas have replication lag. The framework routes reads to the
replica only outside an active transaction; reads inside a
WithTransaction-managed request go to the primary. For
read-your-writes outside a transaction, run the query through
ctx.RawQuery against an explicit primary connection, or shorten the
client’s expected window.
“I dropped a column from my struct — AutoMigrate didn’t remove it.”
By design. The migrator never drops; it logs a drift warning so you see
the column still exists. Remove it with an explicit ALTER TABLE … DROP COLUMN
during a maintenance window.
“504 TIMEOUT on a query that used to work.”
Config.QueryTimeout fired. The deadline is per request, applied to
ctx.Ctx. Either:
- Raise the timeout (
30sis a common ceiling). - Speed up the query (missing index, expensive include, large LIMIT).
- Add a
db.Paginatecap on the offending list endpoint.
OpenAPI
“/openapi.json is empty.”
You didn’t register any models, or you call server.Pipeline.OpenAPI.*
before MustRegister. The generator reads the registry at request time,
not registration time — but if the registry is empty when a client hits
it, the spec is empty too.
“I customised the spec but my changes don’t appear.”
Register the customisation at maniflex.After position on the Generate step,
not Before. Before runs against an empty spec; After mutates the
just-generated document.
server.Pipeline.OpenAPI.Generate.Register(
openapi.SetTitle("My API"),
maniflex.After, // <-- not the default Before
)
Production
“My pod terminated mid-request on deploy.”
Kubernetes sent SIGTERM, gave you terminationGracePeriodSeconds,
then SIGKILL’d the process. Config.ShutdownTimeout defaults to 30s;
set the pod’s grace period larger (60s is comfortable) so the graceful
path has time to complete. See Graceful Shutdown.
“Health probe returns 503 intermittently.”
If Config.HealthCheckDB is true, the probe pings the database. Tune:
Config.HealthTimeout— should be shorter than the probe’stimeoutSeconds.- The database — if the pool is exhausted,
db.Ping()waits for a connection.
“Logs are noisy with debug records.”
Config.Trace.Enabled = true was left on, or the Logger accepts DEBUG.
Set the handler’s level to INFO in production. Trace flags are opt-in
specifically because they’re high-volume.
Library design questions
“Why reflection instead of codegen?”
To avoid the regeneration step. A mfx: tag change is in effect on the
next process start; nothing to rebuild. The cost is one reflection pass
per model at boot — usually under a millisecond per model. See
Architecture.
“Can I use maniflex with GraphQL?”
The generated routes are REST. You can put GraphQL in front (gqlgen,
graphql-go) and have resolvers call ctx.GetModel(...).List / .Read
/ etc. — the model accessor doesn’t care which HTTP layer is on top.
“Can I use multiple databases?”
One adapter per *maniflex.Server. For multi-database setups, run two servers
(possibly in the same process) and route between them at the application
layer, or use raw queries from custom actions that target the secondary
database directly.
“Is there a CLI?”
Not yet. The framework is intentionally library-only — no project-scaffolding command, no migration runner. Use the standard Go toolchain and an external migration tool. The App Anatomy page describes the recommended project layout.
Where to find more
If your question isn’t here, three places to look next:
- The page for the concept involved — every link in this FAQ points to it.
- The framework’s own e2e tests under
tests/e2e/— they exercise every edge case the docs describe. - The source. The
maniflexpackage is small; reading the implementation of a step or a middleware is often faster than guessing.
AI Agents
A condensed, self-contained reference for AI coding agents working on maniflex
projects. Copy the block below into CLAUDE.md, AGENTS.md, or an equivalent
context file. Everything an agent needs to write correct maniflex code is in it;
no prose links to chase.
# maniflex — Reference for AI coding agents
Reflection-driven Go REST framework. Annotated structs in → full REST API out:
filtering, pagination, relations, soft-delete, file uploads, OpenAPI 3.1 spec.
No codegen.
## Modules
- `maniflex` — core (chi + uuid only).
- `maniflex/db/sqlite` — pure-Go SQLite (modernc.org/sqlite). No CGo.
- `maniflex/db/postgres` — lib/pq.
- `maniflex/middleware/{auth,body,validate,service,db,response,openapi,idempotency}` — catalogue.
- `maniflex/middleware/service/bcrypt` — password hashing.
- `maniflex/middleware/db/redis` — Redis cache backend.
- `maniflex/events/{kafka,nats,rabbitmq,redis}` — event publishers.
- `maniflex/jobs/redis` — background job queue.
- `maniflex/scheduled` — runner for `mfx:"scheduled"` fields.
- `maniflex/storage` — local file storage; ships `LocalStorage`.
- `maniflex/pkg/encryption` — `EnvKeyProvider`, `VaultKeyProvider`.
Each is its own module. Import only what you use.
## The four-step lifecycle (fixed order)
```go
server := maniflex.New(maniflex.Config{Port: 8080, PathPrefix: "/api", AutoMigrate: true})
server.MustRegister(User{}, Post{}) // 1. populate registry
db, _ := sqlite.Open("./app.db", server.Registry()) // 2. adapter reads registry
server.SetDB(db) // 3. inject adapter
log.Fatal(server.Start()) // 4. serve
```
Models registered after `SetDB` do not reach the adapter. The adapter is built
from the registry at `Open` time.
## Models
Every model embeds `maniflex.BaseModel`:
```go
type Post struct {
maniflex.BaseModel // adds id (UUID), created_at, updated_at
maniflex.WithDeletedAt // optional — adds deleted_at (timestamp soft delete)
// maniflex.WithIsDeleted // alternative — adds is_deleted (bool)
Title string `json:"title" mfx:"required,filterable,sortable"`
Body string `json:"body" mfx:"required"`
Status string `json:"status" mfx:"required,enum:draft|published"`
UserID string `json:"user_id" mfx:"required,filterable"` // BelongsTo User
}
```
Table name = pluralised snake-case of struct name (`BlogPost` → `blog_posts`).
Override with `ModelConfig.TableName`.
Validation:
- Struct must be a struct type and embed `BaseModel`, else `MustRegister` panics.
- Field types: scalars, `*time.Time`, slices for relations, `map[string]any`,
structs for companions.
## The `mfx:` tag — complete list
Comma-separated. Whitespace trimmed. Unknown directives ignored.
**Validation:**
- `required` — must be present on create
- `enum:a|b|c` — pipe-separated allowed values
- `min:N`, `max:N` — numeric bounds
- `default:V` — used when field absent on create (cast to type)
**Write access:**
- `readonly` — stripped from all writes
- `immutable` — accepted on create, rejected on update
**Response visibility:**
- `writeonly` — accepted on write, hidden in responses (e.g. password)
- `hidden` — hidden in responses **and** stripped from create/update schemas
**Query:**
- `filterable` — usable in `?filter=`
- `sortable` — usable in `?sort=`
- `searchable` — full-text search hint
**Schema:**
- `unique` — `UNIQUE` constraint at the column
**Relations** (semicolon-separated sub-options):
- `relation:Name` — explicit FK; requires companion field `Name` of target type
- `relation:Name;onDelete:cascade|setNull|restrict` — referential action
- `through:JunctionModel` — declares M2M via junction (on slice fields)
**Files:**
- `file` — multipart file field; column stores storage key
- `max_size:N` — `KB`/`MB`/`GB` suffix or bytes
- `accept:pattern1|pattern2` — MIME-type patterns
- `auto_delete:false` — keep stored file when row deleted or field replaced
**Encryption:**
- `encrypted` — AES-256-GCM at rest; not `filterable`/`sortable`
- `key:NAME` — keyID for KeyProvider (default `"default"`)
- `encrypted,unique` — adds `{field}_hmac` companion column for uniqueness
**Versioning** (on embedded `BaseModel` field):
- `mfx:"versioned"` — adds `{model}_history` sibling table
- `mfx:"versioned:diff_only"` — store diffs only, skip snapshots
**Scheduled** (on `*time.Time` fields):
- `scheduled;soft-delete` — needs `WithDeletedAt`/`WithIsDeleted`
- `scheduled;hard-delete`
- `scheduled;field=NAME;to=VALUE` — required pair
- `scheduled;field=NAME;from=OLD;to=NEW` — guarded transition
**Exclusion:** `json:"-"`, `db:"-"`, or `mfx:"-"` removes the field entirely.
## Relations
| Kind | Declared by | Relation key |
|---|---|---|
| BelongsTo (convention) | `UserID string` field → `User` | `user` |
| BelongsTo (explicit) | `ManagerID string \`mfx:"relation:Manager"\`` + `Manager User` companion | `manager` |
| HasMany | `Posts []Post` slice field | `posts` |
| ManyToMany | `Tags []Tag \`mfx:"through:ProductTag"\`` on both sides + junction model registered | `tags` |
Include in queries: `?include=user,posts,tags`. Nested filter: `?filter=user.role:eq:admin`.
Junction models for M2M are registered like any model.
## The 6-step pipeline
`Auth → Deserialize → Validate → Service → DB → Response`. Each step has a
default + `*StepRegistry` on `server.Pipeline`.
Middleware signature:
```go
type MiddlewareFunc func(ctx *maniflex.ServerContext, next func() error) error
```
Register:
```go
server.Pipeline.Service.Register(myFn,
maniflex.ForModel("User", "Order"), // optional, by struct name
maniflex.ForOperation(maniflex.OpCreate, maniflex.OpUpdate), // optional
maniflex.AtPosition(maniflex.Before), // Before (default) | After | Replace
maniflex.WithName("my-fn"), // optional, for traces
)
```
Operations: `OpList`, `OpRead`, `OpCreate`, `OpUpdate`, `OpDelete`, `OpHead`, `OpOptions`, `OpAction`.
`OpAction` uses a trimmed pipeline: `Auth → action middleware → handler → Response`.
Validate/Service/DB middleware never fires for actions.
**Short-circuit pattern (always pair these):**
```go
ctx.Abort(http.StatusUnauthorized, "UNAUTHORIZED", "missing token")
return nil // do NOT call next()
```
Calling `next()` after `Abort` lets downstream steps run and possibly overwrite `ctx.Response`. Always return without `next()`.
**Default step behaviours:**
- Auth: passthrough. Populate `ctx.Auth` here.
- Deserialize: parses query params → `ctx.Query`; body → `ctx.ParsedBody`; multipart → `ctx.Files`. 4 MB body limit.
- Validate: enforces `mfx:` tag rules on create/update. Strips `readonly`/`id`; strips `immutable` on update.
- Service: passthrough. Business logic goes here.
- DB: dispatches to adapter. Routes through `ctx.Tx` when set. Maps `ErrNotFound`→404, `*ErrConstraint`→409, `context.DeadlineExceeded`→504.
- Response: builds `APIResponse` from `ctx.DBResult`. List adds `meta`. Delete returns 204.
## ServerContext (per-request, not goroutine-safe)
Common fields:
- `Request *http.Request`, `Writer http.ResponseWriter`, `Ctx context.Context`
- `Model *ModelMeta`, `Operation Operation`, `ResourceID string`
- `RequestID string`, `TraceID string`
- `RawBody []byte`, `ParsedBody *RequestBody` (read-only — `ctx.Field` to read, `ctx.SetField`/`DeleteField` to mutate), `Query *QueryParams`, `Files map[string]*UploadedFile`
- `DBResult any` (`*ListResult` for list; the record otherwise — a typed `*T` on reads)
- `Response *APIResponse`
- `Auth *AuthInfo`, `Tx Tx`
Methods:
- `Abort(status int, code, message string)` — sets `ctx.Response`; caller returns nil without `next()`.
- `BindJSON(v any) error` — decode body into `v`; calls Abort on error.
- `URLParam(name) string`, `QueryParam(name) string`
- `Set(k, v)` / `Get(k) (any, bool)` — cross-step storage
- `Logger() *slog.Logger` — pre-seeded with request_id, trace_id, service
- `HasRole(role string) bool`
- `BeginTx(ctx, opts *TxOptions) (Tx, error)` — start a transaction
- `LockForUpdate(modelName, id) (map[string]any, error)` — requires `ctx.Tx`
- `RawQuery(sql, args...) ([]map[string]any, error)` — routes through ctx.Tx
- `RawExec(sql, args...) (int64, error)`
- `GetModel(name) *ModelAccessor` — CRUD on any registered model; routes through ctx.Tx
ModelAccessor methods: `List(q)`, `Read(id)`, `Create(data)`, `Update(id, data)`, `Delete(id)`.
`AuthInfo`:
```go
type AuthInfo struct {
UserID string
Roles []string
Claims map[string]any
TenantID string
IdentityType AuthIdentityType // IdentityHuman | IdentityServiceAccount | IdentityAnonymous
Scopes []string
SessionID string
AuthMethod string // "jwt" | "api_key" | "session" | ...
}
```
## Transactions
```go
// Option A — middleware-wrapped, automatic commit/rollback
server.Pipeline.Service.Register(
maniflex.WithTransaction(nil), // nil opts = default isolation
maniflex.ForOperation(maniflex.OpCreate, maniflex.OpUpdate, maniflex.OpDelete),
)
// Option B — manual
tx, err := ctx.BeginTx(ctx.Ctx, nil)
if err != nil { return err }
ctx.Tx = tx
defer tx.Rollback() // no-op after Commit
// ... ctx.GetModel(...).Create/Update/Delete all routed through tx ...
return tx.Commit()
```
`WithTransaction` commits if `next()` returned nil AND `ctx.Response` is nil or 2xx. Otherwise rolls back. Idempotent — registering twice is fine, second call sees existing `ctx.Tx`.
SQLite does not support nested transactions. Use `_txlock=immediate` DSN for write-lock isolation.
## Errors
Sentinels (use `errors.Is` / `errors.As`):
```go
maniflex.ErrNotFound // 404 NOT_FOUND
*maniflex.ErrConstraint // 409 CONFLICT (Table, Column, Detail)
```
Built-in error codes the framework emits:
`INVALID_JSON`, `EMPTY_BODY`, `BODY_READ_ERROR`, `INVALID_QUERY`,
`MULTIPART_ERROR`, `NOT_FOUND`, `CONFLICT`, `VALIDATION_FAILED`,
`DATABASE_ERROR`, `TX_BEGIN_ERROR`, `TX_COMMIT_ERROR`, `NO_STORAGE`,
`TIMEOUT`, `PANIC`, `ENCRYPTION_NOT_CONFIGURED`.
Envelope: `{"error": {"code": "...", "message": "...", "details": ...}}`
Success envelope: `{"data": ...}`; list adds `"meta": {total, page, limit, pages}`.
## Querying (only on opt-in fields)
Operators: `eq`, `neq`, `gt`, `gte`, `lt`, `lte`, `like`, `ilike`, `in`, `not_in`, `is_null`, `not_null`.
```
?filter=status:eq:published
?filter=views:gte:100&filter=status:eq:published # ANDed
?filter=tag:in:go,rust,zig
?filter=author.name:ilike:%ursula% # relation dot notation
?sort=created_at:desc&sort=title:asc
?include=user,comments,tags
?page=2&limit=20 # default 20, max 200
```
Soft-deleted rows are filtered out of list/read/include automatically. To see them: `?filter=deleted_at:not_null`.
## Custom Actions
```go
server.Action(maniflex.ActionConfig{
Method: "POST",
Path: "/orders/{id}/cancel",
Handler: cancelOrder,
Middleware: []maniflex.MiddlewareFunc{
auth.JWTAuth(secret),
// any maniflex.MiddlewareFunc — runs between Auth and the handler
},
})
func cancelOrder(ctx *maniflex.ServerContext) error {
id := ctx.URLParam("id")
var req MyReq
if err := ctx.BindJSON(&req); err != nil { return nil }
// ... work via ctx.GetModel / ctx.RawExec / ctx.BeginTx ...
ctx.Response = &maniflex.APIResponse{StatusCode: http.StatusOK, Data: result}
return nil
}
```
Action handler owns body parsing, validation, transactions. Validate/Service/DB pipeline steps do NOT run for actions.
## File uploads
Tag a string field `mfx:"file,max_size:2MB,accept:image/*"`. Configure `maniflex.Config.FileStorage`.
Two upload styles:
1. Multipart POST/PATCH to the model endpoint — fields become `ctx.ParsedBody`, file parts become `ctx.Files`, storage key is written into the column.
2. Two-step: `POST /files` (multipart, field name `file`) returns `{"data":{"key":...}}`. Pass the key as a JSON string on the file field.
Standalone routes (when `FileStorage` set):
- `POST /files` — upload
- `GET /files/{key...}` — download (sets Content-Type, Content-Disposition, Content-Length)
- `DELETE /files/{key...}`
Without `FileStorage`, multipart and `/files/*` return 501 NO_STORAGE.
Auto-cleanup: stored file deleted on hard-delete or field overwrite. `auto_delete:false` opts out. Soft-delete does not trigger cleanup.
## Catalogue middleware
```go
import (
"github.com/xaleel/maniflex/middleware/auth"
"github.com/xaleel/maniflex/middleware/body"
"github.com/xaleel/maniflex/middleware/validate"
"github.com/xaleel/maniflex/middleware/service"
"github.com/xaleel/maniflex/middleware/db"
"github.com/xaleel/maniflex/middleware/response"
"github.com/xaleel/maniflex/middleware/openapi"
"github.com/xaleel/maniflex/middleware/idempotency"
)
// AUTH (Auth step)
auth.JWTAuth(secret, auth.JWTOptions{Issuer, Audience, TenantClaim, ScopesClaim, PublicKey})
auth.APIKeyAuth("X-API-Key", auth.APIKeyEntry{Key, Auth: maniflex.AuthInfo{...}}, ...)
auth.RequireRole("admin")
auth.AllowPublicRead()
auth.AllowPublicWrite() // useful exception for sign-up
auth.BlockOperation(maniflex.OpCreate, maniflex.OpUpdate, maniflex.OpDelete)
// BODY (Deserialize / Validate steps)
body.MaxBodySize(16 << 20) // override 4MB default
body.StripUnknownFields()
body.CoerceTypes()
// VALIDATE (Validate step)
validate.UniqueField(sqlDB, "email")
validate.RegexField("phone", `^\+?[0-9]{7,15}$`)
validate.ForbiddenValues("role", "superadmin")
validate.RequireAtLeastOne("name", "email")
validate.CrossFieldValidate(func(body map[string]any) error { return ... })
validate.DateRange("start_date", "end_date") // end must not be before start
validate.RequireWhen("reason", "status:eq:rejected") // conditional required
// SERVICE (Service step — usually Before)
service.HashField("password") // bcrypt
service.SlugifyField("title", "slug")
service.SetField("user_id", func(ctx) any { return ctx.Auth.UserID })
service.StripField("password_confirm")
service.TimestampWhen("published_at", "status", "published")
service.OwnerScope("user_id")
// Side effects — register on DB step at AtPosition(After)
service.Emit(bus)
service.Webhook(service.WebhookConfig{URL, Secret})
service.SendEmail(mailer, func(ctx) *service.EmailMessage { return ... })
// DB (DB step)
db.ForceFilter("org_id", func(ctx) any { return ctx.Auth.Claims["org_id"] })
db.Tenancy("org_id", func(ctx) string { return ctx.Auth.TenantID })
db.Paginate(50)
db.RateLimit(db.RateLimitConfig{RequestsPerMinute: 10, Key: func(ctx) string {...}})
db.AuditLog(sink) // AtPosition(After), or default Before with db.WithChanges()
db.Invalidate(cache, func(ctx) []string { return ["keys", ...] }) // AtPosition(After)
// RESPONSE (Response step)
response.CORSHeaders()
response.Cache(300) // AtPosition(After)
response.TransformField("avatar_url", func(v any) any { return cdn+v.(string) })
response.RedactField("phone", func(ctx) bool { return !ctx.HasRole("support") })
response.Envelope(func(ctx, data, meta) any { return ... })
response.AddHeader("Strict-Transport-Security", "max-age=63072000")
response.Logging(slog.Default()) // AtPosition(After)
response.Metrics(collector) // AtPosition(After)
// OPENAPI (OpenAPI.Generate step, maniflex.After position)
openapi.SetTitle("My API")
openapi.SetDescription("...")
openapi.AddServer("https://api.example.com", "Production")
openapi.AddSecurityScheme("bearerAuth", maniflex.OASSecurityScheme{Type: "http", Scheme: "bearer"})
openapi.AddExtension(func(spec *maniflex.OpenAPISpec) { /* mutate freely */ })
// IDEMPOTENCY (Deserialize step, AtPosition(After), scoped to OpCreate)
idempotency.Middleware(idempotency.Config{
Store: maniflex.NewMemoryCache(), // or any maniflex.CacheStore (e.g. Redis)
TTL: 24 * time.Hour,
KeyFunc: func(ctx) string { return ctx.Auth.UserID },
HeaderRequired: false,
})
// Reads Idempotency-Key header. Replays cached 2xx for same key+body.
// Same key + different body → 422 IDEMPOTENCY_KEY_REUSED.
// Adds "Idempotent-Replayed: true" on replay.
```
## Encryption at rest
```go
import "github.com/xaleel/maniflex/pkg/encryption"
server := maniflex.New(maniflex.Config{
KeyProvider: &encryption.EnvKeyProvider{Prefix: "MYAPP_KEY"},
// or: &encryption.VaultKeyProvider{Address, Token, Mount: "transit"}
})
type Patient struct {
maniflex.BaseModel
SSN string `json:"ssn" mfx:"encrypted,key:patient-pii"` // → MYAPP_KEY_PATIENT_PII env var
}
```
- Storage: `enc:<base64(envelope)>`.
- `EnvKeyProvider` needs base64-encoded 32-byte keys in env vars.
- Encrypted fields cannot be `filterable`/`sortable`.
- `encrypted,unique` adds `{field}_hmac TEXT UNIQUE` companion column.
- `maniflex.RotateEncryptionKey(ctx, server, "Model", oldKeyID, newKeyID)` re-encrypts in pages of 100. Keep both keys active.
## Versioning
```go
server.MustRegister(Invoice{}, maniflex.ModelConfig{Versioned: true})
// or VersionedDiffOnly: true to skip snapshots
```
Creates `invoice_histories` table with columns: `id, record_id, version, operation, actor_id, timestamp, request_id, diff, [snapshot]`.
Diff excludes hidden/writeonly/encrypted fields and HMAC companions. History table is read-only (writes return 405).
## Scheduled runner
```go
import "github.com/xaleel/maniflex/scheduled"
runner, _ := scheduled.New(server, scheduled.Config{
Interval: time.Minute,
BatchSize: 500,
OnDelete: func(model, id string) { ... },
OnSetField: func(model, id, field, to string) { ... },
})
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
runner.Start(ctx)
defer runner.Stop()
```
Scans `*time.Time` columns with `mfx:"scheduled"` tag. Per-model transactional batches. Hooks fire after commit. Use `runner.Sweep(ctx)` for one-shot ticks.
For multi-replica deployments: run runner in one replica, or use `maniflex/scheduled/jobsx` to dispatch sweeps through a job queue.
## Configuration
```go
type Config struct {
Port int // default 8080
PathPrefix string // default "/api"
ServiceName string // adds "service" attr to logs, X-Service-Name header
DB DBAdapter // required before Start
AutoMigrate bool // default true
QueryTimeout time.Duration // per-request DB deadline; 0 = unlimited
ShutdownTimeout time.Duration // default 30s
Logger *slog.Logger
PanicLogger *slog.Logger
Trace PipelineTrace // {Enabled, Steps, Timings, Aborts, Bodies, Skips}
FileStorage FileStorage
KeyProvider KeyProvider
HealthCheckDB bool // GET /health pings DB
HealthTimeout time.Duration // default 3s
}
```
`maniflex.ConfigFromEnv()` reads PORT, PATH_PREFIX, DB_WRITE_URL, DB_READ_URL, SERVICE_NAME, LOG_LEVEL, etc.
## Database adapters
```go
// SQLite (dev)
db, err := sqlite.Open("./app.db", server.Registry())
db, err := sqlite.Open(":memory:", server.Registry())
db, err := sqlite.Open("file:./app.db?_txlock=immediate", server.Registry())
// PostgreSQL (prod)
db, err := postgres.Open(postgres.Options{
WriteURL: os.Getenv("DB_WRITE_URL"),
ReadURL: os.Getenv("DB_READ_URL"), // optional read replica
MaxOpenConns: 25,
MaxIdleConns: 5,
ConnMaxLifetime: 30 * time.Minute,
}, server.Registry())
```
Reads route to ReadURL outside an active transaction; reads inside a tx go to write primary. AutoMigrate adds missing columns; never drops. Logs drift warnings.
## ModelConfig (per-model options)
```go
server.MustRegister(MyModel{}, maniflex.ModelConfig{
TableName: "custom_table",
SoftDelete: maniflex.SoftDeleteConfig{Enabled: true, Field: "deleted_at", FieldType: maniflex.SoftDeleteTimestamp},
Middleware: &maniflex.ModelMiddleware{
Auth: []maniflex.MiddlewareFunc{...},
Deserialize: []maniflex.MiddlewareFunc{...},
Validate: []maniflex.MiddlewareFunc{...},
Service: []maniflex.MiddlewareFunc{...},
DB: []maniflex.MiddlewareFunc{...},
Response: []maniflex.MiddlewareFunc{...},
},
Versioned: true,
VersionedDiffOnly: false,
Indices: []maniflex.IndexSpec{{Name, Columns, Unique}},
})
```
`Register` accepts `...any`. Slice arguments are flattened one level — so you can do:
```go
var AuthModels = []any{User{}, Role{}}
var OrderModels = []any{Order{}, OrderLine{}}
server.MustRegister(AuthModels, OrderModels) // both flattened
```
A `ModelConfig` value applies to the model immediately preceding it.
## Common patterns
### Sign-up exception with global JWT
```go
server.Pipeline.Auth.Register(auth.AllowPublicWrite(),
maniflex.ForModel("User"), maniflex.ForOperation(maniflex.OpCreate))
server.Pipeline.Auth.Register(auth.JWTAuth(secret),
maniflex.ForOperation(maniflex.OpCreate, maniflex.OpUpdate, maniflex.OpDelete))
```
### Hash password on User create/update
```go
server.Pipeline.Service.Register(service.HashField("password"),
maniflex.ForModel("User"), maniflex.ForOperation(maniflex.OpCreate, maniflex.OpUpdate))
```
### Multi-tenancy
```go
server.Pipeline.DB.Register(db.Tenancy("org_id",
func(ctx *maniflex.ServerContext) string { return ctx.Auth.TenantID }))
```
### Audit every write
```go
server.Pipeline.DB.Register(db.AuditLog(sink, db.WithChanges()),
maniflex.ForOperation(maniflex.OpCreate, maniflex.OpUpdate, maniflex.OpDelete))
// Note: WithChanges() requires default Before position, NOT After.
```
### Transactional create with stock lock
```go
server.Pipeline.Service.Register(maniflex.WithTransaction(nil),
maniflex.ForModel("Order"), maniflex.ForOperation(maniflex.OpCreate))
server.Pipeline.Service.Register(func(ctx *maniflex.ServerContext, next func() error) error {
bookID, _ := ctx.Field("book_id")
book, err := ctx.LockForUpdate("Book", bookID.(string))
if err != nil { return err }
if book["stock"].(int64) < 1 {
ctx.Abort(http.StatusConflict, "OUT_OF_STOCK", "")
return nil
}
return next()
}, maniflex.ForModel("Order"), maniflex.ForOperation(maniflex.OpCreate))
```
### Custom default sort
```go
server.Pipeline.Deserialize.Register(func(ctx *maniflex.ServerContext, next func() error) error {
if err := next(); err != nil { return err }
if len(ctx.Query.Sorts) == 0 {
ctx.Query.Sorts = append(ctx.Query.Sorts, maniflex.SortExpr{
Field: "created_at", Direction: maniflex.SortDesc,
})
}
return nil
}, maniflex.ForModel("Article"), maniflex.ForOperation(maniflex.OpList), maniflex.AtPosition(maniflex.After))
```
### Background worker reading registered models
```go
// In a goroutine launched alongside server.Start():
events := server.ModelAccessor("OutboxEvent")
rows, _ := events.List(&maniflex.QueryParams{
Filters: []*maniflex.FilterExpr{{Field: "status", Operator: maniflex.OpEq, Value: "pending"}},
Limit: 20,
})
for _, ev := range rows {
// ... process ...
events.Update(ev["id"].(string), map[string]any{"status": "done"})
}
```
## Project layout (recommended)
Small app — single `main.go`. Past ~5 models, split by responsibility:
```
main.go wiring only (4 steps)
config.go maniflex.Config assembly
models/ one file per model
middleware/ custom middleware + register.go
actions/ custom action handlers
internal/ framework-free business logic
```
Large monolith — split by domain:
```
domains/auth/ models.go + middleware.go + register.go (exports Models []any and Register(s))
domains/orders/ ditto
domains/catalog/ ditto
main.go server.MustRegister(auth.Models, orders.Models, catalog.Models)
auth.Register(server); orders.Register(server); catalog.Register(server)
```
## Hard rules (ordered by frequency of being violated)
1. **Register models before `sqlite.Open` / `postgres.Open`.** Adapter reads
registry at open time.
2. **After `ctx.Abort`, return without `next()`.** Abort only sets `ctx.Response`;
it does not stop the chain.
3. **Soft-deleted rows are auto-filtered from list/read/include.** To see them,
filter on the marker (`?filter=deleted_at:not_null`).
4. **`OpAction` skips Validate/Service/DB.** Per-action middleware list runs
between Auth and the handler. The handler owns body parsing.
5. **`WithTransaction` on Service step requires `maniflex.Before` position** (default).
Or use `maniflex.Replace` on the DB step.
6. **`db.AuditLog(sink, db.WithChanges())` must be registered at `maniflex.Before`
position**, not After — it needs the pre-image.
7. **Encrypted fields can't be `filterable` or `sortable`.** For uniqueness use
`encrypted,unique` (adds HMAC companion).
8. **`AutoMigrate` never drops columns.** Removes need explicit `ALTER TABLE`.
9. **Configure `FileStorage` before any model with `mfx:"file"` is exercised.**
Without it, multipart uploads return 501.
10. **SQLite has no nested transactions.** Calling `BeginTx` inside an active
SQLite tx fails. Reuse `ctx.Tx`.
11. **`Handler()` does not migrate.** Only `Start()` runs auto-migration. When
mounting `Handler()` yourself, call `MigrateOnly`/`AutoMigrate` first.
## Testing pattern
```go
func newTestServer(t *testing.T) (*httptest.Server, *maniflex.Server) {
t.Helper()
server := maniflex.New(maniflex.Config{Port: 0, PathPrefix: "/api", AutoMigrate: true})
server.MustRegister(User{}, Post{})
db, err := sqlite.Open(":memory:", server.Registry())
if err != nil { t.Fatal(err) }
t.Cleanup(func() { db.Close() })
server.SetDB(db)
// Handler() does not migrate — only Start() does. Migrate explicitly.
if err := server.MigrateOnly(context.Background()); err != nil { t.Fatal(err) }
middleware.Register(server)
ts := httptest.NewServer(server.Handler())
t.Cleanup(ts.Close)
return ts, server
}
```
In-memory SQLite per test. `server.Handler()` returns the chi router but does
**not** migrate — `MigrateOnly` creates the tables up front.
## Sentinels & constants
```go
maniflex.OpCreate, maniflex.OpRead, maniflex.OpUpdate, maniflex.OpDelete, maniflex.OpList, maniflex.OpHead, maniflex.OpOptions, maniflex.OpAction
maniflex.Before, maniflex.After, maniflex.Replace
maniflex.OpEq, maniflex.OpNeq, maniflex.OpGt, maniflex.OpGte, maniflex.OpLt, maniflex.OpLte, maniflex.OpLike, maniflex.OpILike, maniflex.OpIn, maniflex.OpNotIn, maniflex.OpIsNull, maniflex.OpNotNull
maniflex.SortAsc, maniflex.SortDesc
maniflex.OnDeleteCascade, maniflex.OnDeleteSetNull, maniflex.OnDeleteRestrict, maniflex.OnDeleteNoAction
maniflex.IdentityHuman, maniflex.IdentityServiceAccount, maniflex.IdentityAnonymous
maniflex.SoftDeleteTimestamp, maniflex.SoftDeleteBool
maniflex.SchedSoftDelete, maniflex.SchedHardDelete, maniflex.SchedSetField
maniflex.ErrNotFound // sentinel error
*maniflex.ErrConstraint // typed error with Table, Column, Detail
maniflex.ErrNoAdapter
maniflex.ErrFileNotFound
```
## What maniflex does NOT do
- No codegen. Reflection at registration only.
- No GraphQL.
- No built-in CLI. Use standard `go` tooling.
- No multi-database per server instance. One adapter per `*maniflex.Server`.
- No automatic column drops in AutoMigrate. Manual `ALTER TABLE` required.
- No SQLite nested transactions.
- No magic — every behaviour is a function in the `maniflex` package or a
catalogue middleware. Read the source when in doubt.