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 (+ nomixed, via custom Pest expectations intests/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'sHttp/Livewireare private), and structural tenant-isolation guards (centralUser/Tenantnever useFillsCurrentTenant; tenant-scoped models use UUID keys). 14 arch assertions. tenants:verify-isolationcommand (Tenancy module) — computes the intended scope set fromTableRLSManager::generateQueries()and fails if any such table lacks an ENABLED+FORCEDpg_policiespolicy. Runs indev-start-app.sh(non-fatal) and should be a hard gate in CI/deploy aftermigrate --force && tenants:rls. Covered byRlsCoverageTest(passes when isolated; fails on drift).
Conventions captured (apply as modules grow):
- Per-module internal layout (internachi puts classes under
src/, androutes/ 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\Contractsinterfaces (bound in the module provider), never another module'sServices/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 singleCore/Enumsgod-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 bus → recommend 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 contract → user-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-moduleResource/Request/Dataclasses. 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, notenant_id) +journal_entries,journal_entry_lines,ledger_entries,accounts,balance_holds,accounting_periods(TENANT-scoped viatenant_idFK → auto-RLS). Money is a positive BIGINT in minor units (never float/decimal); direction is aDEBIT/CREDITcolumn. - 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 holdsmaintenance. - A single
LedgerServicewrite path (validate-in-PHP belt + post-in-transaction withlockForUpdate()+ reverse-by-posting-opposite-legs, never mutate posted entries). CurrencyCode/CurrencyType/DebitCredit/ChartAccountTypeenums (encode decimal places, fiat/crypto/stablecoin, normal-balance rules); idempotency-key on financial mutations; aFinancialIntegrityTestcorrectness 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 totenant_id+ stancl auto-RLS.