Integrations » Writing custom code

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 --custom on 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 a deploy.json, put "custom": true in 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 /v1 base. 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, never r.Use. chi refuses 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:

  1. Declare the RPC and its messages in app/idl/proto/custom.proto.
  2. Run ./gen.sh in that directory — it needs protoc on your PATH, and writes the stubs into app/idl/gen.
  3. Implement the generated service next to grpc.go.
  4. 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

  • OnStart must not block. fx runs the hooks one after another and won't serve a single request until yours returns. Spawn a goroutine and return nil.
  • The OnStart context 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 from context.Background() and cancel in OnStop.
  • OnStop should 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.yaml is 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})

List and the index fetches are paginated in SQL. A request with PageSize left 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.


Next steps