Skip to main content

03 — The codebase tour

A folder-by-folder walkthrough. After this you should be able to look at any file path and know what kind of code lives there.

Top-level shape

Demoz-Pay/
├── apps/ Process roots — bootstrap + wiring, NO business logic
├── packages/ Reusable code — bounded contexts + shared infra
├── services/ Independently deployable Go services
├── infra/ Terraform, Helm, ArgoCD, docker-compose, ops SQL
├── tooling/ ESLint rules, Prisma lint, Nx generators
├── docs/ ADRs, runbooks, architecture, onboarding (this folder)
├── nx.json, package.json, tsconfig.base.json, pnpm-workspace.yaml
└── docker-compose.yml, .env.example, CLAUDE.md, README.md, ...

The split is opinionated:

  • apps/ are processes. They bootstrap. They wire. They never contain domain rules.
  • packages/ are libraries. Domain rules live here. Shared primitives live here.
  • services/ are standalone deployables. They have their own go.mod, Dockerfile, and don't share build artefacts with the monolith.

Read docs/adr/ADR-001-modular-monolith.md for why this split exists.

apps/ — six frontends + one API

apps/
├── api/ NestJS modular monolith (port 8000) [Live]
├── admin-web/ platform admin (Next.js 16) (port 4200) [Partial — UI mocks only]
├── employer-web/ employer / HR / finance (port 4201) [Partial — UI mocks only]
├── employee-web/ employee end-user (port 4202) [Partial — UI mocks only]
├── fi-web/ financial institutions (port 4203) [Partial — UI mocks only]
├── merchant-web/ BNPL merchants (port 4204) [Partial — UI mocks only]
├── docs-web/ public docs (Docusaurus) (port 4205) [Stub — template]
└── *-e2e/ Playwright tests per app [Stub — template tests]

Status legend: see ../STATUS_LEGEND.md.

apps/api/

The NestJS monolith. The most-important folder in the repo. We'll spend a section on it below.

apps/{admin,employer,employee,fi,merchant}-web/

Five Next.js 16 frontends. All five are UI shells with hardcoded mock arrays in src/data/*.ts. None call the API. The route trees are real (36 pages in admin, 29 in employer, 13 in employee, 10 in fi, 9 in merchant) but the data is fake.

Connecting these to the API is Planned. Until then, treat them as design references, not implementations.

apps/docs-web/

Docusaurus template. The pages are the default Docusaurus tutorial (create a blog post, deploy your site, etc.). No DemozPay docs. Stub — should be deleted or filled.

apps/api/ — the NestJS monolith

This is where almost all backend work happens today.

apps/api/
├── src/
│ ├── main.ts ← entry point
│ ├── app/ ← root module wiring (AppModule, AppController)
│ ├── assets/ types/
│ │
│ ├── _infra/ ← cross-cutting plumbing (the transactional + platform spine)
│ │ ├── config/ ← Zod env validation + APP_CONFIG token
│ │ ├── prisma/ ← PrismaService (the DB client)
│ │ ├── shared-infra/ ← tx runner (sets app.tenant_id) + outbox + audit + idempotency
│ │ ├── observability/ ← tracing, pino-logger, /metrics + collectors
│ │ ├── health/ ← /healthz + /readyz + startup probes
│ │ ├── outbox/ ← outbox publisher worker
│ │ ├── dead-letter/ ← DLQ admin surface (requeue)
│ │ ├── scheduling/ ← leader election (advisory locks)
│ │ ├── notification-consumers/ ← in-process SMS/email dispatch
│ │ └── idempotency/ resilience/ email/ sms/ uploads/ grpc-auth/
│ │
│ ├── identity/ ← auth + tenancy + org management
│ │ ├── auth/ ← better-auth (factory, session.middleware, guards, @Public)
│ │ ├── tenant/ ← tenant-context.middleware (reads req.user.businessId)
│ │ ├── tenancy/ organization/ organization-provisioning/
│ │ └── members/ roles/ platform-staff/ merchant/ financial-institution/ me/
│ │
│ ├── workforce/ ← employee CRUD + child resources + tenant catalogs
│ │ ├── employee/ employee-absence/ employee-allowance/ employee-deduction/
│ │ └── department/ position/ employment-type/ payment-frequency/ absence-type/ allowance/ deduction/
│ │
│ ├── products/ ← ewa/ lending/ equb/ (adapter bindings; domain lives in packages/*)
│ ├── compliance/ ← kyc/ (incl. identity-verification) + sanctions/
│ ├── money/ ← banking/ + integration/ (bank-settlement applier, webhooks)
│ └── payroll/ ← payroll engine + consumers/ (was payroll-consumers)
└── prisma/
├── schema.prisma ← The whole DB schema
├── migrations/ ← 85+ numbered migrations
└── RLS.md ← Implementation pointer (canonical: ADR-013)

Reading order for the API

If you read these 6 files in this order, you'll understand 90% of how the API works:

  1. apps/api/src/main.ts — boot sequence
  2. apps/api/src/app/app.module.ts — what's wired
  3. apps/api/src/_infra/config/config.module.ts + packages/shared/config/ src/lib/config.ts — the env contract
  4. apps/api/src/identity/auth/session.middleware.ts — how req.user is populated
  5. apps/api/src/identity/tenant/tenant-context.middleware.ts — how the tenant is resolved
  6. apps/api/src/_infra/shared-infra/prisma-transaction-runner.ts — the transactional spine where everything money-related goes through

Then peek at:

  • packages/ewa/backend/application/request-ewa.usecase.ts to see a real use case
  • apps/api/src/products/ewa/ledger.grpc-client.ts to see how it talks to the Go ledger

services/ — three Go services

services/
├── ledger/ Go gRPC — money journal (port 50051)
│ ├── cmd/ledger/main.go
│ ├── internal/{config,pg,store,server,fingerprint}/
│ ├── migrations/ Its own Postgres schema (separate from API's)
│ ├── test/ Hermetic test harness (postgres:16-alpine on :5434)
│ ├── go.mod, Dockerfile, project.json, README.md
│ └── Status: [Partial — schema + DB invariants Live, Go server Compile-only]

├── integration-gateway/ Go HTTP — bank/wallet adapter hub
│ ├── cmd/integration-gateway/main.go (25 lines, /health only)
│ └── Status: [Stub]

└── notifications/ Go HTTP — SMS/email/push worker
├── cmd/notifications/main.go (25 lines, /health only)
└── Status: [Stub]

services/ledger/

The most-developed Go service. Implements the gRPC contract at packages/contracts/grpc/ledger.proto:

  • PostTransaction — atomic multi-leg insert, idempotent via (tenant_id, idempotency_key) UNIQUE + fingerprint reconciliation
  • GetBalance — derived from ledger_account_balance view
  • Reverse — compensating-entry RPC with double-reversal lockout (partial unique index + SELECT ... FOR UPDATE)
  • GetEntries — paginated journal scan
  • ReconcileAccount — independent Go-side sum vs view-derived balance, returns drift

The schema is Live and runtime-proven (all 5 DB invariants verified via psql on this host). The Go binary is Compile-only on most hosts — you need Go 1.22+ to build it, and buf generate to produce the proto stubs first.

The test harness at services/ledger/test/verify.sh brings up a hermetic Postgres on :5434 and runs the store-layer integration tests. Works without Go if you run it inside the container.

services/integration-gateway/

Stub. Intended to be the place where bank / wallet / mobile-money adapters live (CBE, Telebirr, Awash, etc.). Today it serves /health only.

services/notifications/

Stub. Intended to consume the outbox topic and send SMS / email / push. Today it serves /health only. The SMS provider abstraction currently lives at apps/api/src/identity/auth/sms-sender.ts (for the better-auth phone-OTP flow); the eventual notifications service would consume the outbox in Go.

packages/ — bounded contexts + shared infra + contracts

packages/
├── shared/ Cross-cutting infrastructure libraries
│ ├── money/ Exact-integer Money type [Live, 39 importers]
│ ├── idempotency/ Idempotency-Key store + port [Live, 15 importers]
│ ├── audit/ Audit emitter port [Live, 15 importers]
│ ├── events/ Outbox event types + EventPublisher port [Live, 18 importers]
│ ├── tenant-context/ AsyncLocalStorage tenant ctx [Live, 17 importers]
│ ├── auth/ JWT verifier port + FakeJwtVerifier [Deprecated — superseded by better-auth]
│ ├── database/ TransactionRunner port [Live, 15 importers]
│ ├── logging/ Logger contract [Live, 6 importers]
│ ├── validation/ Zod schemas [Partial — 4 importers]
│ ├── ui/ React design system [Partial — 2,624 LOC, ZERO TESTS, 73 importers]
│ └── config/ Zod env validation [Live, 10 importers]
├── ewa/ Earned Wage Access bounded context [Partial]
│ └── backend/
│ ├── domain/ Pure rules (Money, eligibility policy, status enum)
│ ├── application/ Use cases + ports (no Prisma here)
│ ├── infrastructure/ In-memory implementations (tests)
│ └── presentation/ EwaController, EwaModule, DTOs
├── lending/ Salary-backed loans bounded context [Partial]
│ └── backend/ Same hexagonal structure
└── contracts/ API contracts (the source of truth)
├── grpc/ ledger.proto, integration_gateway.proto
├── openapi/ (planned)
├── gen/ buf generate output [gitignored .pb.go files]
├── buf.yaml, buf.gen.yaml
└── README.md

packages/shared/ — the platform's primitives

Read packages/shared/README.md for the index. Highlights:

  • shared/money — never bypass this. It's the only correct way to do money arithmetic in the codebase. Money.fromMinor("150000", "ETB"), Money.allocate([60, 40]), Money.add, Money.sub, etc. All operations are exact-integer.
  • shared/database — defines the TransactionRunner<TTx> port. Domain code depends on this interface; apps/api's PrismaTransactionRunner is the concrete impl.
  • shared/tenant-contextrunWithTenant({tenantId}, fn) and getTenantId(). Backed by Node's AsyncLocalStorage. The middleware and runner use it to thread the tenant invisibly.
  • shared/events — the EventPublisher port and the outbox event shape.
  • shared/config — the Zod env schema. Single source for every environment variable in the platform.
  • shared/authDeprecated. Only contains JwtVerifier interface + FakeJwtVerifier. Real auth lives at apps/api/src/identity/auth/ (better-auth). Has 4 importers, all likely test-only.
  • shared/ui — 2,624 LOC of React components, zero tests, 73 importers. The frontends use it heavily. The widest-blast- radius untested code in the repo. Test coverage is Planned (Phase 1 of the intern programme).

packages/ewa/backend/ and packages/lending/backend/

The two bounded contexts that exist as code today. Both follow the hexagonal shape described in ADR-003:

packages/ewa/backend/
├── domain/ Pure rules. No I/O. No framework.
│ ├── ewa-request.ts The aggregate root + invariants
│ ├── ewa-status.ts Status enum + transition rules
│ ├── eligibility.ts Policy: how much can this employee draw?
│ ├── errors.ts Domain-specific exceptions
│ └── events.ts Event payload types
├── application/ Use cases — orchestrate domain via ports.
│ ├── ports/
│ │ ├── ewa-request.repository.ts ← interface
│ │ ├── accrued-earnings.port.ts ← interface
│ │ ├── ledger.port.ts ← interface
│ │ ├── disbursement.port.ts ← interface
│ │ ├── eligibility-cache.port.ts ← interface
│ │ ├── ledger-accounts.port.ts ← interface
│ │ └── clock.port.ts ← interface
│ ├── request-ewa.usecase.ts
│ ├── disburse-ewa.usecase.ts
│ └── get-eligibility.usecase.ts
├── infrastructure/ Stunt-double impls for tests
│ ├── in-memory-ewa.repository.ts
│ └── system-clock.ts
├── presentation/ HTTP edge
│ ├── ewa.controller.ts ← @Controller('ewa')
│ ├── ewa.module.ts ← EwaModule.register()
│ ├── tokens.ts
│ └── dtos/
└── index.ts ← barrel exports

The pattern (from ADR-003):

  • domain/ never imports anything outside itself. Pure TypeScript.
  • application/ depends on domain/ and on its own port interfaces.
  • infrastructure/ provides test doubles.
  • presentation/ is the framework adapter — defines controllers, modules.
  • The real production adapters (Prisma repos, gRPC clients) live in apps/api/src/products/ewa/, not here. This is why apps/api/src/products/ewa/ looks empty — it's just bindings.

packages/contracts/

.proto and OpenAPI sources. The Go stubs land in gen/go/ after buf generate (gitignored — they're build artefacts).

buf.gen.yaml uses BSR remote plugins, so you don't need protoc-gen-go installed locally. You do need buf itself.

The Go ledger consumes the stubs via a replace directive in services/ledger/go.mod pointing at ../../packages/contracts/ gen/go. The gen/go/go.mod file is committed (the .pb.go files are not) so the replace resolves immediately after buf generate.

infra/

infra/
├── docker-compose.yml (in repo root, not here — confusing, todo)
├── sql/
│ └── 00_create_outbox_publisher_role.sql [Live]
└── README.md

Today this folder is mostly aspirational. The only thing that actually runs is the publisher-role provisioning SQL. Terraform, Helm, ArgoCD subdirectories are mentioned in docs but don't exist on disk. Treat infra/ as Stub until proven otherwise.

tooling/

tooling/
├── eslint/ Custom ESLint rules (e.g. no-delete-on-financial)
├── prisma-lint/ Schema lint rules (no float money, etc.)
├── generators/ Nx generators for new domains
└── README.md

Read the rules here before you write code. They catch common mistakes (DELETE on financial tables, float money columns, cross-domain imports). Most violations are caught at lint time.

docs/

docs/
├── STATUS_LEGEND.md ← read first; what Live/Partial/etc. mean
├── adr/ Architecture Decision Records 001…013
├── runbooks/ (mostly Stub — templates only today)
├── architecture/
│ └── restructure-2026-05.md ← historical log of the May 2026 refactor
├── onboarding/ ← this folder
├── archive/ 5 obsolete files queued for deletion in Phase 2C
└── security/ (planned home for compliance-ethiopia.md)

docs/adr/

The most important folder in this directory. 13 ADRs covering:

  • ADR-001 modular monolith
  • ADR-002 packages over libs
  • ADR-003 domain package shape
  • ADR-004 frontend rename
  • ADR-005 money as santim
  • ADR-006 ledger as source of truth
  • ADR-007 idempotency-key required
  • ADR-008 audit + outbox same transaction
  • ADR-009 no DELETE on financial rows
  • ADR-010 two-language ceiling
  • ADR-011 cross-domain events only
  • ADR-012 ledger accounting model
  • ADR-013 tenant isolation via RLS

Every non-obvious choice has an ADR. Read the ones relevant to what you're touching.

docs/archive/

Five obsolete files:

  • DEMOZPAY_ARCHITECTURE.md — pre-restructure blueprint (delete)
  • SYSTEM_DOCUMENTATION.md — pre-restructure overview (delete)
  • NX_WORKSPACE_EXPLAINED.md — paths stale (delete)
  • theme-system-plan.md — superseded by packages/shared/ui (delete after UI ships)
  • CORE_BANKING_MICROSERVICES_PLAN.mdcontains Ethiopia regulatory research worth preserving. Pending carve-out to docs/security/compliance-ethiopia.md before deletion.

Phase 2C of the doc consolidation plan handles these; nothing deleted yet.

Configuration files at the root

FileWhat it does
package.jsonWorkspace root; defines pnpm run scripts
pnpm-workspace.yamlTells pnpm where workspace packages live
nx.jsonNx workspace config — tags, target defaults, cache
tsconfig.base.jsonShared TS config; defines @demoz-pay/* path aliases
docker-compose.ymlPostgres + Redis + Redpanda + API + 5 webs (some have status caveats)
.env.exampleTemplate for env vars — copy and edit
CLAUDE.mdAI session context
README.mdQuick-start
PROJECT_STRUCTURE.mdIntern-friendly long-form folder guide
SECURITY_CONTROLS.mdAuditor Q&A reference
INTERN_PROGRAM.md16-week intern programme

Nx tags + module boundaries

Open nx.json and look at the project graph (pnpm graph). The key insight:

  • Projects are tagged with scope:<domain> and type:<role>.
  • ESLint module-boundary rule enforces: scope:ewa cannot import scope:lending etc. (ADR-011).
  • If you try, lint fails before you can commit.

This is how cross-domain communication stays event-only without relying on developer discipline alone.

Continue reading

Next: 04-how-the-backend-works.md — how all this fits together at runtime.