Skip to main content

D21 — Inspiration mined from the legacy stablesxyz/core repo

Architecture decision record, relocated verbatim from the retired single-file docs/tenancy/ decision log. Status, thematic clusters, and how to record a new ADR: the decision log index.

Studied the prior (non-working) modular neobank stablesxyz/core — a hand-rolled Stables\ modular monolith (no internachi/modular), CQRS, JSON:API + Scramble, custom tenant isolation (no stancl), headless (no Livewire/Fortify), 18 domain modules. We mine patterns, not the stack. (6-agent study; full notes in the run transcript.)

Adopted now (done, green):

  • Architecture tests (tests/Architecture/, own PHPUnit suite): strict_types everywhere, return types on every method (+ no mixed, via custom Pest expectations in tests/Pest.php), module boundaries (Foundation is the shared kernel and depends on no module; Tenancy depends on nothing higher; Backoffice is a leaf; a module's Http/Livewire are private), and structural tenant-isolation guards (central User/Tenant never use FillsCurrentTenant; tenant-scoped models use UUID keys). 14 arch assertions.
  • tenants:verify-isolation command (Tenancy module) — computes the intended scope set from TableRLSManager::generateQueries() and fails if any such table lacks an ENABLED+FORCED pg_policies policy. Runs in dev-start-app.sh (non-fatal) and should be a hard gate in CI/deploy after migrate --force && tenants:rls. Covered by RlsCoverageTest (passes when isolated; fails on drift).

Conventions captured (apply as modules grow):

  • Per-module internal layout (internachi puts classes under src/, and routes/ tests/ database/ as siblings): src/Http/{Controllers,Requests,Resources,Middleware}, src/Models, src/Actions, src/Services, src/DataObjects, src/Enums, src/Providers; routes/*.php, tests/{Feature,Unit}, database/{migrations,factories,seeders}.
  • Business logic in invokable Action classes (matching Modules\Identity\…\Actions), input via spatie/laravel-data DTOs. NOT CQRS — see decision below.
  • Cross-module calls go through Modules\<M>\Integration\Contracts interfaces (bound in the module provider), never another module's Services/internals. Add the matching arch rule (->expect('Modules\A\Services')->not->toUse('Modules\B\…') + contracts ->toBeInterfaces()) when the first cross-module dependency appears.
  • Enums live in their owning module (Modules\<M>\…\Enums); only genuinely shared vocabulary (e.g. CurrencyCode) goes in Foundation. (The reference's single Core/Enums god-namespace is an anti-pattern we avoid.)

Deliberately skipped: the hand-rolled ModuleServiceProvider/bootstrap/providers.php registration (internachi auto-discovers), the reference .env.testing (we use tests/bootstrap.php), the custom Scramble type extensions, the UsesCentralConnection attribute

  • manual per-table policy migrations (we use stancl auto-RLS), and the audit hash-chain (defer to a future Audit module).

Decisions for you (not actioned):

  • CQRS busrecommend SKIP. The reference's Command/Query/Handler/Bus indirection is powerful but heavy; for a from-scratch neobank, invokable Actions + spatie-data DTOs give the same testability with far less ceremony and match our existing conventions. Revisit only if a command bus's cross-cutting hooks (audit, authorization) become compelling.
  • API response contractuser-decision. The reference uses a JSON:API envelope ({data, meta, jsonapi} + {errors[]}). Recommended light adoption when the first real tenant API resource lands: a Foundation {data, meta:{request_id}} envelope + a JSON:API-style error handler + per-module Resource/Request/Data classes. Decide JSON:API {errors[]} vs Laravel default {message, errors{}} for the Sanctum SPA then.

Domain roadmap (the reference's 18 modules → our future modules): on top of today's Foundation/Tenancy/Identity/Backoffice, anticipate Payment (double-entry ledger), Billing, Crypto, Fee, Dispute, Provider (banking-rails adapters via Saloon), Policy, Approval, Audit, Notification, Connection, Report, Admin, Developer. Each is a module owning its routes/tests/migrations and exposing an Integration\Contracts surface.

Payment-module blueprint (highest-value forward-looking inspiration — capture when built):

  • Double-entry ledger: chart_of_accounts (CENTRAL reference table, no tenant_id) + journal_entries, journal_entry_lines, ledger_entries, accounts, balance_holds, accounting_periods (TENANT-scoped via tenant_id FK → auto-RLS). Money is a positive BIGINT in minor units (never float/decimal); direction is a DEBIT/CREDIT column.
  • Postgres invariant triggers as DB-layer integrity (complements our RLS-at-the-DB stance): a DEFERRABLE-INITIALLY-DEFERRED constraint trigger that rejects an unbalanced journal at COMMIT (per currency), immutability of POSTED lines, closed-period guard, and available_balance = balance − active holds maintenance.
  • A single LedgerService write path (validate-in-PHP belt + post-in-transaction with lockForUpdate() + reverse-by-posting-opposite-legs, never mutate posted entries).
  • CurrencyCode/CurrencyType/DebitCredit/ChartAccountType enums (encode decimal places, fiat/crypto/stablecoin, normal-balance rules); idempotency-key on financial mutations; a FinancialIntegrityTest correctness gate (balance, reversal restores to zero, trial balance nets to zero, hold prevents overdraft). Adapted migration/enum/test code is in the study transcript, re-keyed to tenant_id + stancl auto-RLS.

← Decision log index