08 — How to add things
Recipes for the most common changes a new contributor will make.
Each recipe references the rules in
07-rules-and-patterns.md.
Add a new HTTP endpoint (existing domain)
Most endpoint additions land in an existing domain like EWA or lending. Process:
-
Define the use case in the application layer:
// packages/ewa/backend/application/cancel-ewa.usecase.ts@Injectable()export class CancelEwaUseCase {constructor(@Inject(EWA_TOKENS.EWA_REPOSITORY) private repo: EwaRequestRepositoryPort,@Inject(EWA_TOKENS.TRANSACTION_RUNNER) private runner: TransactionRunner<object>,@Inject(EWA_TOKENS.AUDIT_EMITTER) private audit: AuditEmitterPort,@Inject(EWA_TOKENS.OUTBOX_REPOSITORY) private outbox: OutboxRepositoryPort,) {}async execute(input: CancelEwaInput): Promise<EwaRequest> {return this.runner.runInTransaction(async (tx) => {const ewa = await this.repo.findById(input.id, tx);if (!ewa) throw new NotFoundException();if (ewa.status !== 'PENDING') {throw new BadRequestException('only PENDING can be cancelled');}const updated = ewa.transitionTo('CANCELLED');await this.repo.save(updated, tx);await this.audit.record({ tx, action: 'EWA_CANCELLED', ... });await this.outbox.append({ tx, type: 'ewa.cancelled', ... });return updated;});}} -
Add the route to the controller:
// packages/ewa/backend/presentation/ewa.controller.ts@Post('requests/:id/cancel')async cancel(@Param('id') id: string,@Headers('idempotency-key') idempotencyKey: string,) {return this.cancelUseCase.execute({ id, idempotencyKey });} -
Register the use case in the module:
// packages/ewa/backend/presentation/ewa.module.tsproviders: [RequestEwaUseCase,DisburseEwaUseCase,GetEligibilityUseCase,CancelEwaUseCase, // new] -
Write the unit test with an in-memory repo:
// packages/ewa/backend/application/cancel-ewa.usecase.spec.tsconst repo = new InMemoryEwaRequestRepository();const runner = { runInTransaction: async (work) => work({}) };const audit = { record: jest.fn() };const outbox = { append: jest.fn() };const useCase = new CancelEwaUseCase(repo, runner, audit, outbox);it('cancels a PENDING request', async () => {repo.seed({ id: 'r1', status: 'PENDING', tenantId: 't', ... });const result = await useCase.execute({ id: 'r1', idempotencyKey: 'k' });expect(result.status).toBe('CANCELLED');expect(audit.record).toHaveBeenCalled();expect(outbox.append).toHaveBeenCalledWith(expect.objectContaining({type: 'ewa.cancelled',}));}); -
Run lint + build + tests:
pnpm nx test ewapnpm nx lint ewapnpm nx build api -
Manual smoke against your local API (see 02-running-locally).
-
Commit with a meaningful message; reference the use case name and any ADR if behavior changes.
Add a new domain (e.g. savings, payroll, bnpl)
Use the generator:
pnpm gen:domain savings
This scaffolds:
packages/savings/backend/
├── domain/
├── application/{ports,}/
├── infrastructure/
├── presentation/
├── index.ts
└── project.json (with Nx tags scope:savings)
Then:
- Define domain types in
domain/(status enum, aggregate root, policy classes). - Define ports in
application/ports/(repository, ledger, any external service). - Implement use cases in
application/using the runner + audit + outbox pattern from EWA. - Wire production adapters in
apps/api/src/savings/:prisma-savings.repository.ts— Prisma implledger.grpc-client.ts— copy from EWA's, retarget tokenssavings-api.module.ts— bind tokens to providers
- Add to
apps/api/src/app/app.module.ts:imports: [...,SavingsApiModule,SavingsModule.register(),]configure() {consumer.apply(SessionMiddleware, TenantContextMiddleware).forRoutes(EwaController, LendingController, SavingsController);} - Add to Prisma schema if you need new tables. Run
pnpm prisma:migrate— but see "Add a new tenant table" below before doing this.
Add a new tenant table
This is the highest-risk change because if you forget step 3, the new table will not have tenant isolation in production.
-
Add the model in
apps/api/prisma/schema.prisma:model SavingsGoal {id String @id @default(cuid())tenantId String // ← REQUIREDemployeeId StringtargetSantim Decimal @db.Decimal(20,0)currency String @default("ETB")status SavingsGoalStatuscreatedAt DateTime @default(now())updatedAt DateTime @updatedAt@@index([tenantId, employeeId, status]) // ← include tenantId@@map("savings_goal")} -
Generate the schema migration:
pnpm prisma migrate dev --name add_savings_goal -
Update the RLS verify guard in
apps/api/prisma/migrations/20260526030000_apply_tenant_rls/ migration.sql. Specifically, add the new table to BOTHtenant_tablesarrays (the install loop AND the verify block).Actually, since you can't modify a past migration once it's landed, the correct pattern is:
- Create a NEW migration (e.g.
20260605000000_rls_savings_goal) - In that migration: install the policy on the new table AND verify it's there.
Use migration
20260526030000_apply_tenant_rls/migration.sqlas the template. - Create a NEW migration (e.g.
-
Verify locally:
pnpm prisma:reset && pnpm prisma:migrate# Verify guard at end of the new migration should pass. -
Read ADR-013 "Tables intentionally bypassing RLS" to confirm your new table genuinely belongs under RLS. (Hint: financial-tier → yes. Identity-tier → no.)
Add a new outbox event type
-
Define the event payload in
packages/shared/events/src/lib/or in the domain's own contracts:export interface EwaCancelledEvent {ewaRequestId: string;reason: string;cancelledAt: string; // ISO 8601} -
Append in the use case (inside the runner's txn):
await this.outbox.append({tx,type: 'ewa.cancelled', // canonical namepayload: serializeForEvent(event), // your serializer}); -
Naming convention:
<domain>.<verb-past-tense>. Examples:ewa.requested,ewa.disbursed,loan.repaid,payroll.run-completed. -
Consumers: today there are none. The outbox publisher produces to Kafka; future consumer services subscribe.
Add a new background job
@Injectable()
export class SavingsAccrualJob implements OnApplicationBootstrap, OnModuleDestroy {
private timer?: NodeJS.Timeout;
async onApplicationBootstrap() {
this.timer = setInterval(() => void this.tick(), 60_000);
this.timer.unref(); // don't block shutdown
}
onModuleDestroy() {
if (this.timer) clearInterval(this.timer);
}
private async tick() {
try {
// Operating across tenants → loop and enter context per tenant:
const tenants = await this.findActiveTenants();
for (const t of tenants) {
await runWithTenant({ tenantId: t.id }, async () => {
await this.runner.runInTransaction(async (tx) => {
// ... work for this tenant ...
});
});
}
} catch (err) {
this.logger.error('savings accrual failed', { err });
}
}
}
Rules of thumb:
- Use
setInterval, not cron-style libraries, for sub-hourly work. Use the OS cron / k8s CronJob for daily/weekly. - Always
unref()the timer so the process can shut down. - Wrap the work in try/catch so a single failure doesn't stop the loop.
- Background jobs operating across tenants must either use BYPASSRLS
(rare) or loop over tenants and
runWithTenantfor each.
Add a new env var
-
Add to the Zod schema in
packages/shared/config/src/lib/config.ts:SAVINGS_ACCRUAL_INTERVAL_MS: z.coerce.number().int().positive().default(60_000), -
Document in
.env.examplewith the same comment style as existing entries. -
Read via APP_CONFIG, never
process.env:constructor(@Inject(APP_CONFIG) private config: AppConfig) {this.intervalMs = config.SAVINGS_ACCRUAL_INTERVAL_MS;} -
If the new var is mandatory in production, mark it required in the schema (
.string().min(1)). Boot will fail fast on missing.
Add a new gRPC contract
-
Add the proto under
packages/contracts/grpc/. Follow the pattern ofledger.proto:- Package:
demozpay.<service>.v1 option go_package = "github.com/demoz-pay/contracts/gen/go/<service>/v1;<svc>v1";- Comment EVERY message field with its semantics.
- Package:
-
Run buf lint to confirm shape:
cd packages/contracts && buf lint -
Generate stubs:
cd packages/contracts && buf generateStubs land in
packages/contracts/gen/go/<service>/v1/*.pb.go(gitignored — they're build artefacts). -
Consume from the Go service:
import <service>v1 "github.com/demoz-pay/contracts/gen/go/<service>/v1" -
For breaking changes (renaming a field, removing a field, changing semantics): write an ADR. See
packages/contracts/README.mdfor versioning rules — same field number forever, new versions get new packages.
Add a new frontend page
(Assumes the app is one of the existing 5 product apps.)
- Create the route file:
apps/employer-web/src/app/(authenticated)/payroll/runs/page.tsx
- Use shared UI:
import { Button, Card, Table } from '@demoz-pay/shared-ui';
- Mock data first (today's pattern):
import { payrollRunsMock } from '@/data/payroll';
- When ready to wire to API: see "API integration roadmap"
notes in 05-the-frontend-apps.md.
Roughly:
@tanstack/react-query+ better-auth cookies + handle 401/403 + typed DTOs.
Add a runbook
cp docs/runbooks/_template.md docs/runbooks/<alert-name>.md
Fill in:
- Symptoms (what page/metric/log fires)
- First-response checks (≤ 5 commands)
- Likely causes (ranked by frequency)
- Mitigation steps (per cause)
- Postmortem checklist
The team owns runbooks together. Every incident should leave a runbook better than it found it.
Add an ADR
# Find the next number:
ls docs/adr/ | grep -E '^ADR-[0-9]+' | sort | tail -1
# → ADR-013-tenant-isolation.md
# Create:
cp docs/adr/_template.md docs/adr/ADR-014-<slug>.md
(The template lives in docs/adr/README.md. Copy from there if no
_template.md.)
Structure:
# ADR-NNN: <title in present tense>
- Status: Proposed → Accepted → Superseded (eventually)
- Date: YYYY-MM-DD
- Deciders: roles
## Context
What problem are we solving? What constraints exist?
## Decision
One paragraph, plain language.
## Alternatives considered
- Option A — why we didn't pick it
- Option B — why we didn't pick it
## Consequences
- Positive
- Negative
- Follow-ups
PR review: ADRs get reviewed like code. Don't merge until at least the engineering lead and one domain owner sign off.
Continue reading
Final file: 09-common-mistakes.md — what
goes wrong and how to debug it.