Building Products That Solve Real Problems
A few fundamental lessons on identifying genuine friction, avoiding vanity complexity, and building technology products that endure.
A comprehensive architectural blueprint for engineering an enterprise-grade Core Banking System with microservices, immutable double-entry ledgers, Kafka event streaming, and regulatory compliance.
In consumer applications and social platforms, an eventual-consistency glitch, a dropped notification, or a slight UI delay is an acceptable tradeoff. In a Core Banking System (CBS), a single lost kobo, an unhandled race condition, or an unrecorded journal leg is an existential catastrophe.
A core banking system serves as the definitive financial brain of an institution: it orchestrates customer identities (CIF), deposits, loan amortization, card issuing, fraud analysis, and interbank settlement. It operates under a strict, unforgiving axiom: every state transition must be mathematically balanced, strictly idempotent, and permanently auditable.
When architecting an enterprise-grade Core Banking System designed for high concurrency, ACID consistency, and regulatory compliance (compliant with NDPA, tiered KYC, and NIBSS Instant Payments), we discarded conventional monolithic CRUD shortcuts in favor of clean Domain-Driven Design (DDD), immutable double-entry bookkeeping, and event-driven microservices.
Here is the architectural blueprint, data model, and engineering decisions behind designing a modern core banking platform.
A core banking engine cannot be a brittle monolith where a billing bug can corrupt general ledger balances. In this architecture, we separated domains across autonomous, independently deployable microservices—each owning its dedicated PostgreSQL database to guarantee database-level isolation.
┌───────────────────────────┐
│ Clients / Web / Mobile │
└─────────────┬─────────────┘
│
▼
┌───────────────────────────┐
│ API Gateway (Port 3000) │
│ • JWT Auth & Validation │
│ • Redis Rate Limiting │
│ • Sensitive Data Redact │
└─────────────┬─────────────┘
│
┌───────────────────┬─────────────┼───────────────────┬───────────────────┐
│ │ │ │ │
▼ ▼ ▼ ▼ ▼
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ IAM Service │ │ CIF Service │ │ Account Service │ │Transaction Svc │ │ Ledger Service │
│ (Port 3001) │ │ (Port 3002) │ │ (Port 3003) │ │ (Port 3004) │ │ (Port 3005) │
│ • RBAC & TOTP │ │ • KYC Tiers │ │ • Sole/Joint │ │ • ACID Transfers│ │ • Double Entry │
│ • JWT Tokens │ │ • BVN / RC Ver │ │ • Balance/Lien │ │ • NIBSS Rails │ │ • Trial Balance│
└────────┬────────┘ └────────┬────────┘ └────────┬────────┘ └────────┬────────┘ └────────┬────────┘
│ │ │ │ │
└───────────────────┴─────────────┼───────────────────┴───────────────────┘
│
▼
┌───────────────────────────┐
│ Apache Kafka Event Bus │
│ • Notifications • Auditing│
│ • Webhooks • Fraud Alerts │
└───────────────────────────┘
api-gateway): Enforces distributed rate limiting via Redis (rolling window per IP), token verification with IAM, and request/response payload redaction (scrubbing password, token, secret, apiKey).cif-service): Manages identity lifecycle, biometric links, National Identity (NIN), Corporate Affairs Commission (RC) validation, and Bank Verification Number (BVN) verification.account-service): Governs account numbers (NUBAN generation), product schemes (Savings, Current, Fixed Deposit, Domiciliary), account tiers (Tier 1–3), and status lifecycles (ACTIVE, DORMANT, FROZEN, CLOSED).transaction-service): Orchestrates fund movements, channel validations (ATM, POS, Web, Mobile, USSD), statutory deductions (7.5% VAT, stamp duty, transfer fees), and pessimistic row-locking pipelines.ledger-service): The immutable accounting heart. Maintains the Chart of Accounts, validates double-entry balance equations, records journals, and generates real-time trial balances.loan-service, card-service, fraud-detection-service, virtual-account-service, and audit-service.The most critical architectural rule in financial software engineering: balances are never arbitrary mutable variables—they are materialized projections of an immutable stream of balanced journal entries.
A robust core banking system implements a 5-category hierarchical Chart of Accounts:
| Range | Category | Normal Balance | Description & Control Structure |
|---|---|---|---|
1000–1999 | Assets | Debit | 1001 Cash in Vault, 1010 Central Bank Reserve, 1100 Customer Loans, 1201 NIBSS Settlement Clearing. |
2000–2999 | Liabilities | Credit | 2001 Customer Demand Deposits, 2002 Term Deposits, 2100 Suspense Clearing, 2200 Accrued Interest. |
3000–3999 | Equity | Credit | 3001 Share Capital, 3002 Statutory Reserves, 3003 Retained Earnings. |
4000–4999 | Revenue | Credit | 4001 Transaction Fee Income, 4002 Loan Interest Income, 4100 VAT Collected (FIRS). |
5000–5999 | Expenses | Debit | 5001 Switching & NIBSS Charges, 5002 Deposit Insurance (NDIC), 5003 Operating Costs. |
Every transaction posted to the General Ledger must satisfy the fundamental accounting equation:
The Fundamental Accounting Equation:
Σ Debits − Σ Credits = 0
(Total Debits must precisely equal Total Credits across all legs before any posting is committed)
// Core Double-Entry Validation in Ledger Service
export class LedgerService {
async createJournalEntry(dto: CreateJournalEntryDto): Promise<JournalEntry> {
const totalDebit = dto.lines
.filter((l) => l.entryType === EntryType.DEBIT)
.reduce((sum, l) => DecimalUtil.add(sum, l.amount), '0.00');
const totalCredit = dto.lines
.filter((l) => l.entryType === EntryType.CREDIT)
.reduce((sum, l) => DecimalUtil.add(sum, l.amount), '0.00');
// Strict balance check (precision to minor currency unit)
if (Math.abs(parseFloat(totalDebit) - parseFloat(totalCredit)) >= 0.01) {
throw new BadRequestException({
code: ErrorCode.ERR_JOURNAL_NOT_BALANCED,
message: `Journal entry out of balance. Total Debit: ${totalDebit}, Total Credit: ${totalCredit}`,
});
}
// Control accounts (e.g. 1100 Customer Accounts Control) cannot be posted to directly
for (const line of dto.lines) {
const glAccount = await this.glAccountRepository.findOne({
where: { accountCode: line.glAccountCode },
});
if (!glAccount) {
throw new NotFoundException(`GL Account ${line.glAccountCode} not found`);
}
if (glAccount.isControlAccount) {
throw new BadRequestException({
code: ErrorCode.ERR_CANNOT_POST_TO_CONTROL_ACCOUNT,
message: `Direct posting to control account ${glAccount.accountCode} (${glAccount.accountName}) is prohibited`,
});
}
}
// Persist atomically in transaction...
}
}
In core banking, entries are never deleted or updated. When an error or cancellation occurs, the system issues a formal Reversal Entry—a cryptographic mirror transaction that inverts debits and credits with an explicit reference to the original journal_entry_id and audit trail.
High-concurrency banking environments face the continuous threat of race conditions and double-spending. If a customer has ₦50,000 and two transfer requests of ₦50,000 arrive within milliseconds across different nodes, naive asynchronous code will process both.
SELECT ... FOR UPDATE)To ensure absolute serializability, the transaction engine wraps balance updates in database-level transactional locks:
// Transaction Execution with Row-Level Locking
export class TransactionService {
async processTransfer(dto: TransferDto): Promise<TransactionResult> {
const queryRunner = this.dataSource.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction();
try {
// 1. Idempotency Check: Prevent duplicate debit executions
const existingTx = await this.transactionRepository.findOne({
where: { transactionReference: dto.reference },
});
if (existingTx) {
await queryRunner.release();
return { status: 'EXISTING', transaction: existingTx };
}
// 2. Lock Source Account with SELECT FOR UPDATE
const sourceAccount = await queryRunner.manager
.createQueryBuilder(Account, 'account')
.setLock('pessimistic_write')
.where('account.id = :id', { id: dto.sourceAccountId })
.getOne();
if (!sourceAccount) {
throw new NotFoundException('Source account not found');
}
// 3. Compute Fees & Statutory Deductions
const transferAmount = DecimalUtil.format(dto.amount);
const isInternal = dto.destinationBankCode === '000'; // Intra-bank transfer
const fee = isInternal ? '10.75' : '26.88'; // NIP switching fee
const vat = DecimalUtil.multiply(fee, '0.075'); // 7.5% Nigerian VAT
const totalDebitRequired = DecimalUtil.add(transferAmount, DecimalUtil.add(fee, vat));
// 4. Validate Available Balance (Ledger Balance minus Active Liens/Holds)
const availableBalance = DecimalUtil.subtract(
sourceAccount.ledgerBalance,
sourceAccount.lienAmount || '0.00',
);
if (parseFloat(availableBalance) < parseFloat(totalDebitRequired)) {
throw new BadRequestException({
code: ErrorCode.ERR_INSUFFICIENT_FUNDS,
message: 'Insufficient available funds including fees and VAT',
});
}
// 5. Debit Source Account
const newSourceBalance = DecimalUtil.subtract(
sourceAccount.ledgerBalance,
totalDebitRequired,
);
await queryRunner.manager.update(Account, sourceAccount.id, {
ledgerBalance: newSourceBalance,
availableBalance: DecimalUtil.subtract(newSourceBalance, sourceAccount.lienAmount || '0.00'),
updatedAt: new Date(),
});
// 6. Record Transaction & Dispatch Kafka Event for Settlement & GL Posting
const transaction = queryRunner.manager.create(Transaction, {
transactionReference: dto.reference,
accountId: sourceAccount.id,
amount: transferAmount,
feeAmount: fee,
vatAmount: vat,
status: TransactionStatus.SUCCESSFUL,
channel: dto.channel,
});
await queryRunner.manager.save(transaction);
await queryRunner.commitTransaction();
// Emit Kafka Event for Asynchronous GL Posting & Customer SMS/Email Alerts
await this.messagingService.emit(KAFKA_TOPICS.TRANSACTION_EVENTS, {
event: TRANSACTION_EVENTS.FUNDS_TRANSFERRED,
data: transaction,
});
return { status: 'SUCCESS', transaction };
} catch (error) {
await queryRunner.rollbackTransaction();
throw error;
} finally {
await queryRunner.release();
}
}
}
Core banking platforms must enforce strict jurisdictional compliance limits. Under modern banking regulations (such as CBN tiered KYC and data protection mandates), the system implements three distinct account tiers:
┌─────────────────────────────────┐
│ Customer Identity (CIF) │
└────────────────┬────────────────┘
│
┌────────────────────────────────┼────────────────────────────────┐
▼ ▼ ▼
┌───────────────────┐ ┌───────────────────┐ ┌───────────────────┐
│ TIER 1 (Basic) │ │ TIER 2 (Medium) │ │ TIER 3 (Full) │
├───────────────────┤ ├───────────────────┤ ├───────────────────┤
│ • Phone & Name │ │ • BVN Validated │ │ • Proof of Address│
│ • Daily: ₦50,000 │ │ • Daily: ₦200,000 │ │ • Utility Bill │
│ • Max: ₦300,000 │ │ • Max: ₦500,000 │ │ • Unlimited Daily │
└───────────────────┘ └───────────────────┘ └───────────────────┘
When a transaction is initiated, the AccountService and CIFService evaluate:
A core banking platform is the primary target for credential stuffing, insider fraud, and MITM attacks. The platform implements defense-in-depth across every layer:
// Logging Interceptor with Sensitive Data Masking in API Gateway
export class LoggingInterceptor implements NestInterceptor {
private readonly SENSITIVE_KEYS = [
'password',
'pin',
'token',
'secret',
'apikey',
'authorization',
'bvn',
'nin',
'cvv',
'pan',
];
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
const request = context.switchToHttp().getRequest();
const startTime = Date.now();
// Sanitize payload before writing to log streams
const sanitizedBody = this.sanitize(request.body);
this.logger.log(`→ ${request.method} ${request.url} | User: ${request.user?.id || 'anonymous'}`);
return next.handle().pipe(
tap(() => {
const duration = Date.now() - startTime;
this.logger.log(`← ${request.method} ${request.url} | Status: 200 | Duration: ${duration}ms`);
}),
);
}
private sanitize(obj: any): any {
if (!obj || typeof obj !== 'object') return obj;
const sanitized = { ...obj };
for (const key of Object.keys(sanitized)) {
if (this.SENSITIVE_KEYS.some((s) => key.toLowerCase().includes(s))) {
sanitized[key] = '***REDACTED***';
} else if (typeof sanitized[key] === 'object') {
sanitized[key] = this.sanitize(sanitized[key]);
}
}
return sanitized;
}
}
TELLER_POST, MANAGER_OVERRIDE, AUDIT_READ, TREASURY_TRANSFER) with dual-control (Maker-Checker) requirements on high-value transfers.fraud-detection-service scores transactions against velocity spikes, atypical geo-coordinates, and high-frequency nocturnal transfers before the transaction manager commits.Architecting a modern core banking platform reinforces fundamental principles that every software engineer working in financial technology must master:
FLOAT, DOUBLE) introduces catastrophic binary rounding errors. Always utilize arbitrary-precision decimals or 64-bit integer minor currency units.Core banking architecture is software engineering at its most demanding and rewarding. It does not reward decorative trends; it rewards mathematical discipline, clean domain boundaries, and paranoid fault-tolerance.
Combining NestJS microservices, PostgreSQL ACID isolation, double-entry ledgering, and Kafka event streaming creates a durable, scalable foundation capable of powering millions of transactions across modern financial ecosystems with absolute precision.
WRITTEN BY
I build technology products, explore artificial intelligence, and work on ideas that solve meaningful problems.
A few fundamental lessons on identifying genuine friction, avoiding vanity complexity, and building technology products that endure.
Reflections on multi-tenant architecture, pricing models, database integrity, and operational simplicity in SaaS development.