Canonical Data Model: When a Shared Message Format Pays Off

By Sergey Nosov

14 October 2026

Suppose five systems publish orders and five others consume them. If each consumer reads each producer’s format directly, that is up to twenty-five mappings to write, test, and keep working. Each mapping is another place to get the details wrong. One producer sends 19.99 and means dollars, another sends 1999 and means cents, and a third calls the buyer a customer while a fourth calls it a client.

A canonical data model replaces those mappings with one shared format that belongs to none of the systems. Each system translates once, into the shared format or out of it, so the ten systems need ten adapters instead of twenty-five mappings.

The arithmetic is the easy part. A canonical model pays off when many producers and many consumers exchange similar data, when the model stays small and neutral, and when someone owns how it changes. Without them, it is overhead at best and a bottleneck at worst. This article covers when the numbers work, where translation belongs, how the model fails, and how to version, govern, and introduce it. A short C# example shows the adapters at work.

What a Canonical Data Model Is

Gregor Hohpe and Bobby Woolf describe the pattern in their 2003 book Enterprise Integration Patterns. Their online summary frames the problem as a question: “How can you minimize dependencies when integrating applications that use different data formats?” The answer is to “design a Canonical Data Model that is independent from any specific application” and to “require each application to produce and consume messages in this common format.”

Independent is the key word. The canonical model is a contract between systems, not any system’s internal model and not a shared database. Each system keeps its own storage and model; only the messages between systems take the canonical shape. Think of it as Esperanto for your services: a deliberately neutral vocabulary that no system speaks natively, but every system can translate into and out of.

Domain-driven design has a close cousin of this pattern, which Eric Evans calls a published language. His Domain-Driven Design Reference explains why it should not be one of the existing models: they may be overly complex or poorly factored, and probably undocumented. Worse, “If one is used as a data interchange language, it essentially becomes frozen and cannot respond to new development needs.” His prescription reads like a definition of the pattern. “Use a well-documented shared language that can express the necessary domain information as a common medium of communication, translating as necessary into and out of that language.”

My Software Development Principles series has entries on the Canonical Data Model pattern and on the Message Translator, with indicators of proper application and of common violations.

When the Arithmetic Works

With N producers and M consumers, direct integration needs up to N × M mappings, one for each pair that exchanges data. A canonical model needs N + M adapters: one outbound per producer and one inbound per consumer. When every system both sends and receives, the counts become n(n − 1) and 2n.

producers consumers producers consumers A B C D 1 2 3 4 A B C D 1 2 3 4 Canonical model Point to point: 16 mappings Canonical model: 8 adapters

Count the pairs that really exchange data, not the systems: a canonical model needs fewer translations only when those pairs outnumber its adapters. Two producers that each feed the same two consumers need four either way: the break-even. Hohpe and Woolf count translators for applications that both send and receive. Two applications need two translators directly and four with a canonical model, and three need six either way. Six, though, need thirty directly and twelve with the canonical model. The pattern “seems complicated if only a small number of applications participate,” they write, but it “quickly pays off as the number of applications increases.”

With a single producer, a canonical model never saves an adapter: k consumers need k mappings directly and k + 1 adapters with the model. The producer still should not hand consumers its internal model, which Evans warns would freeze it. Instead, it can translate once into a documented format it owns: the same k + 1 translations, with no neutral model in between. Evans pairs such a published language with what he calls an open-host service, a protocol opened “so that all who need to integrate with you can use it.”

The Larger Reason: Vocabulary Drift

The count is the smaller reason to adopt the pattern. The larger one is vocabulary drift: the same concept represented differently from team to team. That is where subtle, hard-to-diagnose integration bugs come from. In the opening example, each consumer must know which producer sends dollars and which sends cents. A consumer that misreads either one is off by a factor of one hundred. A canonical model writes the convention down once, and each producer’s quirks stay in its adapter.

A canonical model can standardize only meaning that is really shared, though. Martin Fowler recalls an electricity utility where the word meter “meant subtly different things to different parts of the organization”: sometimes a grid connection, sometimes the physical device. Those are different concepts, not different representations of one, and he sees the same confusion recur with customer and product. A canonical model can give each concept its own precise name; it cannot make one word mean all of them. Domain-driven design keeps such concepts in separate bounded contexts and maps between them where they meet.

Keep Translation at the Edges

Adapters should be thin, focused, and dumb: they translate and do nothing else. Hohpe and Woolf call such an adapter a Message Translator, the messaging equivalent of the Gang of Four’s Adapter pattern. Each system’s team should own its adapters, because it knows the system’s format best; for a vendor’s system, the team that integrates it does. The rule that makes the whole pattern work is simple to state: translation lives at the edges, never inside domain logic. Three reasons make it worth enforcing:

Canonical types belong to the integration: the messages on the wire, any router that inspects them, and the adapters on both sides. Inside a consuming system, business logic works on its own model, and an inbound adapter maps canonical messages into it. In Evans’s terms, that adapter is a simple anticorruption layer, one that provides “functionality of the upstream system in terms of your own domain model.” My article on Conway’s Law looks at that layer from the organization’s side. A small consumer with no model of its own can use canonical types directly, a choice Evans calls conformist, but breaking canonical changes then reach its business logic.

A practical test: outside the source system itself, only its adapter may reference the source’s wire types. If they appear anywhere else, the boundary has leaked. Fowler gives the same advice in his Tolerant Reader entry: “make sure there’s only one bit of code that reads data payloads like this.” The compiler can enforce the rule. In C#, a source’s types can be private to its adapter class, or internal to an adapter project, which hides them from other assemblies by default.

A Small Example in C#

The example has two producers and one consumer, too few to pay off. It runs in one process, so it shows the mappings, not a wire format or transport. Vendor A sends dollars as a decimal number. Shop B sends an integer in the currency’s minor unit and splits the buyer’s name into parts. Billing, the consumer, keeps its own invoice model.

The five blocks form one .NET 10 file-based app: save them in order as canonical-orders.cs and run dotnet run canonical-orders.cs. The first line turns off native AOT, which Microsoft’s documentation says file-based apps enable by default. Without it, even dotnet run fails at run time, because the AOT default also turns off reflection-based JSON serialization.

#:property PublishAot=false

using System.Text.Json;

// Vendor A sends dollars.
var fromA = VendorAAdapter.ToCanonical("""
    {"orderRef": "A-1001",
     "amountUsd": 19.99,
     "buyerName": "Harbor Cafe",
     "loyaltyTier": "gold"}
    """);

// Shop B sends minor units.
var fromB = ShopBAdapter.ToCanonical("""
    {"id": "B-77",
     "totalMinor": 1999,
     "currency": "JPY",
     "customer": {"first": "Ana",
                  "last": "Lima"}}
    """);

// Prints: InvoiceDraft {
//   Reference = A-1001,
//   Amount = 19.99,
//   Currency = USD,
//   BillTo = Harbor Cafe }
Console.WriteLine(BillingInbound.FromCanonical(fromA));

// Prints: InvoiceDraft {
//   Reference = B-77,
//   Amount = 1999,
//   Currency = JPY,
//   BillTo = Ana Lima }
Console.WriteLine(BillingInbound.FromCanonical(fromB));

try
{
    // No buyerName this time.
    VendorAAdapter.ToCanonical("""
        {"orderRef": "A-1002",
         "amountUsd": 5.00}
        """);
}
catch (JsonException e)
{
    // Prints: JSON deserialization
    // for type 'VendorAAdapter+
    // VendorOrderV1' was missing
    // required properties
    // including: 'buyerName'.
    Console.WriteLine(e.Message);
}

Vendor A’s first payload carries a loyaltyTier field that the adapter does not know, and System.Text.Json skips it by default: Microsoft’s documentation says such properties are “simply ignored.” The default makes each adapter a tolerant reader, which lets a producer add fields without breaking existing consumers. The last payload has no buyerName, so it fails inside the adapter instead of reaching billing as a null.

// The canonical model. No producer
// or consumer owns these shapes.
// Amount is in major units, and
// Currency is an ISO 4217 code.
public record Money(decimal Amount, string Currency);
public record Party(string DisplayName);
public record Order(string OrderId, Money Total, Party Buyer);

The canonical model is three records. Money pairs an amount in major units, such as dollars or yen, with an ISO 4217 currency code. The currency travels as data, not in a field name like vendor A’s amountUsd. Party is a neutral word for any participant, and the order’s Buyer field names its role.

public static class VendorAAdapter
{
    // Vendor A's shape is private,
    // so no other code can use it.
    private record VendorOrderV1(
        string OrderRef, decimal AmountUsd, string BuyerName);

    public static Order ToCanonical(string json)
    {
        var src = JsonSerializer.Deserialize<VendorOrderV1>(json, Edge.Json)
            ?? throw new JsonException("Null payload.");
        return new Order(
            OrderId: src.OrderRef,
            Total: new Money(src.AmountUsd, "USD"),
            Buyer: new Party(src.BuyerName));
    }
}

A reference to VendorAAdapter.VendorOrderV1 from outside the adapter fails to compile.

public static class ShopBAdapter
{
    private record ShopOrder(
        string Id, long TotalMinor, string Currency, Name Customer);
    private record Name(string First, string Last);

    // Minor units per major unit,
    // as shop B counts them.
    private static readonly Dictionary<string, decimal> MinorPerMajor = new()
    {
        ["USD"] = 100m, ["EUR"] = 100m, ["JPY"] = 1m,
    };

    public static Order ToCanonical(string json)
    {
        var src = JsonSerializer.Deserialize<ShopOrder>(json, Edge.Json)
            ?? throw new JsonException("Null payload.");
        if (!MinorPerMajor.TryGetValue(src.Currency, out var perMajor))
        {
            throw new FormatException($"Unknown currency {src.Currency}.");
        }
        return new Order(
            OrderId: src.Id,
            Total: new Money(src.TotalMinor / perMajor, src.Currency),
            Buyer: new Party($"{src.Customer.First} {src.Customer.Last}"));
    }
}

Shop B’s amounts are in minor units, and how many make a major unit depends on the currency. ISO 4217 gives the US dollar two decimal places and the yen none, so 1999 means $19.99 in dollars but ¥1,999 in yen. An adapter that divides by one hundred everywhere is wrong for the yen by a factor of one hundred.

The minor-unit table describes shop B, not the standard, and Stripe’s API shows why that matters. Stripe takes amounts in the currency’s minor unit: 1000 charges 10 USD, and 10 charges 10 JPY. ISO 4217 gives the Icelandic krona no minor unit. Stripe’s documentation, though, says that “backward compatibility requires you to represent it as a two-decimal value,” so 500 charges 5 ISK. An adapter that read Stripe’s ISK amounts through the ISO table would turn 5 ISK into 500.

The canonical model also has gaps. Party holds one display name, so shop B’s first and last names merge into one string. If a consumer ever needs them apart, the model has to grow. Order also does not record which producer issued an OrderId, so two producers’ IDs could collide.

static class Edge
{
    // Skip unknown fields (the
    // default); throw on missing
    // or null required ones.
    public static readonly JsonSerializerOptions Json =
        new(JsonSerializerDefaults.Web)
        {
            RespectRequiredConstructorParameters = true,
            RespectNullableAnnotations = true,
        };
}

// Billing's own model: canonical
// types stop at its adapter.
record InvoiceDraft(
    string Reference, decimal Amount, string Currency, string BillTo);

static class BillingInbound
{
    public static InvoiceDraft FromCanonical(Order order) => new(
        Reference: order.OrderId,
        Amount: order.Total.Amount,
        Currency: order.Total.Currency,
        BillTo: order.Buyer.DisplayName);
}

An adapter should ignore what it does not need and reject what it cannot do without. Ignoring is the default. Rejecting is not: System.Text.Json treats constructor parameters as optional unless told otherwise, so a missing field quietly becomes a null or a zero. Two options introduced in .NET 9, RespectRequiredConstructorParameters and RespectNullableAnnotations, make a missing or null required field throw a JsonException at the edge.

Microsoft notes that RespectNullableAnnotations “only validates nullability annotations that are present on non-generic properties, fields, and constructor parameters.” It does not check the top-level type, so each adapter checks for a null payload itself. Billing’s inbound adapter then copies the canonical order into billing’s own record, and canonical types go no further.

Where It Fits

A canonical model is worth considering in these situations:

A canonical model is the wrong tool in these situations:

The wrong canonical model is worse than none.

Where one does fit, first check whether your industry already publishes a shared language. “Many industries establish published languages in the form of data interchange standards,” Evans notes, and HL7’s FHIR, “a standard for health care data exchange,” is one. A maintained standard spares you much of the design work, but not all of it. FHIR’s overview says resources are more often “combined and tailored to meet use case specific requirements” than used alone. The parties still have to agree on a version, constraints, and extensions.

The Strongest Objection

The sharpest criticism targets the enterprise-wide version. Stefan Tilkov published “Why You Should Avoid a Canonical Data Model” on 24 March 2015. It describes the idea in its “extreme (and very common) form,” with “just one kind of Person, Customer, Order, Product, etc.,” and calls it “a horrible, horrible idea.” His reason is the bounded context: “a Person or a Contract are different things in different contexts on a conceptual level.” He also rejects the defense that the model covers only interfaces: “You’re still trying to make everyone agree what a concept means.”

Tilkov left a narrow door open. A canonical data model is a universally bad idea, he wrote, “unless you approach it differently.” He doubted its value in many cases and preferred “a different and less intrusive kind of specification.” For teams that want one anyway, he suggested three remedies:

Fowler made a related argument in 2003. In “Multiple Canonical Models,” he wrote, “You can have several canonical models rather than just one.” The models, he added, “only need to cover everything that needs to be communicated between applications.” Several scoped models with clear owners are a different thing from competing formats for the same data, which only add translation targets.

Alternatives and Related Patterns

Two approaches replace a canonical model:

Three others solve neighboring problems and can sit beside one:

Pick by the shape of the integration problem, not by fashion.

Six Ways It Fails

When a canonical model goes wrong, it tends to fail in one of six ways.

Versioning the Model

Plan versioning before the first release. A model without a versioning strategy has two futures: it stops changing and goes stale, or it changes and breaks its consumers. Three approaches are common:

Two disciplines apply to all three. First, give each schema version an identity of its own, and have each message name the one it follows. Confluent’s serializers write a schema identifier into each message, in front of the payload or in a header. CloudEvents defines a dataschema attribute for the same purpose, and its specification says that “Incompatible changes to the schema SHOULD be reflected by a different URI.” A JSON Schema can declare that URI in its own $id keyword. Second, treat the canonical model as a product with a lifecycle and owners, not as a document someone finished once.

Compatibility Rules That Last

Versions help only with clear compatibility rules. Confluent’s Schema Registry documentation defines the terms precisely:

The same documentation ties each level to a rollout order. Under backward compatibility, consumers go first, and all of them upgrade before any new events are produced. Under forward compatibility, producers go first, and consumers upgrade only after data written with the older schema is no longer available to them. Full compatibility means “you can upgrade the producers and consumers independently,” but it allows the fewest kinds of change. By default, the registry checks a new schema only against the latest one. Transitive checking, against every earlier version, is “important once you have more than two versions of a schema.”

Three defaults survive the long run:

Avro and Protobuf define what is compatible; tools enforce it. The registry enforces the chosen level by checking each new schema version. Buf’s buf breaking command runs a similar check on Protobuf schemas, against a past version such as the main branch, locally or in CI. JSON Schema is the exception. Confluent notes that it “does not explicitly define compatibility rules,” so the registry’s own rules decide what counts as compatible.

Even a passing check shows only that the schemas are compatible, not that each consumer still behaves correctly. Protobuf’s guide warns that wire-safe changes “may be a breaking change to application code.” Adding a value to an existing enum, for example, breaks compilation of any code with an exhaustive switch on it. Consumer tests still have to guard the behavior each consumer depends on.

Governance Is the Work

Without governance, a canonical model fails quietly. At a minimum, it needs these five things:

Before deprecating a field, owners need to know who reads it. Fowler suggests that consumers say which parts they read. A good way, he writes, is to “send them the reader and its tests,” so the provider can run them in its build. It is a short step from there to consumer-driven contracts.

Start with One Data Type

If a canonical model fits, do not start with a grand model that covers everything. Start with the integration that hurts most:

  1. Pick one shared data type that at least two real producers send to at least two real consumers.
  2. Build the canonical form for that type only, with its adapters, a schema identity, and a compatibility level.
  3. Prove the whole loop before expanding: test the adapters, release a second schema version, and take one change through the governance process.
  4. Add a second data type only after the first works.
  5. Retire point-to-point connections only after the canonical path proves stable in production.

Fowler pointed the same way in 2003: canonical models “can be built through harvesting, rather than planned up-front,” introduced where applications already communicate pairwise. A model that grows from real needs has a reason for every field. A model designed up front, before any real exchange, has to guess, and I would not bet on its guesses.

Checklist

A canonical data model is a decoupling tool, not a centralization tool; when it starts to feel like centralization, something is off. It pays off with many-to-many integration and a high cost of vocabulary drift. Its hidden costs—versioning, governance, and ownership—are not optional; they are the work. Pattern correctness is not the same as pattern fit. Architecture is the art of trade-offs, not the application of templates.

Further Reading