Legacy core banking systems were designed in a different era. Temenos T24, Finacle, and Flexcube shared one defining assumption: the bank’s entire product catalogue — deposits, lending, payments, trade finance — would live inside a single, tightly coupled application and a single, shared database. That assumption held when banking moved at human speed. It breaks completely when product releases need to go from months to days, when a single fraud engine update must not risk a payments outage, and when engineers on a COBOL codebase are retiring faster than they can be replaced.

Composable banking replaces that monolith with a network of independent, purpose-built service components. This post is a deep engineering guide to what that actually means in Go microservices terms: ledger concurrency patterns, event-driven Saga orchestration, BaaS API idempotency, ISO 20022 message flows, and a step-by-step Strangler Fig migration strategy.

For the foundational Saga mechanics in Go, see Dapr Workflow Saga Orchestration Guide and Financial Microservices Architecture: Saga & Ledger.


What Is Composable Banking Architecture?

Composable banking is a software design approach that replaces a single-unit core banking system with a network of independent, swappable Packaged Business Capabilities (PBCs). Based on MACH principles (Microservices, API-first, Cloud-native, Headless), it lets a financial institution replace the payment engine without touching the lending module, or launch an embedded finance product line without rebuilding the core ledger.

The reference stack has four layers:

graph TD
    EXP["Experience & Channel Layer\n(Mobile, Web, Contact Center, Partner APIs)"]
    ORCH["Integration & Orchestration Layer\n(API Gateway, Kafka, Temporal, BFF)"]
    PBC["Packaged Business Capabilities Layer\n(Ledger, Payments, Lending, KYC, Cards)"]
    INFRA["Cloud Infrastructure Layer\n(Kubernetes, CockroachDB / Spanner, Observability)"]

    EXP --> ORCH
    ORCH --> PBC
    PBC --> INFRA

Each PBC owns its domain completely — its own code repository, its own database, its own deployment pipeline. No shared database. No synchronous cross-domain coupling in the transaction critical path.

BIAN Service Domains and DDD Aggregate Boundaries

The BIAN (Banking Industry Architecture Network) defines ~330 Service Domains — atomic business capabilities like Payment Execution, Current Account, and Customer Agreement. BIAN provides the “what”: a standardized dictionary of capabilities so different vendors and in-house services can interoperate semantically.

Domain-Driven Design (DDD) provides the “how”: each BIAN Service Domain maps to one or more DDD Bounded Contexts, with Aggregates protecting transactional consistency within their boundaries. A Payment Execution domain maps cleanly to an Payment Aggregate that owns the state machine from INITIATED through SETTLED. No other service writes to that state machine directly — it publishes domain events that other contexts react to asynchronously.


The Business Case: Why Legacy Cores Are Breaking Down in 2026

Legacy monolith maintenance now consumes 70-80% of bank IT budgets on average, leaving little capital for innovation. Several converging pressures have turned this from a long-term concern into an immediate operational risk.

Cost indicators driving migration decisions:

  • TCO reduction: Banks completing composable modernization report 20-40% lower Total Cost of Ownership over three years, primarily from eliminating vendor licensing fees and reducing SRE toil.
  • Time-to-market acceleration: Product release cycles improve by 40-60%. Launching a new credit card product drops from 12-18 months to 6-10 weeks.
  • Talent risk: COBOL engineers command 2-3× market salary premiums due to supply scarcity, and attrition accelerates as the workforce ages. Each legacy developer who leaves takes institutional knowledge that cannot be replaced.
  • Security posture: Monolithic systems show a 50% higher surface area for security breaches compared to isolated, network-policy-governed microservices, due to the blast radius of any single compromised component.

The business case is no longer “should we modernize?” It is “what sequence minimizes migration risk?”


Scaling the Core Ledger: Optimistic Locking vs. NewSQL

In a monolithic banking system, financial ledgers rely on pessimistic locking (SELECT FOR UPDATE) to guarantee transaction consistency. Every balance update acquires an exclusive row lock, blocking all concurrent writes to the same account until the transaction commits. At low transaction volumes this is fine. On a hot-spot account — a high-volume merchant account, a shared treasury account, a sweep account receiving thousands of micro-deposits per second — it becomes a write serialization bottleneck that saturates the database.

Composable architectures solve this at two levels:

Level 1: Optimistic Locking on the Account Row

For moderate concurrency, replace the row lock with a version column. The write succeeds only if the version matches what was read; otherwise the application retries.

-- Account table with optimistic concurrency
CREATE TABLE accounts (
    id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    tenant_id   VARCHAR(50) NOT NULL,
    balance     NUMERIC(18, 4) NOT NULL DEFAULT 0,
    version     INT NOT NULL DEFAULT 1,
    updated_at  TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);

-- Optimistic balance update: fails with 0 rows affected if version mismatches
UPDATE accounts
SET    balance    = balance + $1,
       version    = version + 1,
       updated_at = CURRENT_TIMESTAMP
WHERE  id         = $2
AND    version    = $3;  -- stale read returns 0 rows → application retries

In Go, a retry loop with exponential backoff handles the version conflict:

func (r *AccountRepo) UpdateBalance(ctx context.Context, id uuid.UUID, delta decimal.Decimal, version int) error {
    const maxRetries = 5
    for attempt := 0; attempt < maxRetries; attempt++ {
        result := r.db.ExecContext(ctx,
            `UPDATE accounts SET balance = balance + $1, version = version + 1, updated_at = NOW()
             WHERE id = $2 AND version = $3`,
            delta, id, version,
        )
        if result.RowsAffected == 1 {
            return nil // success
        }
        // Version conflict — re-read and retry
        current, err := r.Get(ctx, id)
        if err != nil {
            return err
        }
        version = current.Version
        time.Sleep(time.Duration(attempt*attempt) * 10 * time.Millisecond) // quadratic backoff
    }
    return ErrVersionConflict
}

Level 2: Append-Only Ledger with Balance Snapshots

For very high-throughput accounts, eliminate balance mutations entirely. Every credit or debit appends a signed delta to an immutable ledger_entries table. The current balance is derived by summing all entries, which is accelerated by periodically materializing a balance_snapshots row.

-- Append-only ledger entries (never UPDATE or DELETE)
CREATE TABLE ledger_entries (
    id             UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    account_id     UUID NOT NULL,
    transaction_id UUID NOT NULL,
    amount         NUMERIC(18, 4) NOT NULL,  -- positive = credit, negative = debit
    created_at     TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_ledger_account_time ON ledger_entries (account_id, created_at DESC);

-- Balance snapshot for fast reads on high-volume accounts
CREATE TABLE balance_snapshots (
    account_id      UUID PRIMARY KEY,
    balance         NUMERIC(18, 4) NOT NULL,
    snapshot_at     TIMESTAMPTZ NOT NULL
);

-- Current balance query (snapshot + deltas since snapshot)
SELECT s.balance + COALESCE(SUM(e.amount), 0) AS current_balance
FROM   balance_snapshots s
LEFT JOIN ledger_entries e
       ON e.account_id = s.account_id
      AND e.created_at > s.snapshot_at
WHERE  s.account_id = $1
GROUP BY s.balance;

Level 3: Distributed NewSQL for Multi-Region Scale

When neither approach is sufficient — multi-region active-active deployments, regulatory data sovereignty across jurisdictions — the ledger migrates to a distributed SQL database:

DatabaseConsistency ModelUse Case
CockroachDBSerializable isolation by defaultMulti-region, prevents write-skew without application-level coordination
Google Cloud SpannerExternal consistency via TrueTime API + PaxosGlobal banks requiring strict linearizability at planetary scale
YugabyteDBRead Committed (adjustable) + Postgres wire protocolTeams migrating from Postgres who need scale-out without rewriting queries

CockroachDB’s native serializable isolation is particularly relevant: by default, it prevents write-skew (a class of anomaly that Postgres’s default Read Committed isolation allows), which means financial ledger invariants hold without explicit FOR UPDATE locks.


Event-Driven Orchestration: Sagas, Temporal, and the Outbox Pattern

Composable banking microservices each own their own database. A fund transfer spanning a debit service, a fraud check service, and a credit service cannot rely on a single database transaction. The Saga pattern solves this with a sequence of local transactions, where each step has a corresponding compensating transaction that undoes its effect if a later step fails.

Orchestration vs. Choreography for Financial Flows

Choreography (services reacting to events with no central coordinator) works for simple, low-step flows. For banking, orchestration is mandatory:

DimensionChoreographyOrchestration (Temporal / Dapr)
Flow visibilityDistributed across event logsCentralized in one workflow function
Compensation logicEach service implements its ownOrchestrator manages in sequence
Audit trailRequires multi-topic correlationSingle durable execution history
DebuggingEvent tracing across 5+ topicsSingle workflow history query
Regulatory requirementHard to satisfyClear state at every step (PENDING → SETTLED)

Temporal Workflow for a Fund Transfer Saga

Temporal persists the full execution history of every workflow run in its internal database. If a worker crashes mid-transfer, Temporal replays the event log from the last checkpoint — completed activities return their cached results without re-executing, and execution resumes from the interrupted step.

// FundTransferWorkflow is the Saga orchestrator — MUST be deterministic.
// No time.Now(), no rand, no environment reads inside this function.
func FundTransferWorkflow(ctx workflow.Context, input FundTransferInput) (FundTransferResult, error) {
    ao := workflow.ActivityOptions{
        StartToCloseTimeout: 30 * time.Second,
        RetryPolicy: &temporal.RetryPolicy{
            MaximumAttempts:    3,
            InitialInterval:    time.Second,
            BackoffCoefficient: 2.0,
        },
    }
    ctx = workflow.WithActivityOptions(ctx, ao)

    // Step 1: Debit source account
    var debitResult DebitResult
    if err := workflow.ExecuteActivity(ctx, DebitSourceAccount, input).Get(ctx, &debitResult); err != nil {
        return FundTransferResult{}, fmt.Errorf("debit failed: %w", err)
    }

    // Step 2: Fraud check
    var fraudResult FraudCheckResult
    if err := workflow.ExecuteActivity(ctx, CheckFraud, input).Get(ctx, &fraudResult); err != nil || fraudResult.Flagged {
        // Compensate: reverse the debit
        _ = workflow.ExecuteActivity(ctx, ReverseDebit, debitResult).Get(ctx, nil)
        return FundTransferResult{}, ErrFraudFlagged
    }

    // Step 3: Credit target account
    var creditResult CreditResult
    if err := workflow.ExecuteActivity(ctx, CreditTargetAccount, input).Get(ctx, &creditResult); err != nil {
        // Compensate: reverse the debit
        _ = workflow.ExecuteActivity(ctx, ReverseDebit, debitResult).Get(ctx, nil)
        return FundTransferResult{}, fmt.Errorf("credit failed: %w", err)
    }

    return FundTransferResult{
        TransactionID: input.TransactionID,
        Status:        "SETTLED",
        SettledAt:     workflow.Now(ctx), // deterministic time from Temporal runtime
    }, nil
}

The Transactional Outbox: Preventing Data Drift

After a local database write, the service must also publish an event to Kafka so downstream services react. Writing to both the database and Kafka in the same operation is the Dual-Write problem: if Kafka is unavailable after the database write succeeds, the event is lost and the Saga stalls with no way to detect the gap.

The Transactional Outbox eliminates the problem:

-- Outbox table lives in the same database as the domain table
CREATE TABLE outbox_events (
    id           UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    aggregate_id UUID NOT NULL,
    event_type   VARCHAR(100) NOT NULL,
    payload      JSONB NOT NULL,
    published    BOOLEAN NOT NULL DEFAULT FALSE,
    created_at   TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
// In a single database transaction: write business data + event payload atomically
func (s *AccountService) DebitAccount(ctx context.Context, tx *sql.Tx, input DebitInput) error {
    // 1. Update the account balance
    _, err := tx.ExecContext(ctx,
        `UPDATE accounts SET balance = balance - $1 WHERE id = $2`, input.Amount, input.AccountID)
    if err != nil {
        return err
    }

    // 2. Insert the outbox event — same transaction, same commit
    payload, _ := json.Marshal(AccountDebitedEvent{
        AccountID: input.AccountID,
        Amount:    input.Amount,
        TxID:      input.TransactionID,
    })
    _, err = tx.ExecContext(ctx,
        `INSERT INTO outbox_events (aggregate_id, event_type, payload) VALUES ($1, $2, $3)`,
        input.AccountID, "account.debited", payload)
    return err
}

Debezium reads the PostgreSQL WAL and forwards every outbox_events INSERT directly to Kafka, with zero polling overhead and guaranteed at-least-once delivery. Even if Kafka is down during the database commit, Debezium will forward the event once Kafka recovers.

For a complete walkthrough of the broader event-driven patterns powering this approach, see Mastering Event-Driven Architecture with Dapr. For the observability layer across these banking microservices — W3C trace propagation, OTel Collector sampling, and tracing Kafka consumers — see Go Microservices Distributed Tracing Architecture.


BaaS API Design: Idempotency and ISO 20022 Integration

Banking-as-a-Service APIs are consumed by fintechs over unreliable networks. A payment initiation request that times out at the client side may have already been processed server-side. The client retries and — without proper protection — the customer is charged twice.

Idempotency Key Implementation in Go

Every state-mutating BaaS endpoint must accept a client-generated idempotency key (UUID v4) and store a hash of it alongside the processing result:

// idempotencyMiddleware checks for duplicate requests before processing.
// The key_hash has a UNIQUE constraint in the database.
func (h *PaymentHandler) InitiateTransfer(w http.ResponseWriter, r *http.Request) {
    idempotencyKey := r.Header.Get("Idempotency-Key")
    if idempotencyKey == "" {
        http.Error(w, "Idempotency-Key header required", http.StatusBadRequest)
        return
    }

    // SHA-256 hash prevents key enumeration attacks
    hash := sha256.Sum256([]byte(r.Header.Get("X-Client-ID") + ":" + idempotencyKey))
    keyHash := hex.EncodeToString(hash[:])

    // Atomic insert: if key_hash already exists, ON CONFLICT returns the stored status
    var status string
    err := h.db.QueryRowContext(r.Context(), `
        INSERT INTO idempotency_keys (key_hash, status, created_at)
        VALUES ($1, 'PROCESSING', NOW())
        ON CONFLICT (key_hash) DO UPDATE SET updated_at = NOW()
        RETURNING status`,
        keyHash,
    ).Scan(&status)
    if err != nil {
        http.Error(w, "database error", http.StatusInternalServerError)
        return
    }

    if status != "PROCESSING" {
        // Duplicate request: return the previously cached result
        h.returnCachedResult(w, keyHash)
        return
    }

    // Process the new payment and update the idempotency record
    h.processPaymentAndUpdateKey(w, r, keyHash)
}

ISO 20022 Message Flow

BaaS payment APIs map to ISO 20022 XML messages at the interbank layer. Understanding this mapping is essential when debugging payment failures at the settlement layer:

MessageBusiness AreaFunctionTriggered When
pain.001Payment InitiationCustomer instructs bank to payFintech API calls /v1/payments
pacs.008Clearing & SettlementBank-to-bank credit transferBank forwards to correspondent
pacs.002Clearing & SettlementPayment status reportCorrespondent sends ACCP/RJCT/PDNG
camt.052Cash ManagementIntraday account reportTreasury monitors intraday cash
camt.053Cash ManagementEnd-of-day bank statementReconciliation runs at T+0 close

The pain.001pacs.008 transformation is a critical data mapping step: the pain message carries debtor intent (what the customer wants), while the pacs message carries the actual interbank instruction. Improper mapping — dropping reference fields like EndToEndId — breaks downstream reconciliation and SEPA compliance.

Verification of Payee (VoP) API Integration

Under the EU Instant Payments Regulation, SEPA PSPs must verify payee identity before executing credit transfers. The EPC VoP Inter-PSP API returns one of four matching codes:

CodeMeaningAction
MTCHName matches IBAN holderProceed with payment
NMTCName does not matchWarn user; require explicit confirmation
CMTCClose match (e.g., typo, nickname)Show the actual registered name; user confirms
NOAPVerification not applicableContinue; flag for manual review

The CMTC response is the most operationally complex: the API returns the actual registered name alongside the close-match indicator. Your UI must present this name clearly so the payer can confirm they are paying the right person — this is the primary fraud-prevention mechanism.


Security Gates: RFC 8705 mTLS and DORA Compliance

RFC 8705: Certificate-Bound Access Tokens

Standard OAuth 2.0 Bearer tokens can be stolen and replayed from a different client. The Financial-grade API (FAPI) 1.0 Advanced profile addresses this with RFC 8705 Mutual-TLS Client Certificate-Bound Access Tokens: the access token is cryptographically bound to the client’s X.509 certificate, making stolen tokens useless without the corresponding private key.

The validation flow:

sequenceDiagram
    participant C as Fintech Client
    participant GW as API Gateway
    participant AS as Authorization Server

    C->>AS: mTLS handshake + Client Credentials Grant
    AS->>AS: Bind token to client cert SHA-256 thumbprint
    AS-->>C: access_token { cnf: { x5t#S256: "<thumbprint>" } }

    C->>GW: POST /payments (Bearer token + mTLS connection)
    GW->>GW: Extract client cert from TLS session
    GW->>GW: Compute SHA-256(cert) → compare to cnf claim
    alt Thumbprints match
        GW->>GW: Forward request to Payment Service
    else Mismatch (stolen token)
        GW-->>C: 401 Unauthorized
    end

For mobile and SPA clients where establishing mTLS is architecturally impractical, DPoP (RFC 9449) provides an equivalent proof-of-possession guarantee at the application layer: the client signs each request with a private key, and the server verifies that the signed request matches the DPoP public key bound in the access token.

DORA: Threat-Led Penetration Testing

The Digital Operational Resilience Act (DORA, enforceable from January 17, 2025) requires significant financial institutions to conduct Threat-Led Penetration Testing (TLPT) at least every three years. TLPT is not a standard pen test: it mimics real-world adversary TTPs (Tactics, Techniques, Procedures), typically following the TIBER-EU framework, and covers all critical ICT functions including outsourced cloud infrastructure.

A single TLPT exercise spans 6-12 months including scoping, red team execution, and remediation. For composable banking systems, TLPT scope must include the API Gateway, the event bus (Kafka), the orchestration layer (Temporal/Dapr), and every critical PBC. Third-party SaaS vendors (core banking platforms, cloud providers) are in scope if they support critical functions.


The Strangler Fig Migration: De-Risking Core Modernization

The highest-risk core banking transformation is the “Big Bang” cutover: freeze the legacy system, build the new platform in parallel, and switch everything on a single date. This approach fails consistently because the new system’s edge cases are discovered only under production load, after the rollback window has closed.

The Strangler Fig pattern eliminates this risk with incremental domain extraction:

graph LR
    CLIENT["Client Requests"] --> GW["API Gateway\n(Kong / Apigee)"]
    GW -->|"New domain requests"| NEW["Modern PBCs\n(Go microservices)"]
    GW -->|"Legacy domain requests"| ACL["Anti-Corruption Layer"]
    ACL --> LEGACY["Legacy Core\n(Temenos T24 / Finacle)"]
    NEW --> KAFKA["Kafka Event Bus"]
    LEGACY --> KAFKA
    KAFKA --> RECON["Reconciliation Engine"]

Phase 1: Place the Gateway and Anti-Corruption Layer

Before migrating any domain, position an API Gateway in front of the legacy core. All traffic now flows through the gateway, which routes 100% of requests to the legacy system. The gateway becomes the control plane for traffic shifting.

The Anti-Corruption Layer (ACL) lives between the gateway and the legacy system. It translates between the clean domain model of the new PBCs and the legacy system’s proprietary data model:

// ACL translates a modern PaymentRequest into the legacy T24 transaction format
type T24ACL struct {
    legacyClient *T24Client
}

func (a *T24ACL) InitiatePayment(ctx context.Context, req domain.PaymentRequest) (domain.PaymentResult, error) {
    // Translate modern domain model → legacy T24 format
    t24Req := T24PaymentRequest{
        FTNO:    req.TransactionID,
        DEBIT:   req.SourceAccount.T24AccountID,
        CREDIT:  req.TargetAccount.T24AccountID,
        AMT:     req.Amount.String(),
        CCY:     req.Currency.ISO4217(),
        VDATE:   req.ValueDate.Format("020106"), // T24 date format
    }

    t24Resp, err := a.legacyClient.PostTransaction(ctx, t24Req)
    if err != nil {
        return domain.PaymentResult{}, translateT24Error(err)
    }

    // Translate legacy response → clean domain model
    return domain.PaymentResult{
        TransactionID: req.TransactionID,
        Status:        mapT24Status(t24Resp.Status),
        CompletedAt:   parseT24Date(t24Resp.PostingDate),
    }, nil
}

Phase 2: Shadow Routing for Risk-Free Validation

Before switching live traffic to a new PBC, deploy it in shadow mode. Istio’s traffic mirroring sends a copy of every request to the new service in parallel with the live request to the legacy system:

apiVersion: networking.istio.io/v1alpha3
kind: VirtualService
metadata:
  name: payment-service
spec:
  hosts: ["payment-service"]
  http:
  - route:
    - destination:
        host: payment-service-legacy
        port:
          number: 8080
      weight: 100
    mirror:
      host: payment-service-new        # shadow receives copy of every request
      port:
        number: 8080
    mirrorPercentage:
      value: 100.0                      # mirror 100% of traffic

The shadow responses are logged but discarded — users are served from the legacy system. The reconciliation engine compares shadow responses against legacy responses and alerts on any divergence. Only when the divergence rate drops to zero for 30+ days does traffic shift begin.

Phase 3: Reconciliation Loops for Data Parity

During the dual-run period, a continuous reconciliation engine validates that account balances and transaction history match between the legacy core database and the new PBC databases:

func (r *ReconciliationEngine) RunBalanceCheck(ctx context.Context) error {
    accounts, err := r.listActiveAccounts(ctx)
    if err != nil {
        return err
    }

    var discrepancies []Discrepancy
    for _, account := range accounts {
        legacyBalance, err := r.legacyClient.GetBalance(ctx, account.LegacyID)
        if err != nil {
            continue
        }
        modernBalance, err := r.modernLedger.GetBalance(ctx, account.ModernID)
        if err != nil {
            continue
        }

        if legacyBalance.Amount.Compare(modernBalance.Amount) != 0 {
            discrepancies = append(discrepancies, Discrepancy{
                AccountID:     account.ID,
                LegacyBalance: legacyBalance.Amount,
                ModernBalance: modernBalance.Amount,
                Delta:         legacyBalance.Amount.Sub(modernBalance.Amount),
                DetectedAt:    time.Now(),
            })
        }
    }

    if len(discrepancies) > 0 {
        r.alertOps(ctx, discrepancies)
        return r.storeDiscrepancies(ctx, discrepancies)
    }
    return nil
}

Next-Gen Core Banking Vendor Landscape

For teams evaluating off-the-shelf composable cores before building in-house, the microfinance vertical offers a useful contrast: it shares the same double-entry ledger and Saga requirements but operates on high-frequency, low-value group loans — see Microfinance Core Banking Architecture for that lens.

VendorRuntimeDatabaseCustomizationTenancy
MambuJava EE / TomcatMySQL on Amazon RDSWebhooks & Streaming APIsDatabase-per-tenant on GCP / AWS
Thought Machine (Vault)Go + Python runtimeCockroachDB / Cloud SpannerPython Smart Contracts (event hooks)Cloud-agnostic, multi-tenant
FinxactGo (Golang)PostgreSQL (temporal schema)TypeScript DSL scriptsMulti-tenant, WAL CDC streaming
10x Banking (SuperCore)JVM / JavaRelational + NoSQLClick-to-configure “Meta Core”Multi-tenant SaaS on AWS + Confluent Kafka

Key differentiators:

  • Thought Machine is the only vendor exposing its product configuration engine as Python code. Banks write smart contracts that define product lifecycle rules (interest accrual, fee triggers, account state transitions) in Python, which the Vault runtime executes on event hooks. This gives engineering teams genuine programmable control without forking the core platform.
  • Finxact chose Go for its microservices runtime — the same language most backend teams in this audience use for their own services. Its temporal PostgreSQL schema stores every record with valid-time and transaction-time context, enabling point-in-time queries across the entire ledger history without separate audit tables.
  • Mambu’s database-per-tenant model (separate MySQL RDS instance per bank customer) provides strong data isolation at the cost of higher infrastructure overhead. This is the highest compliance-friendly model for regulated institutions that cannot tolerate shared schema data co-mingling.

Frequently Asked Questions

What is composable banking architecture?

Composable banking architecture replaces a monolithic core banking system with a network of independent, domain-specific Packaged Business Capabilities (PBCs). Each PBC owns its own database, deployment pipeline, and API surface. The system is governed by MACH principles (Microservices, API-first, Cloud-native, Headless) and typically aligns service boundaries with BIAN industry-standard Service Domains.

Why are banks migrating away from monolithic core banking systems?

Three converging pressures: cost (legacy maintenance consumes 70-80% of IT budgets), talent scarcity (COBOL engineers command 2-3× market premiums and the workforce is retiring), and speed (launching a product on a monolith takes 12-18 months; composable architectures reduce this to 6-10 weeks).

What is the Strangler Fig pattern in core banking migration?

The Strangler Fig pattern migrates a monolithic system domain-by-domain without a “Big Bang” cutover. An API Gateway routes traffic, initially forwarding everything to the legacy system. New microservices intercept individual domains (e.g., cards, deposits) as they become production-ready. An Anti-Corruption Layer translates between modern domain models and legacy data formats. Shadow routing validates the new service against the legacy system before any live traffic shifts.

What is the difference between Temporal and Dapr Workflow for banking Sagas?

Both implement Orchestrated Sagas using Event Sourcing Replay for crash recovery. Temporal is preferred for complex, long-running workflows requiring advanced versioning, custom search attributes, and high workflow throughput at scale. Dapr Workflow is lighter and integrates natively with the broader Dapr sidecar ecosystem (Pub/Sub, State, Bindings), making it the better fit for teams already using Dapr for other microservice concerns.

What is RFC 8705 and why does it matter for BaaS APIs?

RFC 8705 defines Mutual-TLS Client Certificate-Bound Access Tokens for OAuth 2.0. The Authorization Server binds the access token to the client’s X.509 certificate by embedding the certificate’s SHA-256 thumbprint in the token’s cnf.x5t#S256 claim. The API Gateway validates that the token thumbprint matches the certificate presented in the current mTLS connection, making stolen tokens unusable without the corresponding private key. This is mandatory for Financial-grade API (FAPI) 1.0 Advanced compliance.

What does DORA require for banks running composable banking systems?

DORA (Digital Operational Resilience Act, enforceable January 2025) requires significant EU financial institutions to conduct Threat-Led Penetration Testing (TLPT) at least every three years. TLPT follows the TIBER-EU framework, mimicking real adversary TTPs against all critical ICT functions — including the API Gateway, event bus, orchestration layer, and third-party SaaS core banking platforms. A single TLPT exercise typically spans 6-12 months.


🤝 Let's Connect

Are you facing similar challenges with system architecture, scaling, or migration? I'd love to hear about it. Connect with me on LinkedIn, check out my GitHub, or drop me an email.