A regional bank with 50+ years of history was constrained by a COBOL-based core banking system built in the 1980s. Unable to compete with digital-first banks and fintechs, they needed a complete modernization while ensuring zero downtime and maintaining regulatory compliance.
NordVarg designed and implemented a modern core banking platform using OCaml, chosen for its strong type system, reliability, and proven track record in financial systems (used by Jane Street, Bloomberg, and Facebook).
The 40-year-old COBOL system presented critical challenges:
- No API support: Impossible to build modern digital experiences
- Batch processing: End-of-day batch jobs taking 6+ hours
- Mainframe dependency: Annual costs of $10M for hardware/licensing
- Skill shortage: Only 3 engineers who understood the codebase
- Rigidity: Simple changes requiring months of development
- Losing customers to digital-first competitors
- Unable to launch mobile banking
- Missing out on real-time payment opportunities
- High operational costs
- Regulatory pressure to improve resilience
- Zero downtime migration: Cannot disrupt 24/7 banking operations
- Data integrity: Absolute correctness in financial calculations
- Regulatory compliance: SOC 2, PCI-DSS, banking regulations
- Performance: Handle 10,000 transactions/second
- Auditability: Complete transaction history and traceability
We selected OCaml for the core banking logic due to its unique strengths:
(* Money type that prevents arithmetic errors *)
module Money : sig
type t
val zero : t
val of_string : string -> (t, string) result
val add : t -> t -> t
val subtract : t -> t -> (t, string) result
val multiply : t -> float -> t
val compare : t -> t -> int
val to_string : t -> string
end = struct
(* Internal representation: cents to avoid floating point *)
type t = int64
let zero = 0L
let of_string s =
match String.split_on_char '.' s with
| [dollars; cents] ->
(try
let d = Int64.of_string dollars in
let c = Int64.of_string cents in
Ok (Int64.add (Int64.mul d 100L) c)
with Failure _ -> Error "Invalid money format")
| _ -> Error "Invalid money format"
let add = Int64.add
let subtract a b =
let result = Int64.sub a b in
if result < 0L then
Error "Insufficient funds"
else
Ok result
let multiply m factor =
Int64.of_float (Int64.to_float m *. factor)
let compare = Int64.compare
let to_string m =
let dollars = Int64.div m 100L in
let cents = Int64.rem m 100L in
Printf.sprintf "%Ld.%02Ld" dollars cents
end
Benefit: Impossible to mix cents with dollars at compile time
(* Account state machine with exhaustive matching *)
type account_status =
| Active
| Frozen of freeze_reason
| Closed of closure_reason
type freeze_reason =
| Suspicious_activity
| Court_order
| Compliance_hold
type transaction_result =
| Success of transaction_id
| Rejected of rejection_reason
let process_transaction account transaction =
match account.status with
| Active ->
(* Process normally *)
validate_and_execute account transaction
| Frozen Suspicious_activity ->
Rejected "Account frozen due to suspicious activity"
| Frozen Court_order ->
Rejected "Account frozen by court order"
| Frozen Compliance_hold ->
Rejected "Account under compliance review"
| Closed reason ->
Rejected (Printf.sprintf "Account closed: %s"
(closure_reason_to_string reason))
Benefit: Compiler ensures all cases handled
(* Immutable transaction journal *)
type transaction = {
id: transaction_id;
timestamp: timestamp;
from_account: account_id;
to_account: account_id;
amount: Money.t;
description: string;
status: transaction_status;
}
(* Events are immutable - can never be modified *)
type event =
| Account_created of account_creation
| Money_deposited of deposit
| Money_withdrawn of withdrawal
| Transfer_executed of transfer
| Account_frozen of freeze_event
| Account_closed of closure_event
(* Event sourcing - reconstruct state from events *)
let replay_events initial_state events =
List.fold_left apply_event initial_state events
Benefit: Concurrent access without locks, perfect audit trail
┌──────────────────────────────────────────────────────┐
│ Digital Channels │
│ Mobile App │ Web │ API │
└──────────────────────────┬───────────────────────────┘
│ REST/GraphQL
┌──────────────────────────▼───────────────────────────┐
│ API Gateway (Kong) │
└──────────────────────────┬───────────────────────────┘
│ gRPC
┌──────────────┬────┴───────┬────────────┐
│ │ │ │
┌────▼────┐ ┌────▼────┐ ┌────▼────┐ ┌────▼────┐
│ Account │ │ Trans- │ │ Payment │ │ Loan │
│ Service │ │ action │ │ Service │ │ Service │
│ (OCaml) │ │ (OCaml) │ │ (OCaml) │ │ (OCaml) │
└────┬────┘ └────┬────┘ └────┬────┘ └────┬────┘
│ │ │ │
└──────────────┴────┬───────┴────────────┘
│
┌───────▼───────┐
│ PostgreSQL │
│ (Event Store) │
└───────┬───────┘
│
┌──────────┴──────────┐
│ │
┌────▼────┐ ┌─────▼────┐
│ Redis │ │ Kafka │
│ (Cache) │ │ (Events) │
└─────────┘ └──────────┘
(* Account module with strong guarantees *)
module Account : sig
type t
type error =
| Insufficient_funds
| Account_frozen
| Daily_limit_exceeded
| Invalid_amount
val create : account_id -> customer_id -> Money.t -> t
val balance : t -> Money.t
val deposit : t -> Money.t -> (t, error) result
val withdraw : t -> Money.t -> (t, error) result
val transfer : t -> t -> Money.t -> (t * t, error) result
val freeze : t -> freeze_reason -> t
val unfreeze : t -> t
end = struct
type t = {
id: account_id;
customer_id: customer_id;
balance: Money.t;
status: account_status;
daily_withdrawal: Money.t;
daily_limit: Money.t;
created_at: timestamp;
updated_at: timestamp;
}
type error =
| Insufficient_funds
| Account_frozen
| Daily_limit_exceeded
| Invalid_amount
let create id customer_id initial_balance = {
id;
customer_id;
balance = initial_balance;
status = Active;
daily_withdrawal = Money.zero;
daily_limit = Money.of_string "5000.00" |> Result.get_ok;
created_at = now ();
updated_at = now ();
}
let balance account = account.balance
let deposit account amount =
if Money.compare amount Money.zero <= 0 then
Error Invalid_amount
else
match account.status with
| Active ->
let new_balance = Money.add account.balance amount in
Ok { account with balance = new_balance; updated_at = now () }
| Frozen _ ->
Error Account_frozen
| Closed _ ->
Error Account_frozen
let withdraw account amount =
match account.status with
| Active ->
(* Check sufficient funds *)
(match Money.subtract account.balance amount with
| Error _ -> Error Insufficient_funds
| Ok new_balance ->
(* Check daily limit *)
let new_daily = Money.add account.daily_withdrawal amount in
if Money.compare new_daily account.daily_limit > 0 then
Error Daily_limit_exceeded
else
Ok { account with
balance = new_balance;
daily_withdrawal = new_daily;
updated_at = now ();
})
| Frozen _ -> Error Account_frozen
| Closed _ -> Error Account_frozen
let transfer from_account to_account amount =
match withdraw from_account amount with
| Error e -> Error e
| Ok from' ->
match deposit to_account amount with
| Error e -> Error e
| Ok to' -> Ok (from', to')
let freeze account reason =
{ account with status = Frozen reason; updated_at = now () }
let unfreeze account =
{ account with status = Active; updated_at = now () }
end
(* Atomic transaction processing with event sourcing *)
module Transaction_processor = struct
type command =
| Process_deposit of account_id * Money.t
| Process_withdrawal of account_id * Money.t
| Process_transfer of account_id * account_id * Money.t
type event =
| Deposit_processed of transaction_id * account_id * Money.t * timestamp
| Withdrawal_processed of transaction_id * account_id * Money.t * timestamp
| Transfer_processed of transaction_id * account_id * account_id * Money.t * timestamp
| Transaction_failed of transaction_id * string * timestamp
(* Process command and generate events *)
let process_command db_conn command =
Lwt_main.run begin
match command with
| Process_deposit (account_id, amount) ->
let%lwt account = Repository.load_account db_conn account_id in
(match Account.deposit account amount with
| Ok updated_account ->
let event = Deposit_processed (
generate_id (),
account_id,
amount,
now ()
) in
let%lwt () = Repository.save_account db_conn updated_account in
let%lwt () = Repository.append_event db_conn event in
Lwt.return (Ok event)
| Error e ->
let event = Transaction_failed (
generate_id (),
error_to_string e,
now ()
) in
let%lwt () = Repository.append_event db_conn event in
Lwt.return (Error e))
| Process_transfer (from_id, to_id, amount) ->
(* Two-phase commit for transfer *)
let%lwt from_account = Repository.load_account db_conn from_id in
let%lwt to_account = Repository.load_account db_conn to_id in
(match Account.transfer from_account to_account amount with
| Ok (from', to') ->
let event = Transfer_processed (
generate_id (),
from_id,
to_id,
amount,
now ()
) in
let%lwt () = Repository.begin_transaction db_conn in
let%lwt () = Repository.save_account db_conn from' in
let%lwt () = Repository.save_account db_conn to' in
let%lwt () = Repository.append_event db_conn event in
let%lwt () = Repository.commit_transaction db_conn in
Lwt.return (Ok event)
| Error e ->
let%lwt () = Repository.rollback_transaction db_conn in
Lwt.return (Error e))
| _ -> (* other cases *)
Lwt.return (Error Account.Invalid_amount)
end
end
- New system processes transactions in shadow mode
- Compare results with legacy system
- Build confidence in correctness
- Train operations team
- Migrate accounts in batches (100 accounts/day)
- New accounts created on new system only
- Dual write to both systems
- Automatic reconciliation
- All traffic routed to new system
- Legacy system in read-only mode
- Final data verification
- Legacy system shutdown
Result: Zero downtime, zero data loss
| Metric | Legacy | Modern | Improvement |
|---|
| Transaction Processing | 50/sec | 10,000/sec | 200x faster |
| End-of-Day Batch | 6 hours | 15 minutes | 24x faster |
| API Response Time | N/A | 50ms | New capability |
| System Uptime | 99.5% | 99.99% | 0.49% improvement |
| Transaction Cost | $0.25 | $0.02 | 92% reduction |
- $30M annual savings from mainframe decommission
- 300% increase in mobile banking adoption
- Real-time payments enabled (Instant Payment System)
- API platform generating $5M annual revenue
- Customer satisfaction improved by 45%
✅ Mobile Banking - Modern iOS/Android apps
✅ Real-Time Payments - Instant transfers
✅ Open Banking APIs - Third-party integrations
✅ Digital Wallet - Mobile payments
✅ Personal Finance - AI-powered insights
- OCaml 5.0 - Core banking logic
- Lwt - Concurrent I/O
- Cohttp - HTTP server
- PostgreSQL - Event store & data
- Redis - Caching layer
- Kafka - Event streaming
- gRPC - Internal services
- GraphQL - External APIs
- Kong - API gateway
- OAuth 2.0 - Authentication
- Kubernetes - Orchestration
- Docker - Containerization
- Terraform - Infrastructure as code
- Prometheus - Monitoring
- Grafana - Dashboards
- React - Web application
- React Native - Mobile apps
- TypeScript - Type safety
Problem: 2 million lines of COBOL business logic
Solution:
- Document COBOL logic before rewrite
- Automated testing of both systems
- Property-based testing for equivalence
- SME review of each module
Problem: 50 years of financial data in mainframe
Solution:
- Custom ETL pipeline
- Incremental migration with validation
- Parallel running for verification
- Automated reconciliation tools
Problem: No OCaml expertise in-house
Solution:
- 3-month training program
- Pair programming with our team
- Gradual responsibility transfer
- Comprehensive documentation
Problem: Strict regulatory requirements
Solution:
- Early regulator engagement
- Comprehensive audit trail
- Independent security assessment
- Phased rollout with regulatory checkpoints
- Multi-factor authentication (MFA)
- Hardware security modules (HSM)
- Biometric authentication
- Role-based access control (RBAC)
- Encryption at rest (AES-256)
- Encryption in transit (TLS 1.3)
- Tokenization of sensitive data
- PCI-DSS compliance
- Complete transaction history
- Immutable audit logs
- Regulatory reporting automation
- SOC 2 Type II certified
"The modernization has transformed our bank. We can now compete with digital banks and fintechs while maintaining the reliability our customers expect. The OCaml platform has been rock-solid - zero unplanned downtime in 18 months of operation."
— CIO, Regional Bank
- Type safety matters: OCaml's type system prevented entire classes of bugs
- Gradual migration: Parallel running reduced risk dramatically
- Event sourcing: Perfect for financial audit requirements
- Immutability: Simplified concurrent programming
- Training investment: Worth it for long-term maintainability
- Regulatory engagement: Early and frequent communication critical
Modernizing a legacy banking system? Get in touch to discuss your project.
Project Duration: 18 months
Team Size: 15 engineers
Technologies: OCaml, PostgreSQL, Kubernetes
Industry: Banking
Location: United States