All work
Case study · Banking · Integration

Moving a forex payment flow off a legacy ESB

Led the rewrite of a high-value forex payment flow from IBM ACE into a Spring Boot service, removing a double-payment race and handling every failure path a transaction can take.

Role
Lead developer
Timeframe
Innowise · 2025 to 2026
Stack
Java, Spring Boot, Kafka, IBM MQ, Oracle, AWS, Terraform
Scale
Payment legs worth millions of USD each

Problem

When the bank executes a forex trade, two things must happen in its core banking system, Flexcube: one account is debited in one currency and another is credited in a different currency. Each of those legs can be worth millions of US dollars.

That orchestration lived in IBM ACE, a legacy enterprise service bus sitting between Flexcube and the trading platform. The ESB fired the debit and credit calls in parallel. Two parallel calls have no defined order and no shared outcome, so failures and retries could leave the flow in a state that risked paying out twice on a high-value transaction.

The goal was to move the entire flow out of the ESB into a service the team owns, and to make it correct by construction rather than by luck.

Constraints

  • The money is real and large. Correctness comes before throughput. Every debit must have a matching credit, or be reversed.
  • No guaranteed order. For a cross-currency trade, the debit and credit legs arrive as two separate Kafka messages, and nothing guarantees which one arrives first.
  • Two messaging worlds. The trading platform publishes over Kafka. Flexcube exchanges debit and credit messages over IBM MQ.
  • The core banking system cannot change. Any validation has to work against Flexcube as it is, including its Oracle database.
  • Three owning teams. The legacy-system team, the market and treasury team that owns the trading platform, and DevOps all had a stake in the cutover.

Architecture

Trading platformcashflow events
→
Forex payment servicehexagonal · Spring Boot
→
Flexcubecore banking
Transaction stateevery leg tracked
→
Sweeperredrive · compensate
→
Alertmanual reversal
Kafka in, IBM MQ out. Every transaction ends booked, compensated, or flagged for a person to act on.

The service is built on a hexagonal architecture (ports and adapters). The payment domain sits at the center: the rules for ordering legs, validating them, booking them and compensating them. It knows nothing about Kafka, IBM MQ or Oracle. Each of those is an adapter plugged into a port the domain defines: a Kafka consumer for trade events from the trading platform (Amazon MSK), a Spring JMS adapter for debit and credit messages to Flexcube over IBM MQ, and a repository adapter for validation queries against Flexcube’s Oracle database.

For a cross-currency trade, the service runs one ordered sequence regardless of the order the messages arrive in:

  1. Consume both cashflow events from Kafka, one per leg.
  2. Validate each leg against Flexcube’s Oracle database: the account status, the available balance, and whether the transaction reference has been seen before.
  3. Debit the source account over IBM MQ and wait for the core to confirm the booking.
  4. Credit the target account only once its debit is booked. A credit that arrives first is held until then.
  5. Compensate if the credit leg fails: send a reversal of the debit to the core, so the two legs end consistent.

The service was built from scratch on AWS: EKS for compute, MSK for Kafka, RDS, and Secrets Manager for credentials. Infrastructure is provisioned with Terraform and released through ArgoCD and Kargo GitOps pipelines, together with the DevOps team.

Handling the edge cases

On a system that moves millions per transaction, the rare path still happens, and it costs the most when it does. The service is designed so that every edge case below has a defined outcome rather than an undefined state.

The credit arrives before the debit

Kafka does not guarantee that the debit message of a cross-currency trade arrives before its credit. Processing messages in arrival order would mean crediting money that has not yet been debited. Instead, when a credit arrives first, the service holds it and releases it only after the matching debit has been booked in the core.

A transaction that never completes

Holding a credit raises the next question: what if its debit never arrives, or a leg stalls halfway? A sweeper service periodically finds transactions that have stayed in an intermediate state for too long. Based on each transaction’s state, it cleans it up, redrives it through the flow, or compensates what has already been booked. Nothing is left waiting indefinitely.

The success that arrives too late

The trading platform expects an answer within a time limit. If the core is slow, the service times out and reports a rejection to the trading platform. But the core can still book the payment and send its success response afterwards. At that point money has moved for a trade the trading platform believes was rejected. Every response from the core carries a correlation ID, so the service looks up the transaction it belongs to. If that transaction has already reached a terminal state, such as failed, the success is late by definition, and the service issues a compensating reversal so the core ends up matching what the trading platform was told.

When the reversal itself fails

A compensation is a payment instruction too, and it can fail. Failed reversals are retried with a capped number of attempts. When the cap is reached, the service stops retrying and raises an alert, so the already-booked transaction is reversed manually. Bounded retries keep a persistent failure from looping forever, and the alert guarantees that a booked transaction needing cleanup always reaches a person.

Key decisions

Sequential legs instead of parallel calls

Running debit and credit in parallel saves one round trip, and that is its only advantage. The cost is that neither leg knows what happened to the other. Making the flow sequential and transactional gives it a defined order: a credit is only ever sent for a debit the core has already booked. That change removed the race condition behind the double-payment risk. On payments of this size, one extra round trip is an easy trade.

A compensating transaction instead of a distributed transaction

The debit and credit are separate operations in the core banking system, reached over a message queue. No single database transaction can span both. Rather than chase a two-phase commit across systems that do not support it, the service uses the saga approach: if a leg fails after another has been booked, it issues an explicit reversal. Consistency comes from a defined undo step, not from a lock held across systems.

Hexagonal architecture

The flow is defined by its business rules, not by its transports, and the transports were exactly what was changing: the logic was leaving an ESB and gaining Kafka and IBM MQ in its place. Keeping the domain independent of every adapter means the ordering, validation and compensation rules can be tested on their own, without a broker or a core banking system, and an integration can change without touching the rules that move money.

Validate before moving money

Kafka delivers messages at least once, so the same event can arrive twice. Checking the transaction reference against the core before each leg makes a redelivered event harmless. The same step rejects payments from blocked accounts or without sufficient balance before any message reaches the core, instead of discovering the problem halfway through.

Results

  • The double-payment race is gone. Credits only follow booked debits, whatever order the messages arrive in.
  • Every transaction ends in a defined state: booked, compensated, or flagged for manual reversal. Stuck transactions are swept, and late successes are reversed.
  • Failures that cannot self-heal reach a person. Reversals retry up to a cap, then alert.
  • The flow left the ESB. The orchestration is now ordinary Java code the team can test, review and deploy through GitOps.
  • Delivered as lead developer with one supporting engineer, coordinating the legacy-system, market and treasury, and DevOps teams through the migration.