Skip to main content

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:

  1. 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;
    });
    }
    }
  2. 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 });
    }
  3. Register the use case in the module:

    // packages/ewa/backend/presentation/ewa.module.ts
    providers: [
    RequestEwaUseCase,
    DisburseEwaUseCase,
    GetEligibilityUseCase,
    CancelEwaUseCase, // new
    ]
  4. Write the unit test with an in-memory repo:

    // packages/ewa/backend/application/cancel-ewa.usecase.spec.ts
    const 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',
    }));
    });
  5. Run lint + build + tests:

    pnpm nx test ewa
    pnpm nx lint ewa
    pnpm nx build api
  6. Manual smoke against your local API (see 02-running-locally).

  7. 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:

  1. Define domain types in domain/ (status enum, aggregate root, policy classes).
  2. Define ports in application/ports/ (repository, ledger, any external service).
  3. Implement use cases in application/ using the runner + audit + outbox pattern from EWA.
  4. Wire production adapters in apps/api/src/savings/:
    • prisma-savings.repository.ts — Prisma impl
    • ledger.grpc-client.ts — copy from EWA's, retarget tokens
    • savings-api.module.ts — bind tokens to providers
  5. Add to apps/api/src/app/app.module.ts:
    imports: [
    ...,
    SavingsApiModule,
    SavingsModule.register(),
    ]
    configure() {
    consumer.apply(SessionMiddleware, TenantContextMiddleware)
    .forRoutes(EwaController, LendingController, SavingsController);
    }
  6. 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.

  1. Add the model in apps/api/prisma/schema.prisma:

    model SavingsGoal {
    id String @id @default(cuid())
    tenantId String // ← REQUIRED
    employeeId String
    targetSantim Decimal @db.Decimal(20,0)
    currency String @default("ETB")
    status SavingsGoalStatus
    createdAt DateTime @default(now())
    updatedAt DateTime @updatedAt

    @@index([tenantId, employeeId, status]) // ← include tenantId
    @@map("savings_goal")
    }
  2. Generate the schema migration:

    pnpm prisma migrate dev --name add_savings_goal
  3. Update the RLS verify guard in apps/api/prisma/migrations/20260526030000_apply_tenant_rls/ migration.sql. Specifically, add the new table to BOTH tenant_tables arrays (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.sql as the template.

  4. Verify locally:

    pnpm prisma:reset && pnpm prisma:migrate
    # Verify guard at end of the new migration should pass.
  5. 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

  1. 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
    }
  2. Append in the use case (inside the runner's txn):

    await this.outbox.append({
    tx,
    type: 'ewa.cancelled', // canonical name
    payload: serializeForEvent(event), // your serializer
    });
  3. Naming convention: <domain>.<verb-past-tense>. Examples: ewa.requested, ewa.disbursed, loan.repaid, payroll.run-completed.

  4. 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 runWithTenant for each.

Add a new env var

  1. 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),
  2. Document in .env.example with the same comment style as existing entries.

  3. Read via APP_CONFIG, never process.env:

    constructor(@Inject(APP_CONFIG) private config: AppConfig) {
    this.intervalMs = config.SAVINGS_ACCRUAL_INTERVAL_MS;
    }
  4. 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

  1. Add the proto under packages/contracts/grpc/. Follow the pattern of ledger.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.
  2. Run buf lint to confirm shape:

    cd packages/contracts && buf lint
  3. Generate stubs:

    cd packages/contracts && buf generate

    Stubs land in packages/contracts/gen/go/<service>/v1/*.pb.go (gitignored — they're build artefacts).

  4. Consume from the Go service:

    import <service>v1 "github.com/demoz-pay/contracts/gen/go/<service>/v1"
  5. For breaking changes (renaming a field, removing a field, changing semantics): write an ADR. See packages/contracts/README.md for 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.)

  1. Create the route file:
    • apps/employer-web/src/app/(authenticated)/payroll/runs/page.tsx
  2. Use shared UI:
    import { Button, Card, Table } from '@demoz-pay/shared-ui';
  3. Mock data first (today's pattern):
    import { payrollRunsMock } from '@/data/payroll';
  4. 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.