VICTOR YUNUSA
Back to Writing
FintechArchitectureTechnologyStartups

Designing Core Banking Applications: Architecture, Ledgers & Distributed Systems

A comprehensive architectural blueprint for engineering an enterprise-grade Core Banking System with microservices, immutable double-entry ledgers, Kafka event streaming, and regulatory compliance.

By Victor Yunusa··10 min read

The High-Stakes Reality of Core Banking Systems

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.


1. Domain Decomposition & Microservice Topology

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 │
                                 └───────────────────────────┘

Core Service Responsibilities

  1. API Gateway (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).
  2. Customer Information File (cif-service): Manages identity lifecycle, biometric links, National Identity (NIN), Corporate Affairs Commission (RC) validation, and Bank Verification Number (BVN) verification.
  3. Account Service (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).
  4. Transaction Service (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.
  5. General Ledger Service (ledger-service): The immutable accounting heart. Maintains the Chart of Accounts, validates double-entry balance equations, records journals, and generates real-time trial balances.
  6. Support Microservices: Specialized services including loan-service, card-service, fraud-detection-service, virtual-account-service, and audit-service.

2. The General Ledger: Immutable Double-Entry Foundation

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.

Standard Chart of Accounts (COA)

A robust core banking system implements a 5-category hierarchical Chart of Accounts:

RangeCategoryNormal BalanceDescription & Control Structure
1000–1999AssetsDebit1001 Cash in Vault, 1010 Central Bank Reserve, 1100 Customer Loans, 1201 NIBSS Settlement Clearing.
2000–2999LiabilitiesCredit2001 Customer Demand Deposits, 2002 Term Deposits, 2100 Suspense Clearing, 2200 Accrued Interest.
3000–3999EquityCredit3001 Share Capital, 3002 Statutory Reserves, 3003 Retained Earnings.
4000–4999RevenueCredit4001 Transaction Fee Income, 4002 Loan Interest Income, 4100 VAT Collected (FIRS).
5000–5999ExpensesDebit5001 Switching & NIBSS Charges, 5002 Deposit Insurance (NDIC), 5003 Operating Costs.

The Invariant: Zero-Sum Journal Posting

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...
  }
}

Automated Reversals

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.


3. The Transaction Engine: Concurrency, Locks & Idempotency

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.

Pessimistic Row Locking (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();
    }
  }
}

4. Regulatory Compliance & Tiered KYC Architecture

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:

  1. Cumulative Daily Outflow: Aggregated via Redis counters for sub-second verification.
  2. Maximum Cumulative Balance Thresholds: Preventing deposits that exceed Tier 1 or Tier 2 caps without immediate KYC step-up prompts.
  3. Lien Management: Freezing specific sums for active loan collateral, pending merchant pre-authorizations, or court-mandated restrictions without freezing the entire account.

5. Security Architecture & Threat Mitigation

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;
  }
}
  1. Role-Based Access Control (RBAC): Fine-grained permissions (TELLER_POST, MANAGER_OVERRIDE, AUDIT_READ, TREASURY_TRANSFER) with dual-control (Maker-Checker) requirements on high-value transfers.
  2. Mandatory TOTP & Device Binding: Step-up multi-factor authentication on all administrative and transaction-triggering endpoints.
  3. Real-Time Fraud Engine: The fraud-detection-service scores transactions against velocity spikes, atypical geo-coordinates, and high-frequency nocturnal transfers before the transaction manager commits.

6. Key Takeaways for Financial Systems Architects

Architecting a modern core banking platform reinforces fundamental principles that every software engineer working in financial technology must master:

  1. Reject Mutable Column Balances: Always build on an immutable double-entry ledger where balances are computable projections.
  2. Never Use Floating-Point Types: IEEE 754 floating-point math (FLOAT, DOUBLE) introduces catastrophic binary rounding errors. Always utilize arbitrary-precision decimals or 64-bit integer minor currency units.
  3. Isolate Microservice Databases: Shared databases between banking microservices create hidden coupling and bypass domain invariants. Enforce strict database-per-service boundaries.
  4. Idempotency is Non-Negotiable: Network disconnects and client retry storms are guaranteed. Every mutation must accept and validate a unique idempotency key.
  5. Real-Time Trial Balances are Your Ultimate Health Metric: An automated health check that runs trial balance reconciliations continuously ensures your ledger never drifts.

Conclusion

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.

Share this article

WRITTEN BY

Victor Yunusa

I build technology products, explore artificial intelligence, and work on ideas that solve meaningful problems.

MORE WRITING

All Articles →