Design for all requirements; build incrementally in small bits (per the v1-scope decision — no reduced-scope release, just a sequence of shippable increments). Each phase is independently valuable, tested, and traces to requirement IDs. Phases have dependencies but adjacent ones can overlap.
Status legend: ✅ done · 🟡 partial · ⬜ not started.
- ✅ api-gateway — federated JWT validation, identity-header inject/strip, routing.
- ✅ platform-security — shared
CallerContext+ auto-config (downstream identity). - 🟡 iam-service — optional bundled IdP: register/login/JWT/JWKS work; KYC still here (to move to party-service).
- 🟡 account-service — skeleton +
/me; no CRUD/persistence yet. - ✅ Infra: Postgres, Kafka, Zipkin, docker-compose; per-service
.env; Boot 4 / Spring Cloud 2025.1.x.
Goal: the seams every service depends on.
- ✅ Tenant seam —
platform-tenancymodule:TenantContext+TenantContextFilter(fromX-Program-Id) + HibernateCurrentTenantIdentifierResolver+ auto-config (ADR-0009). Unit-tested. Services opt in by adding the dep and annotating their tenant column@TenantId; escalation to schema/DB-per-tenant is a Hibernate-strategy swap. - ✅ Outbox —
platform-outboxmodule:OutboxAppender(JDBC, joins the caller's transaction, cross-tenant-safe) + pollingOutboxRelay+ a dedicated String-serializing KafkaTemplate + auto-config; referenceoutbox.sqlshipped for per-service migration. Unit-tested. (FR-25) - ✅ gateway, ✅ platform-security, ✅ observability baseline (tracing).
- ⬜ Config/product/CoA scaffolding — folds into Phase 3 (accounts + CoA), where it's used.
- Build infra: ✅ added the JUnit Platform launcher to the root test classpath (Gradle 9
no longer adds it automatically) — unblocks
testtasks across all modules.
The highest-value, highest-risk piece; build it first to de-risk. Small bits:
- ✅ Ledger accounts (create, from CoA type/currency).
- ✅ Post journal entry + double-entry invariant (per-currency balance) — unit-tested.
- ✅ Balance read (posted/available/held/pending) —
Balances.signed()(normalize by normal side, unit-tested),PostingRepository.fold()(native, explicit tenant filter),GET /accounts/{id}/balance. Live-verified: debit/credit math correct (credit-normal wallet debited 50000 → posted −50000, counterparty +50000) and cross-tenant read → 404. - ✅ Snapshots — global
posting.seq(DB sequence, unmapped) +balance_snapshottable (V3);SnapshotStore(lag-safe advancing UPSERT — gap-free high-water mark) + scheduledSnapshotWriter;getBalancereadssnapshot ⊕ fold(seq > as_of_seq). Live-verified: writer advanced snapshots, and balances stayed exact with no snapshot, with snapshot (== full fold), and with snapshot + new delta. - ✅ Point-in-time by value_date —
PostingRepository.foldAsOf(value_date <= asOf, full value-dated fold, not the booking-seq snapshots) +getBalanceAsOf+GET /accounts/{id}/balance?asOf=YYYY-MM-DD. Live-verified across value dates (inclusive boundary; pre-entry dates read 0). (FR-20) - ✅ Idempotency keys (per tenant). ✅ Reversals —
POST /entries/{id}/reverseappends a swapped-leg entry (reversal_of, original immutable), idempotent, emitsledger.entry.reversed. Live-verified: nets to zero, idempotent replay, PIT before reversal preserves the original. - ✅ Emit
ledger.entry.postedvia outbox.
Bit 1 delivered + verified (2026-07-10): ledger-core-service module + LedgerAccount/
JournalEntry/Posting entities (@TenantId seam), DoubleEntryValidator, LedgerService
(create account, post entry with invariant + idempotency + outbox), REST controller,
Flyway V1/V2 (+ ledger_core_db). Invariant unit-tested (6/6). Live smoke test passed:
booted clean (ddl-auto: validate accepted the schema), balanced post → 201, unbalanced
→ 422, idempotent replay → same entry, and DB showed 2 accounts / 1 entry / 2 postings / 1 outbox all tagged tenant_id auto-set from X-Program-Id. (Fix along the way:
platform-outbox no longer hard-requires the consumer to expose an ObjectMapper bean.)
Parallelizable with Phase 1. Party (INDIVIDUAL/ORG), unified verification (KYC/KYB) + tiers,
PartyMembership (+ authorization API), PartyRelationship. Migrate KYC out of iam-service.
Bit 1 delivered + verified (2026-07-18): party-service module (:8093, party_db) +
Party aggregate (single-table, INDIVIDUAL + ORGANIZATION, @TenantId), create/get REST,
type↔profile validation, party.created outbox event, Flyway V1 (party) + V2 (outbox).
Live smoke test passed: created individual + organization parties, GET both, type/profile
mismatch → 422, and DB showed 2 parties (tenant_id auto-set) + 2 party.created outbox
rows.
Bit 3 delivered + verified (2026-07-18): PartyMembership (subject→party→role, @TenantId) +
authorization (ADR-0006): grant (idempotent per party+subject+role, emits
party.membership.granted), GET /parties/{id}/access?subject= (allowed + roles),
list-for-party, list-for-subject; V3 migration. Live smoke test passed: granted OWNER+VIEWER
to one subject and INITIATOR to another, idempotent re-grant, access → allowed+roles for a
member / denied for a non-member, 3 memberships + 3 granted events in DB (tenant-scoped).
Remaining party bits: verification (KYC/KYB) + tiers, PartyRelationship (directors/UBOs).
account-service CRUD keyed on partyId; template-seeded CoA + standard internal accounts
(ADR-0008); product definitions with posting rules; open-account creates a ledger account.
Replaces the ownerId = X-Subject placeholder.
Open-account integration delivered + verified (2026-07-21): POST /api/v1/accounts authorizes
the caller against the owning party (party-service access, OWNER/ADMIN only), creates the backing
ledger account (ledger-core), persists the Account keyed on partyId + ledgerAccountId
(placeholder gone), emits account.opened. First inter-service HTTP wiring (RestClient
clients forwarding X-Program-Id). Live 3-service E2E passed: authorized open → ledger account
exists (balance 0); non-member and VIEWER → 403; account row + event in DB, tenant-scoped.
Remaining: CoA/product definitions, account update/freeze/close/list, gRPC.
transfer-service (internal transfers), hold-service (place/capture/release/expire → held),
reversal flows.
Harden outbox→Kafka; audit-service (immutable trail), notification-service, webhook-service (delivery + retry). Reporting read models begin here.
fx-service (rate table, quotes, spread — ADR-0007); multicurrency accounts; cross-currency transfer as one multi-currency entry via FX position accounts.
Outbound rail-adapter port + simulated rail; PENDING→SETTLED/FAILED via async callbacks; suspense/clearing accounts; reconciliation.
scheduler-service (interest accrual/posting, fees, rewards; day-count/tiers); batch posting (payroll); limits/controls enforcement.
reporting-service: statements (opening/closing/running balance), GL reports, exports (PDF/CSV/JSON).
License issuance service + in-engine signed-license enforcement (capacity/modules/expiry, air-gapped); signed images + Helm; licensee-run upgrade/migration tooling.
Applied progressively once the posting path exists (after Phases 1 & 4), driven by load tests:
- Shard hot internal/GL accounts (settlement, FX position, fee income) into buckets to spread write contention — every transaction touches these, so they are the hotspot.
- Tuned / async snapshotting; posting-table partitioning (by account/tenant).
- Event-sourced append already avoids balance-row locking — lean into it (no update-in-place).
ledger-core first (spine, de-risks the hardest invariant early); party in parallel; accounts once both exist; then money movement; events/integration; then multicurrency, external rails, scheduling, reporting; licensing/packaging last. Each phase ships in small, tested increments.