Writing custom code
What is the custom application zone?
Everything nuzur generates for you — entities, the data layer, the REST and gRPC servers — is regenerated from scratch on every run, so editing it is pointless: your change is gone the next time you deploy. The custom application zone is the part of the generated project that is not like that. It's a directory (app/ by default) whose files are scaffolded once, carry no DO NOT EDIT header, and are never touched again. That's where your own code lives.
Turn it on by deploying with --custom:
nuzur-cli deploy --host 203.0.113.10 --project my-project --custom
| File | What it's for |
|---|---|
app/rest.go |
Custom REST routes — no codegen needed |
app/grpc.go |
Override or extend generated gRPC endpoints |
app/idl/proto/custom.proto |
Brand-new gRPC RPCs — fill it in, then run app/idl/proto/gen.sh |
app/worker.go |
Background workers — schedulers, pollers, ingest loops, queue consumers |
Each file is written only if it isn't there yet, so once it exists it's yours: re-running deploy refreshes the generated code around it and leaves your code exactly as you wrote it.
Pass
--customon every deploy of that project. On older CLIs an omitted flag regenerates the app without the hooks your custom files call into, and the build fails on the box. If you keep adeploy.json, put"custom": truein it and stop thinking about it.
Custom REST routes
Add routes inside ProvideCustomRoutes in app/rest.go. It returns a function the generated REST server calls with its chi router, after the generated CRUD routes are mounted and after the generated middleware (request ID, recoverer, logger, CORS, and auth if you enabled it) is installed — so your routes get the whole chain, auth included, for free.
func ProvideCustomRoutes(coreImpl *core.Implementation) restserver.CustomRoutesFn {
return func(r chi.Router) {
r.Get("/v1/custom/ping", func(w http.ResponseWriter, req *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"status":"ok"}`))
})
}
}
Two rules, both enforced by a chi panic at startup rather than a compile error — so getting them wrong ships fine and then crash-loops the container:
- Spell the full path, including the
/v1base.r.Get("/custom/ping", …)compiles and registers at the root;r.Route("/v1", …)panics, because the generated CRUD routes already occupy that path. - Scope middleware with
r.Group, neverr.Use.chirefuses new middleware on a mux that already has routes.
Registering a route on a path a generated handler already serves shadows it — a static route wins over a mount — which is how you replace one generated endpoint without touching the rest.
Overriding a generated gRPC endpoint
app/grpc.go defines a Server that embeds the generated service, so every generated RPC works out of the box. Define a method with the same signature to take one over, and delegate to the embedded server when you still want the generated behavior underneath:
func (s *Server) CreateArticle(ctx context.Context, req *pb.CreateArticleRequest) (*pb.Article, error) {
// ... your custom logic ...
return s.ArticleServiceServer.CreateArticle(ctx, req) // generated behavior
}
Keep the embedded field — it's what makes every endpoint you don't override keep working. NewOverride is already wired into main.go, so your server is registered in place of the generated one automatically. Return failures as grpc/status codes, the way the generated endpoints do.
Brand-new gRPC RPCs
Overriding covers the generated surface. For an RPC that doesn't exist in your model at all:
- Declare the RPC and its messages in
app/idl/proto/custom.proto. - Run
./gen.shin that directory — it needsprotocon yourPATH, and writes the stubs intoapp/idl/gen. - Implement the generated service next to
grpc.go. - Register it on the gRPC server with a small fx invoke.
Register a brand-new service under its own service name. Registering a second implementation of the generated service panics the gRPC server with a duplicate-registration error at startup.
Background workers
Not all work starts with a request. Refreshing a cache every five minutes, polling an external API, draining a queue, importing from a feed on a schedule — that lives in app/worker.go:
func RegisterWorkers(lc fx.Lifecycle, coreImpl *core.Implementation, provider config.Provider, logger *zap.Logger)
main.go invokes it through fx at startup. You get the lifecycle to hang hooks on, the same data layer the request handlers use, the config provider, and the logger. Adding a worker needs no code generation — edit, rebuild, run.
A worker, start to finish
func RegisterWorkers(lc fx.Lifecycle, coreImpl *core.Implementation, provider config.Provider, logger *zap.Logger) {
interval := 15 * time.Minute
if d := provider.Get("workers.sync_interval").String(); d != "" {
if parsed, err := time.ParseDuration(d); err == nil {
interval = parsed
}
}
// NOT the OnStart context — that one is cancelled the moment startup finishes.
ctx, cancel := context.WithCancel(context.Background())
var wg sync.WaitGroup
lc.Append(fx.Hook{
OnStart: func(context.Context) error {
wg.Add(1)
go func() {
defer wg.Done()
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
syncTick(ctx, coreImpl, logger)
}
}
}()
return nil // never block startup
},
OnStop: func(stopCtx context.Context) error {
cancel()
done := make(chan struct{})
go func() { wg.Wait(); close(done) }()
select {
case <-done:
return nil
case <-stopCtx.Done(): // shutdown deadline reached
return stopCtx.Err()
}
},
})
}
// syncTick is one unit of work, and it recovers on its own.
func syncTick(ctx context.Context, coreImpl *core.Implementation, logger *zap.Logger) {
defer func() {
if r := recover(); r != nil {
logger.Error("sync worker panicked", zap.Any("panic", r))
}
}()
// Only one replica should actually run this tick. GET_LOCK is per-connection,
// so take a dedicated connection and close it to release the lock.
conn, err := coreImpl.DB().Conn(ctx)
if err != nil {
logger.Error("sync worker: no connection", zap.Error(err))
return
}
defer conn.Close()
var acquired int
if err := conn.QueryRowContext(ctx, "SELECT GET_LOCK(?, 0)", "myapp:sync").Scan(&acquired); err != nil || acquired != 1 {
return // another replica holds it — skip this tick
}
// ... fetch from the external source and write through coreImpl ...
}
What will bite you
OnStartmust not block. fx runs the hooks one after another and won't serve a single request until yours returns. Spawn a goroutine and returnnil.- The
OnStartcontext is a startup deadline, not the app's lifetime. It's cancelled as soon as startup completes, so a goroutine that inherits it dies immediately and usually silently. Derive fromcontext.Background()and cancel inOnStop. OnStopshould cancel and wait. Otherwise a rolling deploy tears down in-flight work mid-write.- Every replica runs the worker. This is the API process; scale to three pods and your five-minute job runs three times every five minutes, concurrently. Nothing coordinates for you — take a lease (a row with an owner and an expiry, or the MySQL advisory lock above) or keep the deployment at one replica.
- An unrecovered panic takes down the whole process. The HTTP and gRPC recoverers never see a panic in your goroutine — it kills the API with it. Recover per unit of work, log, and keep the loop alive.
- Writes flush the shared caches. The per-entity caches your worker writes through are the ones serving API reads, so a worker writing one row at a time in a tight loop keeps the API reading cold. Batch instead (see below).
- Read settings from the config provider, not from constants: intervals, batch sizes, endpoints and feature switches are deploy-time settings. Note that
config/base.yamlis regenerated on every deploy — put real values in the deploy-time config on the box.
Reaching the database from custom code
Custom code — routes, overrides and workers alike — receives *core.Implementation, the same data layer the generated handlers use. Never open your own connection or write raw SQL against your tables; you'd lose the validation, the caching and the event hooks the generated layer gives you.
Every standalone entity exposes a module with Fetch… (one per index), List, Insert, Update, Upsert and Delete:
// articletypes is core/module/article/types — each module has its own.
res, err := coreImpl.Article().List(ctx, articletypes.ListRequest{PageSize: 50})
Listand the index fetches are paginated in SQL. A request withPageSizeleft at zero returns zero rows — always set it.
All of them accept options: WithSkipCache() bypasses the in-process cache, and WithSQLTransaction(tx) joins an existing transaction. There is no transaction helper on core.Implementation — you begin one on the *sql.DB and pass it to every call that must share it, using each module's own option:
tx, err := coreImpl.DB().Begin()
if err != nil {
return err
}
defer tx.Rollback() // no-op once Commit succeeds
for _, a := range articles {
req := articletypes.UpsertRequest{Article: a}
if _, err := coreImpl.Article().Upsert(ctx, req, article.WithSQLTransaction(tx)); err != nil {
return err
}
}
return tx.Commit()
That's the batching a worker wants: one transaction per logical unit of work, instead of one per row.
Shipping it
There is no separate build, no second service, and no extra command. Edit your files in the app workspace and re-run the same deploy:
nuzur-cli deploy --host 203.0.113.10 --project my-project --custom
Deploy regenerates the generated code around your files, rebuilds the image on the box, and restarts the container — so your routes, overrides and workers ship with the rest of the app. Keep the workspace in git and every re-deploy becomes a reviewable diff of what codegen refreshed versus what you wrote.