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.

Scope: payment and account messages

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:

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

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

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

  4. Version explicitly and early

    The first version of the CDM is v1 from 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.

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

The “universal model” trap

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.

CanonicalPayment.java — root CDM objectjava
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
}
CanonicalParty.java — party value objectjava
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.

Mt103ToCdmAdapter.java — ACE JavaCompute nodejava
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);
    }
}
Inter-node CDM transit: JSON BLOB vs XMLNSC

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.

Pain001ToCdmConverter.java — Camel TypeConverterjava
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.

SAMA spec updates trigger CDM minor versions

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 package com.acmebank.cdm.payment.v1
  • cdm-payment-1.1.0 — add optional lei to CanonicalParty for pacs.008 v10 creditor LEI requirement
  • cdm-payment-2.0.0 — breaking: remove deprecated legacyRef field; rename instrId to srcRef

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 Arabic name roundtrip test you must run before go-live

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.

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

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

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

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

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