- Docs
- Extend
- Customizing and leaving zever
Customizing and leaving zever
Use orm and core batteries standalone, write a custom codegen backend, tell generated code from owned code, and leave the framework without rewriting your app.
Zever generates plumbing, never business logic. This page is the escape-hatch manual: what you own versus what the compiler owns, how to use the libraries without the framework, how to version and drift-gate generated code, and how to leave entirely.
Generated versus owned
Section titled “Generated versus owned”| Path | Owner | Rule |
|---|---|---|
schema/*.zen |
You | The single source of truth. Everything else derives from it. |
generated/* |
Compiler (zever compile) |
Never hand-edit. Regenerate, or gate drift (below). |
internal/app/wiring.go, cmd/*, db/seed/* |
You after eject | Scaffolded once by zever new; thereafter yours. The server template resolves a fixed battery set — delete what you don’t call. |
.env.example |
You | Batteries and adapters via env vars; strict keys only. |
.github/workflows/ci.yml |
You after eject | Scaffolded once; regenerate step plus the --check drift gate. |
Every file under generated/ starts with a provenance header recording the backend and the schema revision that produced it (the zever release is deliberately left out, so upgrading the CLI never changes committed output):
// Code generated by zever compile --backend gogen - schema sha256:9f2c…. DO NOT EDIT..json specs (openapi.json, mcp.json) cannot carry a comment without breaking parsers, so they embed the same provenance in a top-level x-zever-generated block (backend, schema) instead. The zever/annotations.proto companion copy is vendored wire-stable API and is never stamped.
Extend around generated code
Section titled “Extend around generated code”Never edit generated/. Three supported patterns cover every extension point; there is deliberately no partial-file or edit-in-place mechanism, so regeneration stays a pure function of the schema:
- Implement the generated interface. Each service’s
service.godeclares a Go interface (one method per RPC) and says so in its own header comment: implement it in your own package and pass it toRegister<Service>Routes(HTTP) andNew<Service>GRPCServer(gRPC). Hand-written middleware, decorators, and wrappers compose around that implementation, never inside generated files. - Start from a write-once stub.
GenerateServiceStubrenders a compiling<Service>Impl(every method returningUnimplemented) meant to live outside the generated tree — typicallyinternal/app/<module>/<service_snake>.go. It is written once and never regenerated, so filling it in is safe; regeneration only fails you forward when the interface gains methods (thevar _ gen.Service = (*Impl)(nil)assertion stops compiling until you add them). - Embed and wrap generated types. Generated request/response aliases and ORM models are plain Go values — embed them in your own structs or convert at the boundary. For wholly new output shapes, write a custom backend (below) instead of post-processing generated text.
What is explicitly out: editing generated files between runs (the next compile overwrites them, and --check will flag the drift as yours to reconcile).
Start small: batteries you actually need
Section titled “Start small: batteries you actually need”zever new --profile picks the adapter set so a prototype never pays for batteries it doesn’t use. Explicit --batteries add to the preset; --adapters override its picks.
| Profile | Batteries | go list -deps packages |
|---|---|---|
minimal |
Bootable floor only: log, router, auth, db, observability, permission, ratelimit |
450 |
api |
Minimal plus cache, queue, session, password, scheduler, outbox, idempotency, lock |
492 |
full |
Every known battery | 649 |
Counts are go list -deps ./... package counts (including stdlib and test dependencies) for a freshly scaffolded Go 1.27 project at v0.6.1, measured 2026-10-13. Direct require lines: 22 / 38 / 82. Grow with zever add <battery> instead of starting full: each addition is three append-only edits (go.mod, internal/app/wiring.go, .env.example) that never overwrite hand-written code.
zever new hello --profile api --dir ./hellozever new hello --profile minimal --batteries db --adapters db=postgresDrift gate: zever compile --check
Section titled “Drift gate: zever compile --check”Teams that commit generated/ (like examples/todo) keep it honest with --check: compile in memory, compare against the tree, exit 1 naming every missing, differs, or extra file. It writes nothing.
zever compile --check --backend=zenorm,openapi,atlas --out ./generated schema/*.zenzever compile: generated output differs in 2 file(s) (atlas/schema.hcl (differs), zenorm/orm/gen/app/app.go (missing)); run 'zever compile' and commit the resultEach generated file carries a provenance stamp naming the backend and the schema hash. Stamps written by zever v0.7.1 and earlier also embed the zever release; --check ignores that legacy version field, so a tree committed by an older CLI does not look drifted: the schema hash and every other byte still compare exactly. When files differ only in the legacy stamped version, --check passes and prints a note (the --json envelope lists them under stampVersionOnly); run zever compile to refresh the stamps, or add --strict to make that case fail too.
Every scaffolded project’s CI (.github/workflows/ci.yml) regenerates from the schema and then runs --check, proving the compile is deterministic; if you commit generated/, drop the regenerate step and --check alone fails the build on drift. The repo’s own make compile-check (part of make check) runs the same gate over examples/todo.
Use orm standalone
Section titled “Use orm standalone”The typed query builder needs only a db.DB, never the container:
import ( "github.com/zenta-dev/zever/orm")
type User struct { ID string Email string}
var users = orm.NewTable[User]("users", []string{"id", "email"})var colEmail = orm.NewColumn[User, string]("users", "email")
// q selects id, email from users where email = ? — no raw SQL.q := orm.From(users).Select(colEmail).Where(colEmail.Eq("a@example.com"))Dialect comes from the connection (sqlite/postgres); unsupported features fail closed with a typed error instead of emitting wrong SQL.
Use core/* standalone
Section titled “Use core/* standalone”Every battery is one small interface plus a Register/Open pair. The container is only the default resolver — open an adapter directly when you embed a battery in a non-zever program:
import ( "github.com/zenta-dev/zever/core/cache" memory "github.com/zenta-dev/zever/adapters/cache/memory")
memory.Register() // wires "memory" into the cache registry, once
c, err := cache.Open("memory", cache.Options{})if err != nil { // ...}No globals cross battery boundaries, no init() wiring, contexts passed per call.
Write a custom backend
Section titled “Write a custom backend”compile is a library: implement backend.Backend and run it over the resolved schema from your own main.go. The CLI’s --backend flag stays fixed, so custom backends ship as their own binaries, not CLI plugins.
import ( "github.com/zenta-dev/zever/dsl/backend" "github.com/zenta-dev/zever/dsl/compile" "github.com/zenta-dev/zever/dsl/ir")
type tsBackend struct{}
func (tsBackend) Name() string { return "ts" }
func (tsBackend) Generate(schema *ir.Schema) (map[string][]byte, error) { // Walk schema.Modules; return path -> content. Keep it pure: // no I/O here, so output stays deterministic for --check. return map[string][]byte{}, nil}
func main() { files := map[string]string{"schema/app.zen": src} result, diags := compile.WithSchemaDir(files, "schema", tsBackend{}) _ = result _ = diags _ = backend.Backend(nil)}See Write a plugin for shipping a new adapter; backends are the codegen-side equivalent.
Leaving zever
Section titled “Leaving zever”- Freeze the plumbing: run
zever compileonce and commitgenerated/. - Trim the entrypoints: delete the
c.<Battery>()calls you never use fromcmd/server/main.go(and their imports frominternal/app/wiring.goplus the.env.examplestanzas). - Swap adapters in place: each battery resolves by name, so replacing
db=sqlitewith your own store is a config plusRegisterchange, not a rewrite. - Drop the framework requires you no longer import (
go mod tidyproves it: the build passes with a shrinkinggo.mod). - Keep the schema as documentation, or keep compiling: the generated ORM and OpenAPI specs have no runtime dependency on the CLI.
What stays portable by design: your handlers (plain net/http), your SQL (via orm, dialect-explicit), and your committed generated/ tree — every file says which release and schema revision produced it, so a future reader can reproduce or replace it.