Skip to main content

Concepts — the dozen things you need to know

Who this is for. Founders. Interns. Self-taught developers. Anyone who's seen these words ("Kafka", "RLS", "gRPC", "idempotency") in DemozPay's code or docs and isn't 100% sure what they mean.

How to read. Top to bottom, roughly 30 minutes. Each concept follows the same shape so you can skim once and come back.

What you get out of it. When you read other DemozPay docs after this, the words won't slow you down.


How each section is laid out

Every concept below uses this five-line shape:

In one sentence. The shortest honest definition.

Picture it. An everyday analogy.

Why DemozPay uses it. The actual business reason.

Find it in the code. File paths to open if you want to see real code.

Try it. Optional — a command you can run on your machine to feel it.


Part 1 — Money + storage

1. Santim

In one sentence. Santim is the smallest unit of Ethiopian birr — 1 ETB = 100 santim — and we store every money amount as an integer count of santim.

Picture it. Like cents to a dollar. $1.50 = 150 cents. Birr 1.50 = 150 santim. We never write 1.50 in the database. We write 150.

Why DemozPay uses it. Computers can't represent decimal money correctly with floats. The classic horror story: 0.1 + 0.2 === 0.30000000000000004. Over a million transactions, those pennies add up to lawsuits. Using integers means no rounding. Ever.

Find it in the code. packages/shared/money/ — the Money class. apps/api/prisma/schema.prisma — every money column is BigInt @db.Numeric(20,0). ADR-005 is the rule.

Try it.

import { Money } from '@demoz-pay/shared-money';
const fivetyBirr = Money.fromMajor('50.00', 'ETB'); // 5000 santim
const fee = Money.fromMinor(125n, 'ETB'); // 1.25 ETB
console.log(fivetyBirr.add(fee).format()); // "ETB 51.25"

2. Prisma

In one sentence. A library that lets you query the database in TypeScript instead of writing raw SQL.

Picture it. SQL is "go to aisle 3, shelf B, grab the can". Prisma is "give me a can". The library figures out the SQL for you. It also keeps your code in sync with the database schema.

Why DemozPay uses it. Type-safety — the TypeScript compiler stops you if you typo a column name. Migrations — schema changes happen via versioned migration files (apps/api/prisma/migrations/), not by editing the DB by hand. Single source of truth — schema.prisma describes every table; TS types are generated from it.

Find it in the code. apps/api/prisma/schema.prisma is the schema. Anywhere you see prisma.payrollRun.create({...}) you're using Prisma. The 41 timestamped folders under apps/api/prisma/migrations/ are the migration history.

Try it. pnpm prisma:studio opens a web UI to browse + edit your local database.


3. Postgres Row-Level Security (RLS)

In one sentence. A Postgres feature where the database itself blocks queries from seeing rows that don't belong to the current tenant.

Picture it. Imagine a library where every book has an owner sticker. Without RLS, the librarian hands you any book you ask for. With RLS, the librarian checks the sticker against your ID and only hands you books you own. Even if you forget to ask politely, you can't see someone else's book.

Why DemozPay uses it. Multi-tenant SaaS classic risk: a developer writes SELECT * FROM payroll_run and forgets WHERE tenant_id = $1. Now Tenant A sees Tenant B's payroll. RLS makes Postgres reject that query — the database itself filters the rows, so even buggy code can't leak data.

-- The actual policy on payroll_run:
CREATE POLICY tenant_isolation ON payroll_run
FOR ALL USING (tenant_id = current_setting('app.tenant_id')::uuid);

ALTER TABLE payroll_run FORCE ROW LEVEL SECURITY;

Before every transaction DemozPay does SET LOCAL app.tenant_id = '<uuid>'. From then on, Postgres only returns rows for that tenant. Forget to set it → zero rows (fail-closed, not fail-open).

Find it in the code. ADR-013. The migration that turns it on is apps/api/prisma/migrations/20260526030000_apply_tenant_rls/migration.sql. 20+ tables are protected.

Try it. Open Prisma Studio while logged into the DB as the api (non-bypass) role and try a SELECT on payroll_run without setting app.tenant_id. You'll get zero rows.


4. The Outbox Pattern

In one sentence. A way to make a database change and a message-broker publish happen atomically — by writing the message to a table inside the same DB transaction, then having a separate worker ship it later.

Picture it. A waiter (your app) needs to update the bill (the DB) AND tell the kitchen (Kafka) to start cooking. If they're two separate actions and the waiter trips between them, you've either updated the bill with no cooking, or started cooking with no bill. Solution: write the kitchen order on the same bill. Then a runner periodically takes the orders off the bills and delivers them to the kitchen. The bill update and the order are now one atomic action.

Why DemozPay uses it. When payroll commits a run, EWA + lending have to know — but the DB and Kafka are two separate systems. Without the outbox, if Kafka is down for 2 seconds, you've lost the event. With the outbox, the event sits safely in outbox_event until the publisher comes back online.

BEGIN;
UPDATE payroll_run SET status='APPROVED' WHERE id=$1; -- 1. state change
INSERT INTO outbox_event (type, payload) VALUES
('payroll.deductions_taken.v1', '{...}'); -- 2. event, SAME txn
INSERT INTO audit_entry (action, before, after) VALUES (...);-- 3. audit (ADR-008)
COMMIT;

A separate worker (apps/api/src/_infra/outbox/outbox-publisher.service.ts) tails the outbox_event table, ships unpublished rows to Kafka, marks them published_at. If the worker crashes, rows wait. Nothing is ever lost or duplicated.

Find it in the code. apps/api/src/_infra/outbox/outbox-publisher.service.ts. OutboxEvent model in schema.prisma. ADR-008.


Part 2 — Communication between services

5. HTTP REST (the one you already know)

In one sentence. The standard way browsers and apps talk: a client sends a request like POST /api/payroll with JSON, the server returns JSON back.

Picture it. Asking a librarian for a book. You walk in, ask for the title, they hand it over.

Why DemozPay uses it. For everything the browser hits — every route under /api/... in apps/api. It's the public API surface.

Find it in the code. Every *.controller.ts in packages/<domain>/backend/presentation/ and apps/api/src/<area>/. See docs/audits/API_INVENTORY_FRESH.md for the catalogue.

6. gRPC

In one sentence. Like HTTP REST but faster and type-safe — two services share a .proto file that defines exactly what calls exist and what data they carry.

Picture it. REST is asking the librarian in English ("can I have any book about cats, please?"). gRPC is showing them a numbered order form with checkboxes — faster, fewer mistakes, but you both need the same form.

Why DemozPay uses it. Inside the cluster, between TypeScript apps and Go services. Binary wire format is smaller and faster than JSON. The .proto file is the contract — change a field and both sides have to update. Catches mismatches at compile time.

Find it in the code. packages/contracts/grpc/ledger.proto and packages/contracts/grpc/integration_gateway.proto are the contracts. apps/api/src/products/ewa/ledger.grpc-client.ts is a TS client. services/ledger/internal/server/post_transaction.go is a Go server.

7. Kafka — the most important new word

In one sentence. A message broker that stores ordered, append-only logs called topics; apps write events to topics, other apps read them, and each reader independently tracks where it is.

Picture it. A shared notebook in the break room. Anyone can write to a page; anyone can read it later. The notebook never erases. Each reader bookmarks where they got to. Different notebooks for different subjects: payroll-events, ewa-events, lending-events.

The vocabulary:

  • Topic = one notebook. payroll.deductions_taken.v1, ewa.bank_transfer_settled.v1.
  • Event / message = one line in the notebook. Usually JSON: {"runId":"r-001", "amount":"5000"}.
  • Producer = an app writing into the notebook.
  • Consumer = an app reading from the notebook.
  • Offset = your bookmark — "I'm on line 47."
  • Broker = the server hosting all the notebooks.

Why DemozPay uses it. Cross-domain communication. ADR-011 forbids import { EwaService } from '@demoz-pay/ewa' inside the payroll package — domains can't directly call each other's code. So payroll emits an event to Kafka when it approves a run. EWA + lending subscribe to that topic and react. If EWA is down for a minute, events queue up; when EWA wakes up, it catches up from its bookmark.

Find it in the code. packages/<domain>/backend/application/events.ts declares the event types each domain emits. apps/api/src/_infra/outbox/kafka-event-publisher.ts is the producer. The compose service redpanda (see next entry) is the broker in dev.

8. Redpanda — the broker DemozPay actually runs

In one sentence. A drop-in replacement for Apache Kafka — same wire protocol, but simpler to run (no JVM, no Zookeeper, lighter on memory).

Picture it. Same notebook system, different brand of paper. Apps that know "Kafka" can't tell the difference.

Why DemozPay uses it. Less ops overhead. Kafka traditionally needs Zookeeper + JVM tuning + careful disk config. Redpanda is one binary that does all of it. For DemozPay's scale (a startup, not Twitter) it's the right call.

Find it in the code. The redpanda: service in infra/docker-compose.full.yml. Port 9092 is the Kafka wire protocol; port 9644 is its admin API.

9. Redpanda Console — the Kafka UI

In one sentence. A web dashboard for browsing what's in the broker — topics, messages, consumer lag.

Picture it. What Prisma Studio is to Postgres, Redpanda Console is to Kafka. A point-and-click window into the data.

Why DemozPay uses it. Debugging. "Did payroll emit the event?" — click the topic, see the message. "Why is EWA behind?" — check the consumer lag. "Replay the last 100 events to recover from a bad deploy" — reset the consumer offset.

Find it in the code. The redpanda-console: service in infra/docker-compose.full.yml. Web UI at http://localhost:9080.

Try it.

docker compose -p demoz -f infra/docker-compose.full.yml up -d redpanda redpanda-console
docker exec demoz-redpanda rpk topic create payroll.deductions_taken.v1
docker exec demoz-redpanda rpk topic produce payroll.deductions_taken.v1 \
<<< '{"type":"payroll.deductions_taken.v1","tenantId":"t-1","runId":"r-001"}'
# Open http://localhost:9080 — your message is in the topic.

Part 3 — Safety + auth

10. Idempotency-Key

In one sentence. A unique random string a client sends with a money-moving request, so the server can recognise retries and not double-charge.

Picture it. A printed restaurant order with a unique slip number. If the waiter trips, brings a second copy by mistake — the kitchen sees the same slip number and ignores it. The customer gets one meal, not two.

Why DemozPay uses it. Networks are unreliable. The client sends "transfer 500 to Alice"; the server processes it; the response gets lost; the client retries — without idempotency, Alice gets 1000. With idempotency: the client generates a random idem-9382 and sends it as the Idempotency-Key header. The server stores (idem-9382 → result). On retry, the server returns the cached result and doesn't re-process.

Find it in the code. ADR-007. packages/shared/idempotency/. Every money-moving use case uses IdempotencyStore.get-or-execute(key, fn). The new global @MoneyMoving() guard rejects any request without the header (400 Bad Request).

11. HMAC — signing requests between services

In one sentence. A small cryptographic tag attached to a request that lets the recipient verify it came from someone who knows the shared secret.

Picture it. A secret handshake. Both sides agree on the rule (e.g. "tap shoulder twice"). When the messenger arrives, they do the handshake. The recipient verifies. Anyone without the secret can't fake it. (Without sending the actual secret across the room.)

Why DemozPay uses it. Service-to-service auth. apps/api (TypeScript) calls services/ledger (Go) over gRPC. They need to trust each other but mTLS (proper certificate auth) is year-1 work. HMAC is the year-0 stopgap.

The signed message is ${clientId}.${timestamp}.${rpcMethod}.${tenantId}. Sha256-hmac of that string with the shared secret. Verified timing-safely on the server side. Replays caught by the 5-minute clock-skew window.

Find it in the code. apps/api/src/_infra/grpc-auth/grpc-hmac.ts (TS signer). packages/grpcauth-go/hmac.go (Go verifier).

12. Better-auth

In one sentence. The Node library DemozPay uses for user accounts, sessions, email verification, OTP — a modern alternative to writing JWT handlers by hand.

Picture it. Like Auth0 or Clerk, but you host it yourself.

Why DemozPay uses it. Auth is the easiest place to make a security mistake. Reaching for a battle-tested library beats hand-rolling. Better-auth handles email/password, email verification, phone-OTP via SMS, magic links, and organization membership (so a user can belong to a tenant).

Find it in the code. apps/api/src/identity/auth/. The Prisma models User, Session, Account, Verification, TwoFactor, Organization, Member, Invitation are populated by better-auth.


Part 4 — Build + dev tooling

13. Modular monolith vs microservices

In one sentence. A modular monolith runs all the code in one process but organises it into strict modules; microservices run each module as its own process.

Picture it. Modular monolith = one big office building with departments behind sturdy walls. Microservices = each department in its own building across the city.

Why DemozPay uses it. The startup version (modular monolith) avoids the "distributed-systems pain on day one" tax. Modules can later be carved out into separate processes when a real reason appears — that's what's already happened with the ledger (predictable latency, no GC pauses) and the integration-gateway (long-lived bank-partner connections). The rest of the backend runs in apps/api as one Nest process.

Find it in the code. ADR-001. apps/api is the modular monolith. services/ledger/ and services/integration-gateway/ are the carve-outs.

14. Nx

In one sentence. The tool that knows which packages depend on which, so commands like "test only what I changed" actually work in a monorepo.

Picture it. A factory map. When you tap "what depends on the bolt machine?", it lights up every product that uses the bolt. Without it, every change means rebuilding everything.

Why DemozPay uses it. With 10+ packages and 6 frontends, full builds + full test runs would be slow. Nx tracks the dependency graph; pnpm nx affected -t test only runs tests for packages your change actually touches.

Find it in the code. nx.json and project.json files in every workspace project. Try pnpm graph for a visual.

15. Docker Compose

In one sentence. A YAML file that defines many Docker containers and how they connect, so you can boot a whole stack with one command.

Picture it. A recipe card for an entire restaurant: "1 oven, 2 fridges, 3 stoves, all plugged into the same power strip, wait for the fridges to be cold before turning the stoves on." docker compose up follows the recipe.

Why DemozPay uses it. Local dev parity. docker compose -p demoz -f infra/docker-compose.full.yml up brings up 17 containers (Postgres, Redis, Redpanda, ledger, gateway, etc.) in the same shape as production. New devs don't manually install + configure 17 things.

Find it in the code. infra/docker-compose.full.yml (the full stack) and infra/docker-compose.test.yml (test-only).


Part 5 — Putting it together

How these all combine in one DemozPay flow

When an employer approves a payroll run, here's every concept in action:

  1. The browser hits POST /api/payroll/:id/approve (HTTP REST).
  2. NestJS's middleware sets SET LOCAL app.tenant_id = '<uuid>' on the DB session (RLS kicks in — only this tenant's rows are visible).
  3. The route is decorated @MoneyMoving() so the global guard checks for Idempotency-Key header (Idempotency).
  4. ApprovePayrollRunUseCase runs:
    • Updates payroll_run row (Prisma).
    • Inserts an outbox_event row in the same transaction (Outbox pattern, ADR-008).
    • Inserts an audit_entry row in the same transaction.
    • Posts the ledger entries via gRPC to services/ledger (HMAC-signed metadata for service trust).
    • Money amounts pass as santim strings ({ santim: "5000", currency: "ETB" }).
  5. Transaction commits. All three writes (state + outbox + audit) become real at the same instant.
  6. The outbox publisher worker (if enabled) reads new rows from outbox_event and ships them to Redpanda (Kafka).
  7. EWA's consumer + lending's consumer are subscribed to the topic. Each reads the event and runs its own use case to close its receivable.
  8. You open Redpanda Console at http://localhost:9080 and watch the event flow through every topic in real time.

That's it — one approve click touches every concept in this doc.


Where to go after this