Sechs Services. Alle kennen das Konzept “Kunde”. Keiner meint dasselbe.
Der CRM-Service hat firstName, lastName, email. Der Billing-Service hat customer_name als ein Feld, dazu billing_email und tax_id. Der Shipping-Service kennt recipient mit fullName und einer verschachtelten address. Der Analytics-Service hat user_id und signup_date, sonst nichts. Der Support-Service hat kontaktName (ja, Deutsch, weil der Kollege der ihn gebaut hat, seine Felder so benannt hat) und vorgangsnummer.
Fünf verschiedene Repräsentationen derselben Entität. Jetzt sollen die miteinander reden.
Die Mapper-Explosion
Die naive Lösung: für jede Verbindung schreibst du einen Mapper. CRM → Billing, CRM → Shipping, Billing → Analytics, Support → CRM. Bei sechs Services sind das im schlimmsten Fall 30 Mapper (n × (n-1)). Jeder davon ist handgeschrieben, jeder macht subtil andere Annahmen über Null-Werte, Datumsformate und ob ein Name ein Feld oder zwei Felder ist.
function crmToBilling(crmCustomer: CrmCustomer): BillingCustomer {
return {
customer_name: `${crmCustomer.firstName} ${crmCustomer.lastName}`,
billing_email: crmCustomer.email,
tax_id: null, // CRM hat keine Tax ID
};
}
Funktioniert. Bis der CRM-Service ein middleName-Feld hinzufügt. Jetzt musst du jeden Mapper anfassen, der firstName und lastName zu einem customer_name zusammenbaut. Das sind fünf Mapper. Du vergisst einen. Billing zeigt drei Monate lang den Vornamen ohne Zweitnamen an und niemand merkt es, weil die Tests den Mapper isoliert testen und der Test kein middleName im Fixture hat.
Und es wird schlimmer. Der Billing-Service ändert tax_id von string | null zu einem Objekt mit type und value, weil sie jetzt zwischen USt-IdNr und Steuernummer unterscheiden müssen. Jeder Service der mit Billing redet, muss seinen Mapper anpassen. Aber der Analytics-Service hat gar keinen direkten Mapper zu Billing, er bekommt die Tax ID über den CRM-Service weitergereicht. Also muss der CRM-Billing-Mapper UND der CRM-Analytics-Mapper angepasst werden. Und der Entwickler der den Analytics-Mapper anfasst, versteht nicht warum sich plötzlich die Tax ID Struktur geändert hat, weil er die Billing-Änderung nie gesehen hat.
Das ist kein Skalierungsproblem. Das ist ein Wissensproblem. Jeder Mapper kodiert implizites Wissen über das Format eines anderen Services. Und implizites Wissen veraltet.
Die Idee: ein kanonisches Modell
Canonical Data Model löst das Problem durch eine Zwischenschicht. Statt dass jeder Service das Format jedes anderen Services kennt, definierst du ein einziges, kanonisches Format für jede Entität. Jeder Service übersetzt nur noch in zwei Richtungen: von seinem internen Format ins kanonische und vom kanonischen zurück.
interface CanonicalCustomer {
id: string;
name: {
given: string;
middle?: string;
family: string;
display: string;
};
email: {
primary: string;
billing?: string;
};
taxIdentifier?: {
type: "vat" | "tax_number" | "ein";
value: string;
country: string;
};
address?: {
street: string;
city: string;
postalCode: string;
country: string; // ISO 3166-1 alpha-2
};
metadata: {
createdAt: string; // ISO 8601
updatedAt: string;
sourceService: string;
};
}
Statt 30 Mapper brauchst du jetzt 12 (2 × n). Jeder Service hat genau einen Mapper rein und einen raus. Wenn der CRM-Service middleName hinzufügt, passt du einen Mapper an: CRM → Canonical. Alle anderen Services bekommen das middle-Feld über das kanonische Modell, wenn sie es brauchen. Wenn Billing sein Tax-ID-Format ändert, passt Billing seinen Mapper an. Kein anderer Service ist betroffen, solange das kanonische Format stabil bleibt.
Der Begriff “Canonical Data Model” kommt aus Gregor Hohpes Enterprise Integration Patterns von 2003. Das Buch ist über 20 Jahre alt und dieser Teil ist relevanter als je zuvor, weil Microservices das Problem um Größenordnungen verschärft haben.
Die Mathematik ist simpel aber überzeugend. Point-to-Point Mapper wachsen quadratisch: n × (n-1). Kanonische Mapper wachsen linear: 2 × n. Bei 6 Services ist der Unterschied 30 vs 12. Bei 15 Services ist es 210 vs 30. Bei 30 Services, was in größeren Unternehmen nicht ungewöhnlich ist, 870 vs 60. Das ist nicht nur weniger Code. Das sind weniger Bugs, weniger Koordination, weniger implizites Wissen das in Mapper-Funktionen versteckt ist.
Canonical Entity Layer
Das kanonische Modell allein ist eine Typ-Definition. Ein Interface in einem Dokument. Damit es funktioniert, brauchst du eine Schicht, die die Übersetzung übernimmt, die Validierung erzwingt und die Konsistenz sicherstellt: den Canonical Entity Layer.
Das ist kein Framework und kein Service. Das ist ein Package, das jeder Service als Dependency einbindet und das vier Dinge tut:
1. Typ-Definitionen. Die kanonischen Interfaces leben in einem shared Package. Eine Source of Truth für die Struktur jeder Entität. Nicht ein Wiki-Eintrag den niemand aktualisiert, sondern Code der kompiliert werden muss.
2. Schemas mit Runtime-Validierung. Jede Nachricht, die das kanonische Format durchläuft, wird gegen ein Schema validiert. Nicht zur Laufzeit optional, sondern immer. Wenn ein Mapper ein Pflichtfeld vergisst, fliegt das beim ersten Request auf, nicht drei Monate später.
3. Mapper-Contracts. Das Package definiert das Interface das jeder Mapper implementieren muss: toCanonical und fromCanonical. Das erzwingt, dass jeder Service beide Richtungen abdeckt.
4. Test Fixtures. Das Package liefert valide Beispiel-Instanzen jeder kanonischen Entität. Jeder Service kann seine Mapper gegen diese Fixtures testen, ohne selbst Testdaten erfinden zu müssen.
import { z } from "zod";
export const CanonicalCustomerSchema = z.object({
id: z.string().uuid(),
name: z.object({
given: z.string().min(1),
middle: z.string().optional(),
family: z.string().min(1),
display: z.string().min(1),
}),
email: z.object({
primary: z.string().email(),
billing: z.string().email().optional(),
}),
taxIdentifier: z
.object({
type: z.enum(["vat", "tax_number", "ein"]),
value: z.string().min(1),
country: z.string().length(2),
})
.optional(),
address: z
.object({
street: z.string(),
city: z.string(),
postalCode: z.string(),
country: z.string().length(2),
})
.optional(),
metadata: z.object({
createdAt: z.string().datetime(),
updatedAt: z.string().datetime(),
sourceService: z.string().min(1),
}),
});
export type CanonicalCustomer = z.infer<typeof CanonicalCustomerSchema>;
import type { CanonicalCustomer } from "./customer";
export const CUSTOMER_FIXTURES: Record<string, CanonicalCustomer> = {
minimal: {
id: "550e8400-e29b-41d4-a716-446655440000",
name: {
given: "Max",
family: "Müller",
display: "Max Müller",
},
email: { primary: "max@example.com" },
metadata: {
createdAt: "2026-01-15T10:30:00Z",
updatedAt: "2026-01-15T10:30:00Z",
sourceService: "crm-service",
},
},
full: {
id: "550e8400-e29b-41d4-a716-446655440001",
name: {
given: "Anna",
middle: "Marie",
family: "Schmidt",
display: "Anna Marie Schmidt",
},
email: {
primary: "anna@example.com",
billing: "billing@schmidtgmbh.de",
},
taxIdentifier: {
type: "vat",
value: "DE123456789",
country: "DE",
},
address: {
street: "Hauptstraße 42",
city: "Berlin",
postalCode: "10115",
country: "DE",
},
metadata: {
createdAt: "2025-06-01T08:00:00Z",
updatedAt: "2026-03-10T14:22:00Z",
sourceService: "crm-service",
},
},
};
Die Fixtures sind wichtiger als sie aussehen. Ohne sie erfindet jedes Team seine eigenen Testdaten, und diese Testdaten driften auseinander. Der CRM-Service testet mit einem Kunden der kein middle hat. Der Billing-Service testet mit einem der keine address hat. Keiner testet den Fall, den der andere als Default annimmt. Gemeinsame Fixtures eliminieren diese Klasse von Bugs.
export interface CanonicalMapper<TInternal, TCanonical> {
toCanonical(internal: TInternal): TCanonical;
fromCanonical(canonical: TCanonical): TInternal;
}
import {
type CanonicalCustomer,
CanonicalCustomerSchema,
type CanonicalMapper,
} from "@company/canonical-entities";
export const crmCustomerMapper: CanonicalMapper<
CrmCustomer,
CanonicalCustomer
> = {
toCanonical(crm: CrmCustomer): CanonicalCustomer {
const canonical = {
id: crm.id,
name: {
given: crm.firstName,
middle: crm.middleName || undefined,
family: crm.lastName,
display: [crm.firstName, crm.middleName, crm.lastName]
.filter(Boolean)
.join(" "),
},
email: {
primary: crm.email,
},
metadata: {
createdAt: crm.createdAt.toISOString(),
updatedAt: crm.updatedAt.toISOString(),
sourceService: "crm-service",
},
};
return CanonicalCustomerSchema.parse(canonical);
},
fromCanonical(canonical: CanonicalCustomer): CrmCustomer {
return {
id: canonical.id,
firstName: canonical.name.given,
middleName: canonical.name.middle ?? null,
lastName: canonical.name.family,
email: canonical.email.primary,
createdAt: new Date(canonical.metadata.createdAt),
updatedAt: new Date(canonical.metadata.updatedAt),
};
},
};
Das display-Feld im name-Objekt ist bewusst redundant. Services die nur
einen lesbaren Namen brauchen (Analytics, Support) nehmen display und müssen
nicht wissen, wie ein Name zusammengesetzt wird. Services die einzelne
Namensbestandteile brauchen (CRM, Formulare) nehmen given/family.
Redundanz im kanonischen Modell ist nicht das Gleiche wie Redundanz in einer
Datenbank. Hier dient sie der Entkopplung.
Der volle Fluss: Event-Driven
Die meiste Kommunikation über kanonische Modelle passiert nicht synchron über HTTP. Sie passiert asynchron über Events. Und da entfaltet sich der eigentliche Wert.
Ein Beispiel: der CRM-Service aktualisiert eine Kundenadresse. Fünf andere Services müssen davon erfahren. Ohne kanonisches Modell publisht der CRM-Service ein Event in seinem eigenen Format, und jeder Consumer muss den CRM-spezifischen Mapper kennen. Wenn CRM sein Format ändert, brechen fünf Consumer.
Mit kanonischem Modell: CRM konvertiert intern ins kanonische Format, publisht ein CustomerUpdated-Event mit dem kanonischen Payload, und jeder Consumer versteht es, weil er nur das kanonische Schema kennt.
import { crmCustomerMapper } from "../mappers/customer";
import type { CanonicalEnvelope } from "@company/canonical-entities";
async function publishCustomerUpdate(crmCustomer: CrmCustomer) {
const canonical = crmCustomerMapper.toCanonical(crmCustomer);
const event: CanonicalEnvelope<CanonicalCustomer> = {
eventType: "customer.updated",
schemaName: "customer",
schemaVersion: 3,
producedAt: new Date().toISOString(),
producerService: "crm-service",
correlationId: crypto.randomUUID(),
payload: canonical,
};
await kafka.produce("customer-events", {
key: canonical.id,
value: JSON.stringify(event),
});
}
import {
CanonicalCustomerSchema,
type CanonicalEnvelope,
} from "@company/canonical-entities";
import { billingCustomerMapper } from "../mappers/customer";
async function handleCustomerEvent(raw: CanonicalEnvelope<unknown>) {
if (raw.schemaVersion > SUPPORTED_VERSION) {
await deadLetterQueue.send(raw);
metrics.increment("customer.unsupported_version");
return;
}
const canonical = CanonicalCustomerSchema.parse(raw.payload);
const billingCustomer = billingCustomerMapper.fromCanonical(canonical);
await db.upsert("billing_customers", billingCustomer);
}
Das correlationId-Feld im Envelope ist kein Nice-to-have. Wenn ein Customer
Update durch sechs Services fließt und irgendwo ein Fehler auftritt, brauchst
du eine ID die den gesamten Fluss verbindet. Ohne sie debuggst du mit
Timestamps und Hoffnung.
Der Billing-Service validiert den Payload gegen das kanonische Schema, konvertiert in sein internes Format und speichert. Wenn die Validierung fehlschlägt, geht die Nachricht in die Dead Letter Queue. Kein stiller Fehler. Kein korrupter State.
Beziehungen zwischen kanonischen Entitäten
Einzelne Entitäten sind der einfache Fall. Es wird interessanter wenn Entitäten aufeinander verweisen. Eine Order gehört zu einem Customer. Ein LineItem gehört zu einer Order und referenziert ein Product.
Zwei Ansätze, und beide haben gute Gründe.
ID-Referenzen. Die Order enthält eine customerId, nicht den ganzen Customer. Der Consumer muss den Customer separat fetchen wenn er ihn braucht. Vorteil: die Entitäten sind unabhängig, Updates an einem Customer erfordern kein Update an allen seinen Orders. Nachteil: der Consumer braucht möglicherweise zwei Calls statt einem.
interface CanonicalOrder {
id: string;
customerId: string; // Referenz, nicht eingebettet
lineItems: CanonicalLineItem[];
total: { amount: number; currency: string };
status: "pending" | "confirmed" | "shipped" | "cancelled";
metadata: CanonicalMetadata;
}
Embedded Snapshots. Die Order enthält einen Snapshot des Customers zum Zeitpunkt der Bestellung. Vorteil: der Consumer hat alles was er braucht in einer Nachricht. Nachteil: der Snapshot ist potenziell veraltet, und du musst entscheiden welche Felder du einbettest.
interface CanonicalOrder {
id: string;
customer: {
id: string;
name: { display: string };
email: { primary: string };
// Nur die Felder die zum Zeitpunkt der Bestellung relevant sind
};
lineItems: CanonicalLineItem[];
total: { amount: number; currency: string };
status: "pending" | "confirmed" | "shipped" | "cancelled";
metadata: CanonicalMetadata;
}
Ich nutze ID-Referenzen als Default und Embedded Snapshots wenn der Consumer die Daten zum Zeitpunkt des Events braucht, nicht den aktuellen Stand. Eine Rechnung muss die Adresse enthalten die der Kunde bei Bestellung hatte, nicht seine aktuelle. Das ist ein fachlicher Grund, kein technischer.
Wo das kanonische Modell lebt
Drei Optionen. Jede mit unterschiedlichen Trade-offs.
Shared Library. Ein NPM-Package (oder Maven-Artifact, Crate, was auch immer) das alle Services als Dependency einbinden. Vorteil: Compile-Time-Checks, IDE-Support, Typ-Sicherheit. Nachteil: jede Änderung am kanonischen Modell erfordert ein neues Release und alle Services müssen updaten. Das kann bei 20 Services dauern.
Schema Registry. Confluent Schema Registry, AWS Glue, oder selbstgebaut. Das Schema lebt zentral, Services fetchen es zur Laufzeit oder beim Build. Vorteil: kein Redeployment aller Services bei Schema-Änderung, Versionierung eingebaut. Nachteil: Runtime-Dependency auf die Registry, Validierung ist langsamer.
API Gateway / Message Broker Translation. Der Broker oder das Gateway übernimmt die Übersetzung. Services schicken ihr internes Format, das Gateway mappt ins kanonische. Vorteil: Services müssen nichts vom kanonischen Modell wissen. Nachteil: die gesamte Mapping-Logik sitzt an einer Stelle, und diese Stelle wird zum Bottleneck, organisatorisch und technisch.
Ich bevorzuge die Shared Library für Teams unter 10 Services und die Schema Registry darüber. Der API-Gateway-Ansatz klingt verlockend, schafft aber einen zentralen Engpass, den du nicht haben willst.
Polyglotte Umgebungen (Java, TypeScript, Go) machen Shared Libraries
schwieriger. Protobuf hilft hier: du definierst das Schema einmal in
.proto-Files und generierst typsichere Clients für jede Sprache. Das
kanonische Modell lebt dann als Protobuf-Schema, nicht als
TypeScript-Interface.
Serialisierungsformate
JSON ist der Default. Einfach zu debuggen, jede Sprache kann es lesen, und für die meisten Systeme reicht die Performance. Aber ab einer gewissen Größe wirst du über Alternativen nachdenken.
JSON Schema. Du bleibst bei JSON, fügst aber ein formales Schema hinzu. Validierung ist möglich, Tooling existiert, und du kannst das Schema in einer Registry versionieren. Nachteil: keine eingebauten Compatibility-Checks. Du musst selbst sicherstellen, dass Version 4 backward-compatible zu Version 3 ist.
Avro. Binäres Format mit Schema-Evolution als Kernfeature. Das Schema wird separat gespeichert (in der Schema Registry), die Nachricht enthält nur die Daten. Confluent Schema Registry erzwingt Compatibility-Rules (BACKWARD, FORWARD, FULL, NONE) automatisch. Du kannst kein Breaking Change deployen, weil die Registry es ablehnt. Das ist der Standardweg in Kafka-basierten Systemen.
Protobuf. Googles Serialisierungsformat. Ähnlich wie Avro binär und kompakt, aber mit einem anderen Ansatz für Schema Evolution: Felder haben Nummern statt Namen, und du fügst neue Felder mit neuen Nummern hinzu. Alte Consumer ignorieren unbekannte Feldnummern. Besonders stark in polyglotten Umgebungen, weil der protoc-Compiler Clients für ein Dutzend Sprachen generiert.
syntax = "proto3";
package canonical;
message Customer {
string id = 1;
CustomerName name = 2;
CustomerEmail email = 3;
optional TaxIdentifier tax_identifier = 4;
optional Address address = 5;
EntityMetadata metadata = 6;
}
message CustomerName {
string given = 1;
optional string middle = 2;
string family = 3;
string display = 4;
}
message TaxIdentifier {
TaxIdType type = 1;
string value = 2;
string country = 3;
}
enum TaxIdType {
TAX_ID_TYPE_UNSPECIFIED = 0;
TAX_ID_TYPE_VAT = 1;
TAX_ID_TYPE_TAX_NUMBER = 2;
TAX_ID_TYPE_EIN = 3;
}
Für die meisten Teams: fang mit JSON und Zod an. Wenn du merkst, dass die Nachrichtengrößen ein Problem werden oder du Compatibility-Checks automatisieren willst, migriere auf Avro (Kafka) oder Protobuf (gRPC, polyglott). Die Migration ist einfacher als du denkst, weil das kanonische Modell die Struktur schon definiert. Du änderst nur die Serialisierung, nicht die Semantik.
Schema Evolution
Das kanonische Modell ist nicht statisch. Felder kommen dazu, Formate ändern sich, Entitäten werden aufgeteilt. Die Frage ist nicht ob, sondern wie du mit Änderungen umgehst, ohne alles gleichzeitig deployen zu müssen.
Drei Regeln, die ich für nicht verhandelbar halte:
Backward Compatibility. Neue Felder sind optional. Bestehende Felder ändern ihren Typ nicht. Ein Consumer, der die alte Version versteht, muss auch die neue Version lesen können, auch wenn er die neuen Felder ignoriert. Wenn du ein Pflichtfeld hinzufügen musst, ist das eine neue Major-Version und du musst beide Versionen parallel unterstützen bis alle Consumer migriert sind.
Forward Compatibility. Ein neuer Consumer muss auch alte Nachrichten verarbeiten können. Das heißt: dein Code darf nicht crashen wenn ein Feld fehlt, das in der neuesten Version existiert. Defensive Defaults. canonical.taxIdentifier?.type ?? "unknown". Klingt offensichtlich, wird trotzdem ständig vergessen.
Explicit Versioning. Jede Nachricht trägt ihre Schema-Version. Nicht implizit über den Timestamp, nicht über die API-Version, sondern als Feld in der Nachricht selbst. schemaVersion: 3. Wenn ein Consumer Version 3 nicht kennt, kann er entscheiden: ignorieren, in eine Dead Letter Queue schreiben, oder einen Alert feuern. Aber er weiß, dass er etwas nicht kennt.
interface CanonicalEnvelope<T> {
eventType: string;
schemaName: string;
schemaVersion: number;
producedAt: string;
producerService: string;
correlationId: string;
payload: T;
}
Was passiert bei einem Breaking Change? Nehmen wir an, address war bisher ein einzelnes Objekt und muss jetzt ein Array werden, weil Kunden mehrere Adressen haben können. Das ist nicht backward-compatible. Du kannst address nicht von einem Objekt zu einem Array ändern ohne jeden Consumer zu brechen.
// Schema v3: address ist ein Objekt
// Schema v4: addresses ist ein Array, address deprecated
interface CanonicalCustomerV4 {
// ... alle bisherigen Felder ...
address?: CanonicalAddress; // Deprecated, aber noch befüllt
addresses: CanonicalAddress[]; // Neu: Array mit allen Adressen
}
// Übergangsphase: Producer füllt beides
function toCanonicalV4(crm: CrmCustomer): CanonicalCustomerV4 {
const primaryAddress = crm.addresses[0];
return {
// ...
address: primaryAddress, // Für v3-Consumer
addresses: crm.addresses.map(mapAddress), // Für v4-Consumer
};
}
Du publishst beide Felder parallel. V3-Consumer lesen address und ignorieren addresses. V4-Consumer lesen addresses und ignorieren address. Wenn alle Consumer auf v4 migriert sind, entfernst du address in v5. Dual-Write ist hässlich, aber es funktioniert und es bricht nichts.
Avro und Protobuf lösen das eleganter. In Protobuf fügst du repeated Address addresses = 7; hinzu und alte Consumer ignorieren das Feld. Aber irgendwann
musst du trotzdem das alte Feld entfernen, und dafür brauchst du denselben
Migrationsprozess.
Anti-Corruption Layer und Canonical Model
Wenn du den DDD-Artikel gelesen hast, kennst du den Anti-Corruption Layer (ACL): eine Übersetzungsschicht die verhindert, dass das Modell eines fremden Systems in deinen Bounded Context leckt. Das kanonische Modell und der ACL ergänzen sich.
Der ACL sitzt nach dem kanonischen Modell. Dein Service empfängt eine kanonische Nachricht, und der ACL übersetzt sie in dein internes Domain Model. Das kanonische Modell ist die Lingua Franca zwischen Services. Dein internes Model ist deine eigene Sprache.
[CRM] → CRM-Mapper → [Canonical] → Kafka → [Canonical] → ACL → [Billing Domain Model]
Warum nicht direkt vom kanonischen Modell arbeiten? Weil das kanonische Modell ein Kompromiss ist. Es muss sechs Services bedienen. Dein internes Model muss nur dich bedienen. Es kann genauer sein, strengere Typen haben, Business-Regeln erzwingen die im kanonischen Modell keinen Sinn ergeben.
import type { CanonicalCustomer } from "@company/canonical-entities";
import type { BillingAccount } from "../domain/billingAccount";
export function toBillingAccount(canonical: CanonicalCustomer): BillingAccount {
if (!canonical.taxIdentifier) {
throw new MissingTaxIdentifierError(canonical.id);
}
return {
accountId: canonical.id,
displayName: canonical.name.display,
billingEmail: canonical.email.billing ?? canonical.email.primary,
taxId: {
type: canonical.taxIdentifier.type,
value: canonical.taxIdentifier.value,
country: canonical.taxIdentifier.country,
},
// Billing braucht kein middle name, keine Adresse,
// kein createdAt. Wird ignoriert.
dunningLevel: 0, // Default für neue Accounts
paymentTermDays: 30, // Business-Default
};
}
Der ACL wirft wenn die Tax ID fehlt. Das ist eine Business-Regel des Billing-Kontexts, nicht des kanonischen Modells. Im kanonischen Modell ist taxIdentifier optional, weil nicht jeder Service es braucht. Für Billing ist es Pflicht. Der ACL erzwingt das.
Mapper testen
Mapper sind einer der häufigsten Fehlerquellen in verteilten Systemen. Nicht weil sie komplex sind, sondern weil sie langweilig sind und niemand sie gründlich testet. Drei Teststrategien die sich ergänzen:
Roundtrip-Tests. fromCanonical(toCanonical(x)) muss das Original zurückgeben. Nicht bitweise identisch (Timestamps können sich unterscheiden), aber semantisch äquivalent. Das fängt Datenverlust ab: wenn toCanonical ein Feld vergisst, kommt es beim Roundtrip nicht zurück.
import { crmCustomerMapper } from "../customer";
import { CUSTOMER_FIXTURES } from "@company/canonical-entities/fixtures";
test("roundtrip: CRM → Canonical → CRM", () => {
const original: CrmCustomer = {
id: "550e8400-e29b-41d4-a716-446655440000",
firstName: "Max",
middleName: null,
lastName: "Müller",
email: "max@example.com",
createdAt: new Date("2026-01-15T10:30:00Z"),
updatedAt: new Date("2026-01-15T10:30:00Z"),
};
const canonical = crmCustomerMapper.toCanonical(original);
const restored = crmCustomerMapper.fromCanonical(canonical);
expect(restored.firstName).toBe(original.firstName);
expect(restored.lastName).toBe(original.lastName);
expect(restored.email).toBe(original.email);
});
test("fromCanonical handles full fixture", () => {
const result = crmCustomerMapper.fromCanonical(CUSTOMER_FIXTURES.full);
expect(result.firstName).toBe("Anna");
expect(result.middleName).toBe("Marie");
expect(result.lastName).toBe("Schmidt");
});
Fixture-Tests. Jeder Mapper muss die kanonischen Fixtures verarbeiten können. Wenn das kanonische Package eine neue Fixture hinzufügt (z.B. einen Kunden mit drei Adressen nach v4), müssen alle Mapper-Tests grün bleiben oder bewusst brechen. Das ist dein Frühwarnsystem für Schema-Änderungen.
Contract-Tests. Der Producer publisht eine Nachricht. Der Consumer parsed sie. Contract-Tests verifizieren, dass dieser Fluss funktioniert, ohne dass Producer und Consumer gleichzeitig laufen müssen. Tools wie Pact machen das formalisiert, aber für den Anfang reicht: der Producer schreibt seine Beispiel-Outputs in eine Datei, der Consumer liest sie in seinen Tests.
Häufige Fehler beim Design
Ich habe kanonische Modelle scheitern sehen. Meistens an denselben Stellen.
Das God-Model. Ein kanonisches Customer-Modell mit 80 Feldern, weil jeder Service seine Felder drangehängt hat. loyaltyPoints (nur Marketing), lastSupportTicketId (nur Support), preferredCarrier (nur Shipping). Das kanonische Modell wird zum God Object. Die Lösung: das kanonische Modell enthält nur Felder, die mindestens zwei Services brauchen. Alles andere bleibt service-intern.
Zu viele Entitäten. Nicht jedes Objekt in deinem System braucht eine kanonische Repräsentation. Customer, Order, Product sind Kandidaten, weil viele Services sie kennen. InvoiceLineItemTaxBreakdown ist kein Kandidat, weil nur der Billing-Service ihn braucht. Kanonische Modelle für Entitäten die nur ein Service nutzt, sind Overhead ohne Nutzen.
Geschäftslogik im Mapper. Der Mapper konvertiert Formate. Er trifft keine Entscheidungen. Wenn dein Mapper if (customer.country === "DE") { taxRate = 0.19 } enthält, gehört das in den Service, nicht in den Mapper. Mapper sind dumm. Absichtlich.
Fehlende Validierung. “Wir vertrauen darauf, dass der Producer valide Daten schickt.” Nein. Validiere auf beiden Seiten. Der Producer validiert beim Serialisieren, der Consumer beim Deserialisieren. Doppelte Validierung ist billiger als korrupter State in der Datenbank.
Das Modell wird zum API-Contract. Das kanonische Modell definiert die Struktur einer Entität. Es definiert nicht die API. Dein REST-Endpoint muss nicht 1:1 das kanonische Modell zurückgeben. Er kann Felder weglassen, umbenennen, verschachteln. Die API ist eine Projektion des kanonischen Modells, nicht das Modell selbst.
Die Kosten
Ich will nicht so tun als wäre ein Canonical Data Model kostenlos. Es hat reale Nachteile, und du solltest sie kennen bevor du dich commitest.
Lowest Common Denominator. Das kanonische Modell muss alle Services bedienen. Das führt dazu, dass es entweder zu generisch wird (alles ist string und optional) oder zu spezifisch (es kennt Felder, die nur ein Service braucht). Die Balance zu finden ist schwer und erfordert ständige Pflege.
Governance. Irgendjemand muss entscheiden, was ins kanonische Modell gehört und was nicht. Wenn jeder Service-Owner einfach Felder hinzufügen kann, hast du nach einem Jahr ein Monster mit 200 Feldern. Wenn ein zentrales Team jede Änderung genehmigen muss, wartet jeder drei Sprints auf ein neues Feld. Du brauchst einen Prozess, und Prozesse kosten Zeit. Was in der Praxis funktioniert: ein RFC-Prozess. Wer ein Feld hinzufügen will, schreibt einen kurzen Vorschlag, die betroffenen Teams reviewen, und innerhalb einer Woche wird entschieden. Kein Komitee, kein Meeting, ein Dokument und ein Approval.
Indirection. Statt direkt von A nach B zu mappen, gehst du über eine Zwischenschicht. Das macht Debugging schwieriger. Wenn Billing einen falschen Namen anzeigt, musst du jetzt prüfen: ist der CRM-Mapper kaputt? Oder der Billing-Mapper? Oder das kanonische Modell selbst? Drei Stellen statt einer. Gutes Logging hilft: jeder Mapper loggt die Eingabe und die Ausgabe im Debug-Level. In Produktion aus, beim Debugging an.
Upfront Investment. Du musst das kanonische Modell designen bevor du Services baust, die es nutzen. Das heißt du musst alle Felder, Typen und Beziehungen durchdenken, bevor du weißt welche du tatsächlich brauchst. Oder du fängst klein an und evolvierst, was pragmatischer ist, aber auch heißt, dass du die ersten zwei Versionen vermutlich wegwirfst.
Wann es sich lohnt
Nicht jedes System braucht ein Canonical Data Model. Wenn du zwei Services hast, die über eine API reden, schreib einen Mapper und fertig. Der Overhead eines kanonischen Modells lohnt sich nicht.
Ab fünf Services, die dieselben Entitäten teilen, ändert sich die Rechnung. Die Mapper-Explosion wird real. Jede Änderung an einer Entität zieht eine Kaskade nach sich. Neue Entwickler brauchen Tage um zu verstehen, welcher Service welches Format erwartet. Dann lohnt es sich.
Besonders relevant wird es bei Event-Driven Architectures. Wenn deine Services über Kafka, RabbitMQ oder SQS kommunizieren, fliegen Events durch das System, die mehrere Consumer lesen. Ohne kanonisches Format muss jeder Consumer das Format des Producers kennen. Mit kanonischem Format kennt er nur das kanonische Schema. Der Unterschied zwischen “jeder kennt jeden” und “jeder kennt einen Standard” wächst exponentiell mit der Anzahl der Services.
Und es lohnt sich besonders wenn dein Team wächst. Zwei Entwickler können sich über Slack abstimmen welches Format ein Feld hat. Zwanzig können das nicht. Das kanonische Modell ist dokumentierte, validierte, getestete Kommunikation. Es ersetzt nicht die Abstimmung, aber es reduziert die Fläche auf der Missverständnisse entstehen.
Abgrenzung zu verwandten Konzepten
Canonical Data Model vs. Shared Database. Eine Shared Database löst das Problem durch eine gemeinsame Datenquelle. Alle Services lesen und schreiben dieselben Tabellen. Das funktioniert bis du Schema-Änderungen machen willst und alle Services gleichzeitig deployen musst. Oder bis ein Service einen Index braucht der einen anderen Service verlangsamt. Canonical Data Model entkoppelt die Datenformate, nicht die Daten selbst. Jeder Service hat seine eigene DB, sein eigenes Schema, optimiert für seinen Use-Case.
Canonical Data Model vs. API Contracts. OpenAPI-Specs definieren das Format einer einzelnen API. Ein Canonical Data Model geht weiter: es definiert das Format einer Entität unabhängig von der API. Der API-Contract sagt “dieser Endpoint liefert dieses JSON”. Das kanonische Modell sagt “ein Kunde sieht immer so aus, egal über welchen Kanal er kommt”. Du kannst (und solltest) deine API-Contracts aus dem kanonischen Modell ableiten, aber sie sind nicht dasselbe.
Canonical Data Model vs. Domain Events. Domain Events beschreiben was passiert ist (“CustomerCreated”). Das kanonische Modell beschreibt die Entität, die im Event steckt. Die beiden ergänzen sich: dein CustomerCreated-Event trägt einen CanonicalCustomer als Payload. Das Event-Format (Envelope mit Typ, Version, Timestamp) ist eine separate Concern vom Entity-Format.
Canonical Data Model vs. GraphQL. GraphQL löst ein ähnliches Problem von der Consumer-Seite: der Client fragt nur die Felder an, die er braucht. Aber GraphQL definiert kein kanonisches Modell. Es definiert ein Query-Interface. Dahinter kann das gleiche Chaos aus unterschiedlichen Service-Formaten existieren. Du kannst beides kombinieren: ein GraphQL-Gateway das intern auf kanonische Modelle mappt.
Praktischer Einstieg
Wenn du morgen anfangen willst, nicht nächsten Monat:
Nimm die Entität, die in den meisten Services vorkommt. Vermutlich Customer, Order oder User. Schau dir an, wie jeder Service sie repräsentiert. Schreib die Felder nebeneinander in eine Tabelle. Finde die Schnittmenge und die Abweichungen. Bau das kanonische Modell um die Schnittmenge, mach die Abweichungen optional.
Dann schreib die Mapper für zwei Services. Nicht sechs. Zwei. Steck das kanonische Modell in ein Package, validier es mit Zod oder JSON Schema, und verschieb die Kommunikation dieser zwei Services aufs kanonische Format. Schreib Roundtrip-Tests. Schreib Fixture-Tests. Wenn es funktioniert, zieh den nächsten Service rein. Wenn es nicht funktioniert, hast du zwei Mapper weggeworfen statt zwölf.
Wenn du schon eine Event-Driven Architecture hast: fang bei den Events an. Definiere kanonische Payloads für deine drei wichtigsten Event-Typen. Validiere sie im Producer und im Consumer. Das allein eliminiert eine ganze Klasse von Bugs, auch ohne dass du den gesamten Entity Layer aufbaust.
Fang klein an. Evolve. Werf weg was nicht passt. Das kanonische Modell, das du in sechs Monaten hast, wird anders aussehen als das, das du heute designst. Das ist in Ordnung. Jedes Modell das du nicht geschrieben hast, ist schlimmer als eins das du zweimal umgeschrieben hast.
Quellen und Weiterlesen
- Gregor Hohpe, Bobby Woolf: Enterprise Integration Patterns, Kapitel “Canonical Data Model” (Addison-Wesley, 2003)
- Martin Kleppmann: Designing Data-Intensive Applications, Kapitel 4 “Encoding and Evolution” (O’Reilly, 2017)
- Confluent Docs: Schema Evolution and Compatibility
- Vaughn Vernon: Implementing Domain-Driven Design, Kapitel 13 “Integrating Bounded Contexts” (Addison-Wesley, 2013)
- Google API Design Guide: Protocol Buffers Schema Design
- Pat Helland: Data on the Outside versus Data on the Inside (CIDR 2005)
- Ben Stopford: Designing Event-Driven Systems, Kapitel 7 “Schemas” (O’Reilly/Confluent, 2018)