Skip to main content

07 — Rules and patterns

The seven non-negotiable rules and the patterns you must use to write code that obeys them. If you find yourself wanting to break a rule, stop and write an ADR.

Rule 1: Money is integer santim (ADR-005)

// ❌ NEVER:
const amount = 150.00;
const total = price * 1.15;
prisma.foo.create({ data: { amount: 150.00 } });

// In schema.prisma:
amount Decimal @db.Decimal(15, 2) // ❌
amount Float // ❌

// ✅ ALWAYS:
import { Money } from '@demoz-pay/shared-money';

const amount = Money.fromMajor('1.50', 'ETB'); // 150 santim
const fee = Money.fromMinor('15', 'ETB'); // 15 santim
const total = amount.add(fee); // exact integer

// In schema.prisma:
amount_santim Decimal @db.Decimal(20, 0)

Why integer santim, not Decimal: float math is wrong for money (0.1 + 0.2 ≠ 0.3). Decimal(15,2) avoids that but allows fractional sub-cents. Integer santim eliminates the entire class of rounding bugs — there is no "0.005 ETB", only 0 santim or 1 santim.

Splitting money (e.g. principal + interest across 12 installments):

const principal = Money.fromMinor('100000', 'ETB'); // 1000 ETB
const installments = principal.allocate(
Array(12).fill(1)
);
// returns 12 Money values that sum EXACTLY to principal —
// the remainder is distributed to the first slots so the rounding
// error cannot propagate.

Marshalling at the proto / DB / JSON edge:

  • proto: int64 santim (with explicit IsInt64() overflow guard).
  • DB: NUMERIC(20,0). Read as text, parse to bigint.
  • JSON over HTTP: string. Never int (JSON has no integer-bigint).

Rule 2: Ledger is the sole source of money truth (ADR-006)

// ❌ NEVER:
prisma.wallet.update({ data: { balance: { increment: 1000 } } });
prisma.user.update({ data: { savings: '1500' } });

// ✅ ALWAYS:
await ledgerClient.postTransaction({
tenantId,
idempotencyKey,
description: 'EWA disbursement',
entries: [
{ accountId: 'ewa_receivable', debit: amount, currency: 'ETB' },
{ accountId: 'employee_wallet', credit: amount, currency: 'ETB' },
],
});

// To READ a balance:
const balance = await ledgerClient.getBalance({
tenantId, accountId: 'employee_wallet'
});

Why no balance columns: stored balances drift. The deferred trigger on the ledger enforces "debits = credits per currency" at COMMIT. Trust the journal; derive balances at read time. If your read pattern needs caching, cache the read — never store the value as if it's authoritative.

The legacy Wallet.balance and LedgerAccount.balance columns are Deferred for removal until their domain extractions ship. Don't write to them. Treat them as stale.

Rule 3: Idempotency-Key required on money-moving POSTs (ADR-007)

Every state-changing money operation needs a client-supplied idempotency key. The header is mandatory; missing → 400.

// In the controller:
@Post('requests')
async create(
@Body() body: RequestEwaDto,
@Headers('idempotency-key') idempotencyKey: string,
) {
if (!idempotencyKey) {
throw new BadRequestException('Idempotency-Key header required');
}
return this.useCase.execute({ ...body, idempotencyKey });
}

// In the use case:
const cached = await this.idempotency.find({
tenantId, scope: 'ewa.request', key: idempotencyKey,
});
if (cached) return cached; // replay-safe

const result = await this.runner.runInTransaction(async (tx) => {
// ... do the work ...
await this.idempotency.save({
tenantId, scope, key: idempotencyKey, fingerprint, result, tx,
});
return result;
});

The ledger layer enforces independently via (tenant_id, idempotency_key) UNIQUE + request_fingerprint on ledger_transaction. Use the same key end-to-end (API → use case → ledger gRPC).

Fingerprint: a stable hash over the request shape (sorted fields, RFC3339Nano timestamps). Same key + same fingerprint → cached. Same key + different fingerprint → FailedPrecondition (client error: you reused a key with different intent).

Rule 4: Audit + outbox in the same transaction (ADR-008)

await runner.runInTransaction(async (tx) => {
// ① Domain write
const ewa = await tx.ewa_request.create({ ... });

// ② Audit row — same tx
await audit.record({
tx,
actorId: getActorId(),
action: 'EWA_REQUESTED',
entityType: 'ewa_request',
entityId: ewa.id,
after: { /* snapshot */ },
});

// ③ Outbox event — same tx
await outbox.append({
tx,
type: 'ewa.requested',
payload: { ewaRequestId: ewa.id, ... },
});

return ewa;
});
// All three rows are visible only after COMMIT.
// On rollback, none exist.

Why same tx: if the audit row is in a separate tx and the domain tx commits first, an audit row never gets written and a regulator can't reconstruct. If the outbox is separate, an event fires for a state change that didn't commit. Both are silent correctness bugs.

The publisher (separate process) drains the outbox AFTER commit, so consumers see real events from real state changes. ADR-008 is the single biggest lever for auditability in the platform.

Rule 5: No DELETE on financial rows (ADR-009)

// ❌ NEVER:
prisma.ewa_request.delete({ where: { id } });
prisma.ledger_entry.delete({ where: { transaction_id: id } });

// ✅ ALWAYS:
// Use forward state transitions:
prisma.ewa_request.update({
where: { id },
data: { status: 'CANCELLED', cancelledAt: new Date() },
});

// For ledger corrections, use Reverse:
await ledgerClient.reverse({
tenantId,
idempotencyKey,
originalTransactionId: id,
reason: 'duplicate request',
});

Enforced at three layers:

  1. Lint — ESLint rule blocks .delete( on financial models.
  2. DB triggersledger_block_mutation rejects UPDATE/DELETE on ledger_transaction and ledger_entry.
  3. Code review — any DELETE on a financial path is a hard stop.

Why: an auditor must be able to reconstruct every state at any point in history. A DELETE makes the history incomplete and the audit trail unverifiable.

Rule 6: Tenant scoping mandatory (ADR-013)

// ❌ NEVER:
prisma.ewa_request.findMany({ where: { employeeId: 'foo' } });
// No tenant context → RLS returns 0 rows. Confusing. Or if you
// somehow have BYPASSRLS: you just returned every tenant's data.

// ✅ ALWAYS go through the runner:
await runner.runInTransaction(async (tx) => {
// app.tenant_id is set automatically from getTenantId()
return tx.ewa_request.findMany({ where: { employeeId: 'foo' } });
});

For background jobs that need to operate on a specific tenant:

import { runWithTenant } from '@demoz-pay/shared-tenant-context';

await runWithTenant({ tenantId: 'business-123' }, async () => {
await runner.runInTransaction(async (tx) => {
// tenant context is alive; RLS sees 'business-123'
});
});

For background jobs that operate across tenants (rare — the outbox publisher is one): provision a dedicated BYPASSRLS role and connect via a separate URL. Never ALTER ROLE the API's role to add BYPASSRLS, even locally. Read ADR-013 §"BYPASSRLS — the one explicit exception".

Rule 7: Two-language ceiling — TS + Go only (ADR-010)

Want to introduce: Decision:
Java / Spring Boot ❌ NO — write an ADR
Python ❌ NO — write an ADR
Rust ❌ NO — write an ADR
Kotlin ❌ NO — write an ADR
TypeScript ✅ YES (for: API, domain orchestration, web)
Go ✅ YES (for: money posting, settlement, high-throughput workers)

The cost of a third language is operational: each one needs its own CI lane, build chain, security patching cadence, hiring pool, runtime monitoring tooling, etc. The bar is higher than "this would be more elegant in language X."

The patterns that make these rules work

Pattern A: hexagonal domain packages (ADR-003)

Domain packages have four layers, and they only depend "inward":

domain/ Pure rules. No I/O. No framework. Imports nothing
outside itself.

application/ Use cases + port INTERFACES. Imports domain/.
Defines what the world looks like (ports) without
implementing it.

infrastructure/ Test doubles only. In-memory implementations
used in unit tests.

presentation/ HTTP edge. NestJS controller + module.

Production adapters (Prisma repos, gRPC clients, real Redis adapters) live in apps/api/src/<domain>/, NOT inside the domain package. This is why apps/api/src/products/ewa/ exists alongside packages/ewa/backend/.

Pattern B: ports & adapters

The domain depends on an interface:

// packages/ewa/backend/application/ports/ledger.port.ts
export interface LedgerPort {
postTransaction(input: PostTransactionInput): Promise<PostedTransaction>;
}

The production adapter implements it:

// apps/api/src/products/ewa/ledger.grpc-client.ts
@Injectable()
export class LedgerGrpcClient implements LedgerPort {
async postTransaction(input) { /* gRPC call */ }
}

The test double implements it too:

// in tests
const fakeLedger: LedgerPort = {
postTransaction: async () => ({ transactionId: 'fake' }),
};

This is how the unit tests run without a Go ledger: ports are swapped at test time, the use case logic is exercised against an in-memory fake.

Pattern C: cross-domain communication is event-only (ADR-011)

// ❌ NEVER:
// packages/lending/backend/...
import { EwaRequest } from '@demoz-pay/ewa'; // cross-domain import

// ✅ ALWAYS:
// packages/lending/backend/.../some-handler.ts
// (Subscribe to the outbox topic in services/notifications or a
// future consumer service, NOT in the same monolith process.)

If lending needs to know about an EWA event, it subscribes to the EWA outbox topic. Both domains import the event shape from @demoz-pay/shared-events, never each other's modules.

Enforced by Nx tag boundaries + ESLint module-boundary rule. If you try to add the import, lint fails before the PR even opens.

Pattern D: ConfigModule is fail-fast

// packages/shared/config/src/lib/config.ts
export const envSchema = z.object({
DATABASE_URL: z.string().min(1, 'DATABASE_URL is required'),
JWT_SECRET: z.string().min(16),
BETTER_AUTH_SECRET: z.string().min(16),
// ...
});
// apps/api/src/_infra/config/config.module.ts
const config: AppConfig = loadConfig(); // runs at module-load

This runs synchronously when the ConfigModule is loaded. Any invalid env throws and the process exits before NestFactory even finishes booting. Never read process.env.X directly in application code — go through APP_CONFIG.

// ❌ NEVER:
const url = process.env.DATABASE_URL; // unvalidated, fragile

// ✅ ALWAYS:
@Injectable()
class Foo {
constructor(@Inject(APP_CONFIG) private config: AppConfig) {}
doStuff() { console.log(this.config.DATABASE_URL); }
}

Pattern E: structured logs with trace correlation

// ✅:
this.logger.info('EWA disbursed', { ewaRequestId, tenantId });

NOT:

// ❌:
console.log(`disbursed ${id} for ${tenant}`);

Pino + OpenTelemetry stamp every line with trace_id and span_id. That correlation is how you find one request across many services. console.log bypasses all of it.

Pattern F: metrics labels are bounded

// ✅ — route is a TEMPLATE
httpRequestsTotal.labels(method, route, status_code).inc();

// ❌ — raw URL: every UUID explodes cardinality
httpRequestsTotal.labels(method, req.url, status_code).inc();

No userId, no tenantId, no idempotency_key on metric labels. The discipline is in apps/api/src/_infra/observability/metrics/registry.ts.

The escalation ladder for breaking a rule

If you think you have a genuine reason to violate one of the seven rules:

  1. Write the violation as code and run the test. Frequently the rule turns out to be right and you find another way.
  2. Search ADRs for prior art. Maybe someone has already thought about this exception.
  3. Open an ADR draft. Title it: "ADR-NNN: Exception to ADR-XYZ for ". Walk through alternatives. Get reviewed.
  4. Get explicit sign-off from the engineering lead AND the security lead (for rules 1–6) or the engineering lead alone (for rule 7).

There is no "I'll just add a TODO and skip the rule for now." Every exception is documented; every exception is reviewable; every exception is reversible.

Continue reading

Next: 08-how-to-add-things.md — recipes for adding common pieces of code safely.