Intermediate May 3, 2026 · 2 min read

Eject when interpreter mode and hooks aren't enough — when the handler logic itself needs to change. The eject command moves the four generated Go files into custom/ so you can edit them.

When to eject

  • The generated CRUD doesn't fit (you need a streaming endpoint, a bulk import, a multi-table transaction)
  • You need a custom SQL query the runtime doesn't generate
  • You need to change the response shape (e.g. include a computed field that's not on the Thread)

If you're "almost there" and a before_save hook would do, prefer the hook — it's far less code to maintain.

The recipe

loom weave              # ensure .loom/go is fresh
loom eject Customer

What this does:

  • Moves .loom/go/models/customer.gocustom/models/customer.go
  • Moves .loom/go/handlers/customer.gocustom/handlers/customer.go
  • Moves .loom/go/handlers/customer_test.gocustom/handlers/customer_test.go
  • Moves .loom/go/routes/customer.gocustom/routes/customer.go
  • Strips the // Generated by Loom and // Source thread: header from each — the file is now yours

Re-running loom eject Customer is a no-op error unless you pass --force. That's intentional — you don't want a future loom weave to silently overwrite your customisations.

What needs to change

After ejection, the runtime no longer auto-generates these files. Two things follow:

  1. Update imports. The ejected handlers import {{ .ModulePath }}/internal/auth and /internal/permissions. In v0.1.0 those paths don't exist in your project — they refer to the framework's runtime. Add a thin shim or write the auth/permissions helpers yourself in your project's internal/auth/ and internal/permissions/.
  2. Wire them into a custom main.go. Interpreter mode (loom serve) doesn't consume custom/. You need a small main.go that imports your ejected routes:
import (
    "github.com/jmoiron/sqlx"
    "github.com/go-chi/chi/v5"
    "you/custom/routes"
)

func main() {
    db, _ := sqlx.Open("mysql", os.Getenv("DATABASE_URL"))
    r := chi.NewRouter()
    routes.MountCustomerRoutes(r, db)
    http.ListenAndServe(":8080", r)
}

Editing the handler

Open custom/handlers/customer.go. The functions follow the chi http.HandlerFunc convention. To customise:

  • Add fields to the JSON response by querying additional data and merging into the rendered map
  • Replace the db.SelectContext query with a more specific one
  • Add a new endpoint by adding a function and registering it in custom/routes/customer.go

Keeping schema in sync

The ejected models/customer.go is a snapshot of the Thread at eject time. If you later add a field to threads/customer.yaml, you have two options:

  • Manual: add the field to the ejected struct + handler INSERT/UPDATE statements
  • Diff-and-merge: run loom weave, compare .loom/go/models/customer.go to your custom/models/customer.go, and apply the diff

There's no automated re-eject. Loom won't overwrite ejected files — that's the contract.

Reverting an eject

There's no loom uneject. If you decide ejection was a mistake:

  1. Delete the four files under custom/
  2. Add the model back to interpreter-mode coverage (it always was — loom serve ignores custom/ entirely)
  3. Drop the custom main.go

You're back to interpreter mode for that model.

See also

  • Concepts → Interpreter Mode vs Ejected Code — the broader picture
  • Recipes → Use Hooks to Normalize Data Before Save — what you may want instead
Was this article helpful?