---
title: 'Writing custom code'
group: 'integrations'
---

## 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`:

```bash
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.

```go
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:

```go
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`:

```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

```go
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`:

```go
// 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:

```go
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**:

```bash
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

- [Deploying a project](/docs/deploy)
- [Go Code Gen](/docs/extensions-go-code-gen)
- [Data Manager overview](/docs/data-manager-overview)
