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:
- Lint — ESLint rule blocks
.delete(on financial models. - DB triggers —
ledger_block_mutationrejects UPDATE/DELETE onledger_transactionandledger_entry. - 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:
- Write the violation as code and run the test. Frequently the rule turns out to be right and you find another way.
- Search ADRs for prior art. Maybe someone has already thought about this exception.
- Open an ADR draft. Title it: "ADR-NNN: Exception to ADR-XYZ
for
". Walk through alternatives. Get reviewed. - 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.