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.