Overview
A canonical data model (CDM) is the internal language your integration layer speaks fluently before converting to anything external. The concept is older than modern messaging standards — the Canonical Data Model pattern appears in Gregor Hohpe’s Enterprise Integration Patterns catalogue, and variations of it predate ISO 20022 by decades. What changes in a KSA bank in 2026 is the pressure: SAMA IPS for domestic payments, BUNA for GCC cross-border, CBPR+ pacs.008 for SWIFT international, pain.001 from corporate treasury portals, SAMA Open Banking APIs for TPP-initiated payments, and whatever proprietary format the legacy core still emits. Each of those formats has its own schema, its own party model, its own date conventions, its own optional-field semantics.
Without a canonical model, each new format connection adds mappings to every other format it touches. With one, each format connects to the canonical layer once. The arithmetic is simple; the discipline required to execute it is not.
This article covers what a production-ready CDM looks like for a KSA commercial bank: the entity taxonomy, the Java implementation that ACE and Camel both consume, the versioning strategy that survives SAMA spec updates, and the governance model that prevents the canonical from drifting into incoherence as teams add fields over time.
This article focuses on the payment domain of the CDM: credit transfers, direct debits, payment status messages, and the account and party entities they reference. The same design principles apply to a securities CDM or a customer data CDM, but the ISO 20022 and SAMA-specific elements described here are payment-domain specific. Start with payments because they have the highest mapping complexity and the clearest regulatory consequences for getting it wrong.
The N×M mapping problem
The naive integration approach is to build point-to-point translators: MT103 → pacs.008, pain.001 → internal payment, SAMA Open Banking payment initiation → internal payment, and so on. With three source formats and three target formats, that is nine translators. When a fourth source format arrives — say, a bulk file format from a new corporate client using ISO 20022 pain.001.001.11 when your existing route handles pain.001.001.09 — you add translators to all existing targets, not one.
Design principles
The CDM most integration teams build is not the CDM they need. The common failure is designing the canonical model to look like the most complex source format — usually ISO 20022 — which means the simpler formats require awkward shoehorning. Three principles avoid this:
-
The canonical must be richer than any single external format
The CDM carries fields that no single external format requires: internal routing identifiers, sanctions screening results, risk scores, enrichment timestamps, and audit correlation IDs. A canonical that mirrors an external format is just a type alias; it does not solve the mapping problem, it only moves it.
-
All external fields are optional in the canonical
A source adapter populates the canonical fields it can derive from the source format. Fields that the source format cannot provide are left absent. The outbound adapter from canonical to target asserts which canonical fields are required for that target and rejects the message at that boundary — not at ingest. This decouples inbound parse failures from outbound validation failures, which makes debugging far cleaner.
-
Never put routing logic in the canonical schema
The scheme field (
IPS,BUNA,SWIFT) belongs in the CDM because it is derived from enrichment, not from any single source format. But the routing decision — the ContentBasedRouter that reads that field — lives in the flow, not in the schema. The canonical describes a payment; the flow decides where it goes. -
Version explicitly and early
The first version of the CDM is
v1from day one, even if you never expect to change it. SAMA will update their IPS spec, ISO 20022 will publish a new message version, and the core banking vendor will add a field. When that happens, you want a versioned namespace in the schema, not a flag day migration. -
The CDM is never serialised as a public interface
The canonical form is internal to the integration layer. It is not a service API, not a message format published to external consumers, and not stored in a database as the record of truth. It exists only between the inbound adapter and the outbound adapter. The moment an external system starts consuming the canonical form directly, you have created a tight coupling that defeats the purpose of having a canonical at all.
A CDM scoped to all domains (payments, securities, customer data, trade finance) in a single schema is almost always a failure mode. The model becomes enormous, the ownership diffuses across teams, and no single flow can evolve the schema without impacting every other flow. Scope the canonical to a domain — payment CDM, customer CDM, account CDM — and connect them at the flow level, not at the schema level. A cross-domain reference is a foreign key, not a nested object.
Entity taxonomy
The payment CDM for a KSA bank needs to model six entity categories. The diagram below shows the hierarchy and which external formats contribute to each.
The key design decision in the taxonomy is keeping Party as a value object, not a reference. The CDM does not hold a customer ID pointing into a customer master. It holds the name, IBAN, BIC, and optionally the national ID or LEI exactly as received or resolved from enrichment — because the CDM must be self-contained for audit replay. A payment replayed six months later must not break because a customer master reference has changed.
| CDM Field | MT103 source | pain.001 source | SAMA OB source | Risk if absent |
|---|---|---|---|---|
debtorIban |
:50A: or derived from :50K: |
DbtrAcct/Id/IBAN |
initiatingParty.iban |
SAMA IPS will reject pacs.008 — mandatory |
creditorIban |
:59A: or BBAN lookup required |
CdtrAcct/Id/IBAN |
creditorAccount.iban |
SAMA IPS reject; manual repair cycle |
purposeCode |
Not present — must be derived from payment type | Purp/Cd |
localInstrument mapping required |
SAMA IPS reject if missing for IPS route |
uetr |
:121: block3 (SWIFT gpi) |
Not present — generate new UUID | Not present — generate | SWIFT gpi tracking broken; not a SAMA reject |
chargeBearing |
:71A: BEN/OUR/SHA |
ChrgBr |
Default per Open Banking rules | Incorrect fee allocation; customer dispute |
riskScore |
Not present — enrichment only | Not present — enrichment only | Not present — enrichment only | Sanctions screening bypass if enrichment step skipped |
Java implementation
The CDM is a plain Java object hierarchy with Jackson annotations for JSON serialisation and JAXB annotations for XML serialisation. Both annotation sets coexist cleanly in Java 21. The CDM JAR is a separate Maven module with no runtime dependency on any integration framework — ACE uses it as a Java plugin, Camel uses it as a type converter target, and the test suite uses it standalone.
package com.acmebank.cdm.payment.v2;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty;
import jakarta.xml.bind.annotation.*;
import java.time.LocalDate;
import java.time.OffsetDateTime;
@XmlRootElement(namespace = "http://acmebank.com/cdm/payment/v2")
@XmlAccessorType(XmlAccessType.FIELD)
@JsonInclude(JsonInclude.Include.NON_NULL)
public class CanonicalPayment {
// Identity cluster
private String msgId; // internal UUID, never reused
private String srcRef; // :20: or pacs.008 InstrId
private String e2eRef; // pain.001 EndToEndId or derived
private String uetr; // SWIFT gpi; generated if absent
// Parties
private CanonicalParty debtor;
private CanonicalParty creditor;
private String debtorAgentBic;
private String creditorAgentBic;
// Amount
private java.math.BigDecimal amount;
private String currency; // ISO 4217
private LocalDate valueDate;
private String chargeBearing; // SHAR | CRED | DEBT
// Routing (populated by enrichment, not inbound parse)
private PaymentScheme scheme; // IPS | BUNA | SWIFT | INTERNAL
private String priority; // HIGH | NORM | BULK
private String purposeCode; // SAMA IPS purpose code list
private String sttlmMtd; // CLRG for IPS, INGA for BUNA
// Remittance
private String remittanceUnstructured;
private String creditorReference;
// Enrichment metadata (never populated by source adapters)
private String sanctionsScreeningId;
private Integer riskScore;
private OffsetDateTime enrichedAt;
private String cdmVersion = "2";
// Getters and setters omitted for brevity
}
package com.acmebank.cdm.payment.v2;
import jakarta.xml.bind.annotation.*;
@XmlType(namespace = "http://acmebank.com/cdm/payment/v2")
public class CanonicalParty {
private String name;
private String iban;
private String bic;
private String nationalId; // Saudi national ID or Iqama, if present
private String lei; // LEI for corporate creditors (pacs.008 v10)
private String addressLine1;
private String addressCity;
private String addressCountry; // ISO 3166-1 alpha-2
// Note: address fields are optional for IPS domestic payments but
// mandatory for BUNA cross-border and SWIFT MT103 originator.
// Outbound adapters assert requirements; inbound adapters populate what they have.
// Getters and setters omitted for brevity
}
IBM ACE integration
IBM ACE 12 does not natively understand Java POJOs at the message tree level, but it can load external JARs as Java plugins and call them from a JavaCompute node. The CDM module is deployed as an ACE shared library JAR; the translation flows call it via JavaCompute nodes at the parse/generate boundary.
The ACE integration pattern has two layers: the adapter (a JavaCompute node that reads the ACE MRM tree and populates a CanonicalPayment POJO) and the generator (a JavaCompute node that reads the POJO and writes to the ACE XMLNSC tree for outbound serialisation). Keeping these two responsibilities in separate nodes makes the flow diagram readable and makes it possible to unit-test the adapter and generator independently of the ACE runtime using ACE’s flow unit testing framework.
package com.acmebank.ace.adapter;
import com.ibm.broker.javacompute.MbJavaComputeNode;
import com.ibm.broker.plugin.*;
import com.acmebank.cdm.payment.v2.*;
public class Mt103ToCdmAdapter extends MbJavaComputeNode {
@Override
public void evaluate(MbMessageAssembly assembly) throws MbException {
MbMessage inMsg = assembly.getMessage();
MbElement mrm = inMsg.getRootElement()
.getLastChild() // MRM domain
.getLastChild(); // MT103 root
CanonicalPayment cdm = new CanonicalPayment();
cdm.setMsgId(java.util.UUID.randomUUID().toString());
cdm.setSrcRef(getString(mrm, "Field20/content"));
cdm.setUetr(getUetr(mrm)); // block 3 :121: or generated
// Debtor from :50K: or :50A:
CanonicalParty debtor = new CanonicalParty();
debtor.setName(getString(mrm, "Field50K/OrderingCustomerName"));
debtor.setIban(resolveIban(getString(mrm, "Field50K/AccountNumber")));
debtor.setBic(getString(mrm, "Field52A/IdentifierCode"));
cdm.setDebtor(debtor);
// Creditor from :59: or :59A:
CanonicalParty creditor = new CanonicalParty();
creditor.setName(getString(mrm, "Field59/BeneficiaryName"));
creditor.setIban(resolveIban(getString(mrm, "Field59/AccountNumber")));
creditor.setBic(getString(mrm, "Field57A/IdentifierCode"));
cdm.setCreditor(creditor);
// Amount: :32A: ValueDate + CCY + Amount
String field32a = getString(mrm, "Field32A/content");
cdm.setValueDate(parseDate32A(field32a)); // YYMMDD → LocalDate
cdm.setCurrency(field32a.substring(6, 9));
cdm.setAmount(parseAmount32A(field32a.substring(9)));
// Charge bearer :71A:
cdm.setChargeBearing(mapCharges(getString(mrm, "Field71A/ChargesCode")));
// Remittance :70:
cdm.setRemittanceUnstructured(getString(mrm, "Field70/content"));
// Routing and enrichment: left for the Enrichment node downstream
cdm.setScheme(PaymentScheme.PENDING);
// Serialise CDM as JSON into the BLOB domain for inter-node transit
MbMessage outMsg = assembly.getMessage();
MbElement blob = outMsg.getRootElement()
.createElementAsLastChild(MbBLOB.PARSER_NAME);
blob.createElementAsLastChild(MbElement.TYPE_BYTE_ARRAY, "BLOB",
CdmSerializer.toJson(cdm).getBytes(java.nio.charset.StandardCharsets.UTF_8));
out.propagate(assembly);
}
}
There are two viable approaches for carrying the CDM between ACE nodes: serialise the POJO to a JSON string in the BLOB domain, or populate an XMLNSC tree directly from the CDM XSD. The JSON BLOB approach is simpler to debug — the message content is inspectable in the ACE flow debugger as a string — but requires an extra serialisation step. The XMLNSC approach eliminates that serialisation but requires the CDM XSD to be present in the ACE message set deployment. For flows where the CDM passes through more than four or five nodes, the XMLNSC approach is worth the initial setup cost. For short flows, JSON BLOB is the pragmatic choice.
Apache Camel integration
Apache Camel 4’s TypeConverter registry is the idiomatic integration point for a CDM in a Camel-based flow. Registering a converter from each source type to CanonicalPayment — and from CanonicalPayment to each target type — lets Camel automatically invoke the conversion when a route calls .convertBodyTo(CanonicalPayment.class). This makes routes short and readable and keeps format-specific logic in the converter classes, not in the route DSL.
package com.acmebank.camel.converter;
import org.apache.camel.Converter;
import org.apache.camel.TypeConverters;
import org.w3c.dom.Document;
import com.acmebank.cdm.payment.v2.*;
import iso.std.iso._20022.tech.xsd.pain_001_001._11.*;
@Converter(generateLoader = true)
public class Pain001ToCdmConverter implements TypeConverters {
@Converter
public static CanonicalPayment fromPain001(
CustomerCreditTransferInitiationV11 pain001) {
CanonicalPayment cdm = new CanonicalPayment();
cdm.setMsgId(java.util.UUID.randomUUID().toString());
GroupHeaderSCT grpHdr = pain001.getGrpHdr();
cdm.setSrcRef(grpHdr.getMsgId());
cdm.setEnrichedAt(java.time.OffsetDateTime.now());
// First payment instruction (simple case; bulk batches handled separately)
PaymentInstruction40 pmtInf = pain001.getPmtInf().get(0);
CreditTransferTransaction58 cdtTrf = pmtInf.getCdtTrfTxInf().get(0);
// Debtor
CanonicalParty debtor = new CanonicalParty();
debtor.setName(pmtInf.getDbtr().getNm());
debtor.setIban(pmtInf.getDbtrAcct().getId().getIBAN());
debtor.setBic(pmtInf.getDbtrAgt().getFinInstnId().getBICFI());
cdm.setDebtor(debtor);
// Creditor
CanonicalParty creditor = new CanonicalParty();
creditor.setName(cdtTrf.getCdtr().getNm());
creditor.setIban(cdtTrf.getCdtrAcct().getId().getIBAN());
creditor.setBic(cdtTrf.getCdtrAgt().getFinInstnId().getBICFI());
cdm.setCreditor(creditor);
// Amount
cdm.setAmount(cdtTrf.getAmt().getInstdAmt().getValue());
cdm.setCurrency(cdtTrf.getAmt().getInstdAmt().getCcy());
cdm.setValueDate(pmtInf.getReqdExctnDt().getDt());
// Purpose code (mandatory for IPS)
if (cdtTrf.getPurp() != null) {
cdm.setPurposeCode(cdtTrf.getPurp().getCd());
}
// Charge bearing
if (pmtInf.getChrgBr() != null) {
cdm.setChargeBearing(pmtInf.getChrgBr().value());
}
// Remittance
if (cdtTrf.getRmtInf() != null
&& !cdtTrf.getRmtInf().getUstrd().isEmpty()) {
cdm.setRemittanceUnstructured(cdtTrf.getRmtInf().getUstrd().get(0));
}
cdm.setScheme(PaymentScheme.PENDING); // enrichment resolves this
return cdm;
}
}
Schema versioning
A CDM that cannot evolve safely is a CDM that will be abandoned and replaced with a new ad-hoc model when the first incompatible change arrives. Version the CDM explicitly from day one using namespace versioning, not field-level flags.
The namespace for the payment CDM follows the pattern http://acmebank.com/cdm/payment/v{N}. A major version increment (v1 → v2) signals a breaking change: a mandatory field added, a field removed, or a field renamed. All flows must be migrated before the old namespace is retired. A minor change (adding an optional field) does not require a version increment — JSON serialisation ignores unknown fields by default (@JsonIgnoreProperties(ignoreUnknown = true)), and JAXB similarly ignores unexpected elements.
When SAMA publishes an update to the IPS Technical Specifications that adds a new mandatory field to pacs.008 — as happened with the SuplmtryData element for enhanced traceability — the CDM must carry that field from ingest to outbound generation. If the field is not yet present in the CDM, the outbound pacs.008 adapter cannot populate it. Adding an optional field to the CDM at a minor version means all flows pick up the field without a migration; the pacs.008 adapter begins populating it; and no existing flows break. Document every minor CDM change in a changelog tied to the SAMA circular that prompted it.
The versioning strategy for the CDM module in Maven:
cdm-payment-1.0.0— initial payment CDM; Java packagecom.acmebank.cdm.payment.v1cdm-payment-1.1.0— add optionalleitoCanonicalPartyfor pacs.008 v10 creditor LEI requirementcdm-payment-2.0.0— breaking: remove deprecatedlegacyReffield; renameinstrIdtosrcRef
Flows that consume cdm-payment-1.x can upgrade to cdm-payment-2.0.0 independently, on their own release cycle. The CDM module coordinates the change; it does not force a synchronised deployment of all flows.
Validation & enrichment hooks
Validation and enrichment are separate concerns, applied in sequence after inbound parsing. Validation checks that the CDM is well-formed enough to attempt enrichment. Enrichment adds the fields that cannot be derived from the source message alone. Both run before the outbound adapter touches the CDM.
The enrichment pipeline is implemented as a Spring bean chain. Each enricher receives a CanonicalPayment, returns a CanonicalPayment (possibly with additional fields populated), and declares which fields it requires as input and which fields it guarantees as output. This contract is enforced by a simple annotation-driven validator that runs before each enricher, making the pipeline self-documenting and the failure points easy to trace.
Testing strategy
Testing the CDM has three dimensions: adapter correctness, enrichment correctness, and schema evolution safety. Each requires a different testing approach.
| Test type | What it verifies | Tooling | Run frequency |
|---|---|---|---|
| Adapter unit test | Every source format field maps correctly to the CDM field; edge cases (absent optional fields, maximum-length values, Arabic names) | JUnit 5 · AssertJ · test message corpus | Every build |
| Enricher unit test | IBAN validation, BIC lookup, purpose code mapping; mock service responses for BBAN resolution | JUnit 5 · WireMock · Mockito | Every build |
| Outbound adapter test | CDM produces a valid pacs.008, MT103, or core-format message; schema validation against SAMA XSD overlay | JUnit 5 · XMLUnit 2 · Saxon EE | Every build |
| Roundtrip test | Source format → CDM → target format preserves semantically equivalent values; critical for MT103 ↔ pacs.008 | JUnit 5 parameterised with golden message corpus | Every build |
| Schema evolution test | A CDM v1 message can be deserialised correctly by the v2 model with no data loss | JUnit 5 · Jackson · stored v1 test fixtures | On CDM module version change |
| SAMA sandbox end-to-end | Full pipeline produces a pacs.008 accepted by SAMA IPS sandbox; reject path returns correct pacs.002 | SAMA IPS sandbox · manual sign-off before release | Pre-release · quarterly regression |
The most common silent failure in a CDM pipeline is Arabic character handling. The test is simple: take a payment with an Arabic name in the debtor and creditor name fields, run it through the full pipeline from MT103 inbound to pacs.008 outbound, and verify that the Arabic characters appear correctly in the pacs.008 output. Then take the same payment, route it back through the SWIFT parallel-run path (pacs.008 → MT103 generation), and verify that the transliteration result is acceptable for SWIFT delivery. If you skip this test, you will discover the failure in production on the first Arabic-name payment, which is not where you want to discover it.
Governance model
A CDM without a governance model is a CDM that is abandoned. The pattern that works at KSA banks with multiple integration teams is a lightweight Architecture Decision Record (ADR) process for CDM changes, owned by a named Integration Architect, with a mandated review before any breaking change is deployed.
-
Designate a CDM owner
One named Integration Architect owns the CDM schema and is the approver for any pull request that changes the CDM module. This person monitors SAMA specification updates, ISO 20022 schema version releases, and the SWIFT CBPR+ coexistence schedule. Without a named owner, changes drift in as “temporary” fields that become permanent.
-
Require a CDM Change Request for breaking changes
A breaking CDM change (any change that requires existing flows to update their adapter code) requires a one-page CDM Change Request: what is changing, why, which flows are affected, what the migration path is, and the target release date. The CDM owner approves it; affected team leads acknowledge the migration timeline. This is not bureaucracy — it is the minimum process that prevents a breaking change from silently breaking a production flow.
-
Maintain a CDM changelog
A plain-text changelog in the CDM module root, tied to the Maven version, lists every change with the SAMA circular or business requirement that prompted it. When the SAMA examiner asks why a field was added in v1.2, the changelog is the answer.
-
Run a quarterly CDM alignment review
Every quarter, the CDM owner reviews the SAMA IPS Technical Specifications and the CBPR+ coexistence schedule for upcoming changes. If a spec update is coming in the next six months that will require a CDM change, the change request is raised now, not when the spec takes effect. A six-month lead time for a minor CDM change is more than enough; a two-week lead time is not.
-
Track CDM adoption per flow
A simple spreadsheet or Confluence table lists each integration flow, the CDM version it consumes, and the last review date. This is the migration tracking tool: when CDM v2.0.0 ships, any flow still on v1.x is visible. Without this, you discover after a go-live incident that a flow never upgraded.
What production tells you
Three patterns consistently emerge in production CDM deployments at KSA banks that documentation does not prepare you for:
The CDM grows faster than you expect. The first version of the CDM carries the fields that the initial three or four source formats need. By the time you have ten active integration flows, the CDM has twenty fields that were added as “temporary” enrichment data, three fields that are populated by only one flow and are null for everything else, and two fields with overlapping semantics added by different teams. Schedule a quarterly CDM review from day one; by the time the first review is due, you will have something worth reviewing.
The enrichment pipeline is where most production failures originate. The source adapter failures — badly formatted input, missing mandatory fields — are caught early and are easy to triage. The enrichment failures are harder: a BBAN-to-IBAN resolution service that returns 200 but with an empty body, a purpose code lookup that times out on a particular payment type, a risk scoring engine that returns a score of zero for a payment that should have been flagged. Build structured error logging at each enrichment step that includes the CDM field being populated, the external service called, and the response received. Without this, enrichment failures are reported as “payment rejected by SAMA” with no trail leading back to the enrichment step that left the CDM in an incomplete state.
Audit replay is more important than you realise before you need it. The ability to take a stored canonical form from six months ago and replay it through the current outbound adapter — to reproduce exactly what was sent to SAMA IPS on a specific date — is a SAMA examination requirement, not an engineering nicety. If the CDM has changed in a breaking way between the original transmission and the replay date, the replay either fails or produces an incorrect message. Versioning the CDM and storing the CDM version alongside each archived message is the minimum required. Some teams go further and store the outbound XSD version alongside each message; this is worthwhile for a bank running both pacs.008 v09 and pacs.008 v10 in the same estate during the CBPR+ coexistence period.