--- title: Compose Hooks, Transactions, Routing, and Enrich in One Request Flow impact: HIGH impactDescription: Individual rules are atomic; this composite example shows which app instance applies at each layer and how errors propagate tags: extending, composition, transactions, hooks, enrich, routing, mental-model --- ## Compose Hooks, Transactions, Routing, and Enrich in One Request Flow The atomic rules (`ext-hooks-chain`, `ext-transactions`, `ext-routing-custom`, `ext-hooks-record-vs-request`, `ext-filesystem`, `ext-filter-binding-server`) each teach a single trap. Real extending code touches **all of them in the same handler**. This rule walks through one complete request flow and annotates **which app instance is active at each layer** - the single most common source of extending bugs is reaching for the wrong one. ### The flow `POST /api/myapp/posts` that: authenticates the caller, validates uniqueness with a bound filter, creates a record inside a transaction, uploads a thumbnail through a scoped filesystem, writes an audit log from an `OnRecordAfterCreateSuccess` hook, and shapes the response (including the realtime broadcast) in `OnRecordEnrich`. ``` HTTP request │ ▼ [group middleware] apis.RequireAuth("users") ◄── e.Auth is set after this │ ▼ [route handler] se.App.RunInTransaction(func(txApp) { │ // ⚠️ inside the block, use ONLY txApp, never se.App or outer `app` │ FindFirstRecordByFilter(txApp, ...) // bound {:slug} │ txApp.Save(post) // fires OnRecord*Create / *Request │ │ │ ▼ │ [OnRecordAfterCreateSuccess hook] ◄── e.App IS txApp here │ │ (hook fires inside the tx) │ e.App.Save(auditRecord) → participates in rollback │ e.Next() → REQUIRED │ │ │ ▼ │ return to route handler │ fs := txApp.NewFilesystem() │ defer fs.Close() │ post.Set("thumb", file); txApp.Save(post) │ return nil // commit │ }) │ ▼ [enrich pass] OnRecordEnrich fires ◄── RUNS AFTER the tx committed │ (also fires for realtime SSE and list responses) │ e.App is the outer app; tx is already closed ▼ [response serialization] e.JSON(...) ``` ### The code ```go app.OnServe().BindFunc(func(se *core.ServeEvent) error { g := se.Router.Group("/api/myapp") g.Bind(apis.RequireAuth("users")) g.POST("/posts", func(e *core.RequestEvent) error { // ── Layer 1: route handler ──────────────────────────────────────── // e.App is the top-level app. e.Auth is populated by RequireAuth. // e.RequestInfo holds headers/body/query. body := struct { Slug string `json:"slug"` Title string `json:"title"` }{} if err := e.BindBody(&body); err != nil { return e.BadRequestError("invalid body", err) } var created *core.Record // ── Layer 2: transaction ────────────────────────────────────────── txErr := e.App.RunInTransaction(func(txApp core.App) error { // ⚠️ From here until the closure returns, every DB call MUST go // through txApp. Capturing e.App or the outer `app` deadlocks // on the writer lock. // Bound filter - see ext-filter-binding-server existing, _ := txApp.FindFirstRecordByFilter( "posts", "slug = {:slug}", dbx.Params{"slug": body.Slug}, ) if existing != nil { return apis.NewBadRequestError("slug already taken", nil) } col, err := txApp.FindCollectionByNameOrId("posts") if err != nil { return err } post := core.NewRecord(col) post.Set("slug", body.Slug) post.Set("title", body.Title) post.Set("author", e.Auth.Id) // txApp.Save fires record hooks INSIDE the tx if err := txApp.Save(post); err != nil { return err } // ── Layer 3: filesystem (scoped to this request) ───────────── fs, err := txApp.NewFilesystem() if err != nil { return err } defer fs.Close() // REQUIRED - see ext-filesystem if uploaded, ok := e.RequestInfo.Body["thumb"].(*filesystem.File); ok { post.Set("thumb", uploaded) if err := txApp.Save(post); err != nil { return err } } created = post return nil // commit }) if txErr != nil { return txErr // framework maps it to a proper HTTP error } // ── Layer 5: response (enrich runs automatically) ──────────────── // e.App is the OUTER app again here - the tx has committed. // OnRecordEnrich will fire during JSON serialization and for any // realtime subscribers receiving the "create" event. return e.JSON(http.StatusOK, created) }) return se.Next() }) // ── Layer 4: hooks ────────────────────────────────────────────────────── // These are registered once at startup, NOT inside the route handler. app.OnRecordAfterCreateSuccess("posts").Bind(&hook.Handler[*core.RecordEvent]{ Id: "audit-post-create", Func: func(e *core.RecordEvent) error { // ⚠️ e.App here is txApp when the parent Save happened inside a tx. // Always use e.App - never a captured outer `app` - so that the // audit record participates in the same transaction (and the // same rollback) as the parent Save. col, err := e.App.FindCollectionByNameOrId("audit") if err != nil { return err } audit := core.NewRecord(col) audit.Set("action", "post.create") audit.Set("record", e.Record.Id) audit.Set("actor", e.Record.GetString("author")) if err := e.App.Save(audit); err != nil { return err // rolls back the whole request } return e.Next() // REQUIRED - see ext-hooks-chain }, }) app.OnRecordEnrich("posts").BindFunc(func(e *core.RecordEnrichEvent) error { // Runs for: // - GET /api/collections/posts/records (list) // - GET /api/collections/posts/records/{id} (view) // - realtime SSE create/update broadcasts // - any apis.EnrichRecord call in a custom route // Does NOT run inside a transaction; e.App is the outer app. e.Record.Hide("internalNotes") if e.RequestInfo != nil && e.RequestInfo.Auth != nil { e.Record.WithCustomData(true) e.Record.Set("isMine", e.Record.GetString("author") == e.RequestInfo.Auth.Id) } return e.Next() }) ``` ### The cheat sheet: "which app am I holding?" | Where you are | Use | Why | |---|---|---| | Top of a route handler (`func(e *core.RequestEvent)`) | `e.App` | Framework's top-level app; same object the server started with | | Inside `RunInTransaction(func(txApp) { ... })` | `txApp` **only** | Capturing the outer app deadlocks on the SQLite writer lock | | Inside a record hook fired from a `Save` inside a tx | `e.App` | The framework has already rebound `e.App` to `txApp` for you | | Inside a record hook fired from a non-tx `Save` | `e.App` | Same identifier, same rules, just points to the top-level app | | Inside `OnRecordEnrich` | `e.App` | Runs during response serialization, **after** the tx has committed | | Inside a `app.Cron()` callback | captured `app` / `se.App` | Cron has no per-run scoped app; wrap in `RunInTransaction` if you need atomicity | | Inside a migration function | the `app` argument | `m.Register(func(app core.App) error { ... })` - already transactional | ### Error propagation in the chain - `return err` inside `RunInTransaction` → **rolls back everything**, including any audit records written by hooks that fired from nested `Save` calls. - `return err` from a hook handler → propagates back through the `Save` call → propagates out of the tx closure → rolls back. - **Not** calling `e.Next()` in a hook → the chain is broken **silently**. The framework's own post-save work (realtime broadcast, enrich pass, activity log) is skipped but no error is reported. - A panic inside the tx closure is recovered by PocketBase, the tx rolls back, and the panic is converted to a 500 response. - A panic inside a cron callback is recovered and logged - it does **not** take down the process. ### When NOT to compose this much This example is realistic but also the ceiling of what should live in a single handler. If you find yourself stacking six concerns in one route, consider splitting the logic into a service function that takes `txApp` as a parameter and is called by the route. The same function is then reusable from cron jobs, migrations, and tests. Reference: cross-references `ext-hooks-chain.md`, `ext-transactions.md`, `ext-routing-custom.md`, `ext-hooks-record-vs-request.md`, `ext-filesystem.md`, `ext-filter-binding-server.md`.