backend-server-anatomy
Canonical folder structure and architecture pattern for gRPC backend servers under apps/servers/. No concrete reference implementation currently exists on disk — this is the pattern to follow when sca
SKILL.md.claude/skills/backend-server-anatomy/SKILL.md
GitHubBackend gRPC Server Anatomy
No concrete reference implementation currently exists in apps/servers/ — the previous
examples (server1-grpc, basic CRUD; server2-grpc, adds a Debezium CDC consumer +
Meilisearch indexing) were removed. This doc captures the pattern they established; treat it
as the spec for the next server scaffolded, and update this doc to point at it once one exists.
Folder structure
apps/servers/{name}-grpc/
├── Dockerfile
├── README.md
├── .env.sample
├── helm/
│ ├── Chart.yaml
│ ├── values.yaml
│ ├── values-prod.yaml
│ └── templates/
│ ├── deployment.yaml
│ ├── env.yaml
│ └── redis.yaml ← only if redis extension installed (Deployment/Service/Secret, same chart)
├── index.ts ← entry point, calls src/app.ts
├── package.json ← scripts: dev, gen, postinstall (prisma generate, if DB)
├── prisma.config.ts ← only present when the database extension is installed
├── tsconfig.json
├── generated/prisma/ ← Prisma client output. NEVER edit, regenerated by `prisma generate`
└── src/
├── app.ts ← ServerApp composition root (see below)
├── topics.ts ← Kafka topic name constants
├── configs/
│ └── proto/buf.gen.yaml ← buf codegen config (present when gRPC driver installed)
├── schemas/
│ ├── prisma/schema.prisma + migrations/ ← source of truth for DB schema
│ └── proto/{service}.proto ← source of truth for gRPC contract
├── repositories/
│ └── {Entity}Repository.ts ← extends BaseRepository<PrismaClient>
├── routers/
│ ├── index.ts
│ ├── {Entity}GrpcRouter.ts ← extends GrpcRouter<...ServiceServer>
│ └── {Entity}DebeziumRouter.ts ← extends KafkaConsumerRouter (only if consuming CDC)
└── usecases/
├── index.ts
├── Create{Entity}UseCase.ts
├── Get{Entity}UseCase.ts
├── List{Entity}UseCase.ts
├── Update{Entity}UseCase.ts
└── Delete{Entity}UseCase.ts
Architecture axioms
src/schemas/proto/{service}.protois the source of truth for the gRPC contract. Edit the.proto, then regenerate — never hand-edit generated stubs (seecode-generation-pipelineskill).src/app.tscomposes aServerApp(from theserverpackage) via a fluent builder: drivers (GrpcDriver, optionallyKafkaDriverfor producing),.database(PrismaClient, new PgAdapter(databaseUrl))if the DB extension is installed,.plugins([...])(HealthCheckPlugin,RedisPlugin,OtelPluginwere the common set in the previous reference servers — add more as needed),.containers({...})to register repositories into the DI container (transient(...)per request,singleton(...)shared),.routers([...]).- Router = wiring, not logic. A
GrpcRoutersubclass only maps RPC method names to UseCase classes viaget handlers()— it holds no business logic itself. - UseCase pattern: one class per operation (one verb, one entity). Constructor receives its repository (and any plugin clients it needs, e.g. Kafka producer) through the DI container — it does not reach into globals.
- Repository pattern: one class per entity, extends
BaseRepository<PrismaClient>. Owns all Prisma access for that entity plus any cache-aside logic (cacheAside/cacheAsideAllhelpers fromserverpackage) and any event publishing it does directly (e.g. producing a Kafka event on create). - CDC consumer router: when the server consumes Debezium change events (not just
produces), the router extends
KafkaConsumerRouter<TopicTypes>, not the producer-sideKafkaDriverconfig. It decodes Debezium's JSON envelope (not a Schema Registry protobuf schema) and dispatches to a UseCase per topic. - Servers with no auth requirement don't need to wire an auth interceptor — the previous reference servers ran without one. Don't invent one unless the task requires it.
Companion infra chart pattern
A server with the database extension gets a sibling apps/servers/{name}-infra chart (Postgres
only — Redis lives directly in the app chart itself, see the folder structure above), Terraform-
applied — see root CLAUDE.md's "Per-server infra" section for why.
apps/servers/{name}-infra/helm/
├── Chart.yaml
├── values.yaml
└── templates/
├── db.yaml ← PostgreSQL Deployment/Service/Secret, static superuser password
└── debezium.yaml ← only if debezium extension installed
The app chart's own env.yaml reads that static password cross-namespace via a Helm lookup at
render time (no provisioning Job, no rotation — same static-credential pattern Redis uses too,
just without the cross-namespace step since Redis's Deployment/Secret share the app chart's own
namespace). A per-deploy Vault-minted dynamic credential was evaluated and dropped in favor of
this — see root CLAUDE.md's services/* note on why Vault (and Infisical, evaluated as a
replacement) were both removed from this repo.
All -infra charts land in the one shared server-infra namespace (see
apps/terraform/main.tf); collisions are avoided by prefixing every object name with the
server's own name, not by a per-server namespace.
Scaffolding — never build this by hand
bun run generate
# select: server
# follow prompts to add drivers (grpc, graphql, kafka, cron) and extensions (database, redis, debezium, secrets)
This scaffolds the full structure above, including helm/.