This is Part 2 of the Web App Evolution series. Part 1 covered the decade-long shift from servlets to agentic AI — this post zooms into a single system from that timeline and takes it apart.
The System: Customer Master Data Management (CMDM)
From September 2018 to November 2020, I led a team of 12 engineers at Nissan Digital India, working across 9 global Sales Finance Business Units (SFBUs). The hardest problem on our plate wasn't a feature — it was a foundation: every regional system had its own version of "the customer."
CMDM was our answer. It became the single source of truth for customer data across 15 regional operations, cut data inconsistencies by 85%, and saved Nissan a substantial amount annually in operational overhead. This post is about how it was actually built.
The Problem: 15 Regions, 15 Different Truths#
Nissan's regional business units — APAC, EMEA, Americas, and others — had each grown their own customer systems over years, sometimes decades. A customer who bought a car in one region and later moved, leased a second vehicle, or contacted support in another region didn't have one record. They had fragments:
- A sales record in the CRM used by the dealership network
- A service history entry in a regional after-sales system
- A finance profile in whatever the local Sales Finance Business Unit had standardized on
- Marketing preferences in yet another system, often with a different name spelling or address format
None of these systems agreed with each other, and none of them were designed to. Each had its own schema, its own validation rules, its own idea of what a "valid" phone number or address looked like. Multiply that by 15 regions and you get a customer service team that couldn't answer a simple question — "what does this customer actually have with us?" — without phoning three different departments.
The State of Things Before CMDM
Buying bigger regional databases wasn't the fix — the problem wasn't capacity, it was disagreement. We needed an architecture that could hold 15 regions' worth of conflicting data and still produce one answer per customer, in near real time, without forcing every regional team to rip out and replace their existing systems (which was politically and operationally not going to happen).
The Shape of the Solution#
We landed on an event-driven microservices architecture with Apache Kafka as the backbone, a golden record master data repository as the single source of truth, and a CQRS split so that the write-heavy conflict resolution path never competed with the read-heavy customer lookup path.
At a glance, four architectural decisions did most of the work:
- Event-driven ingestion instead of polling regional databases
- A golden record model instead of trying to make 15 systems agree on write
- CQRS instead of one database serving both writes and dashboard-style reads
- Business-rule-driven conflict resolution instead of "last write wins"
Let's go through each one, because each was a genuine trade-off, not a default.
Decision 1: Event-Driven Ingestion Over Polling#
Our first instinct — and our first mistake in a proof-of-concept — was a nightly batch ETL job pulling from each regional database. It worked, technically. It also meant a customer's data could be up to 24 hours stale, and a failed job on a Tuesday meant EMEA's numbers didn't show up until Wednesday's retry. For a customer service rep on the phone, "check back tomorrow" is not an acceptable answer.
We moved to change data capture (CDC) on each regional system, publishing change events onto Kafka topics partitioned by region and entity type:
@Component
public class CustomerChangeEventConsumer {
private final GoldenRecordService goldenRecordService;
private final ConflictResolutionEngine conflictEngine;
@KafkaListener(
topics = "customer-change-events",
groupId = "cmdm-integration-layer",
concurrency = "6"
)
public void onCustomerChanged(ConsumerRecord<String, CustomerChangeEvent> record) {
CustomerChangeEvent event = record.value();
// Immutable, append-only log: we never mutate the incoming event,
// only append downstream decisions derived from it.
CustomerRecordCandidate candidate = CustomerRecordCandidate.fromEvent(event);
Optional<GoldenRecord> existing =
goldenRecordService.findByCustomerKey(candidate.getCustomerKey());
GoldenRecord resolved = existing.isPresent()
? conflictEngine.merge(existing.get(), candidate)
: GoldenRecord.fromCandidate(candidate);
goldenRecordService.upsert(resolved);
}
}
Each regional system kept writing to its own database exactly as before — nobody had to change their application. A lightweight CDC connector watched the transaction log and emitted an event per change. That single decision is what made the migration politically viable: regional teams didn't have to trust a new system with their writes, only let us listen.
The trade-off was consistency model: we accepted eventual consistency with a reconciliation process, rather than trying for synchronous, strongly consistent writes across 15 regions (which would have meant every regional write blocking on a cross-continent round trip — a latency and availability problem waiting to happen).
Decision 2: The Golden Record, Not a Sync Protocol#
Early design conversations kept drifting toward "let's build bidirectional sync between all the regional systems." We killed that idea quickly once we sketched it: 15 systems syncing pairwise is 105 relationships to maintain, each with its own conflict semantics. It doesn't scale, and it doesn't actually answer "what is the truth" — it just multiplies the places where two systems can disagree.
Instead, CMDM's master data repository became the golden record: the one place where a customerKey resolves to a single, versioned, audited profile. Regional systems remained systems of entry; CMDM became the system of record.
@Entity
@Table(name = "golden_customer_record",
indexes = @Index(name = "idx_customer_key_region", columnList = "customer_key, region"))
public class GoldenRecord {
@Id
private String customerKey; // deterministic identity, not a regional PK
private String canonicalName;
private String canonicalEmail;
private String canonicalPhone;
@ElementCollection
private Map<String, FieldProvenance> fieldSources; // which region "won" each field, and why
@Version
private Long version; // optimistic locking for concurrent merges
private Instant lastReconciledAt;
}
The fieldSources map matters more than it looks. Every field on the golden record remembers which region's data it came from and why it won — because "single source of truth" without an audit trail is just a black box that compliance and support teams won't trust the first time it looks wrong.
Decision 3: Conflict Resolution Is a Business Problem, Not a Technical One#
This was the part that took the longest to get right, and it's the part generic "master data management" tutorials skip. "Last write wins" is trivial to implement and wrong often enough to erode trust in the platform within a week.
Here's roughly the shape of the rules engine we ended up with, after two rounds of getting it wrong:
@Component
public class ConflictResolutionEngine {
private final List<FieldMergeRule> rules; // ordered, most-authoritative-first
public GoldenRecord merge(GoldenRecord existing, CustomerRecordCandidate incoming) {
GoldenRecord.Builder merged = existing.toBuilder();
for (FieldMergeRule rule : rules) {
FieldDecision decision = rule.resolve(existing, incoming);
if (decision.shouldOverride()) {
merged.setField(rule.getFieldName(), decision.getValue());
merged.recordProvenance(rule.getFieldName(), decision.getSourceRegion(), decision.getReason());
}
}
return merged.build();
}
}
// Example rule: the region that legally "owns" a customer's finance
// relationship is authoritative for contact details, regardless of
// which region's event arrived most recently.
@Component
public class SfbuOwnershipRule implements FieldMergeRule {
@Override
public FieldDecision resolve(GoldenRecord existing, CustomerRecordCandidate incoming) {
boolean incomingIsOwningSfbu = incoming.getRegion().equals(existing.getOwningSfbu());
return incomingIsOwningSfbu
? FieldDecision.override(incoming.getContactDetails(), incoming.getRegion(), "owning-SFBU")
: FieldDecision.keep();
}
}
The key realization: recency, completeness, and business ownership all had to be weighed differently per field. A customer's legal owning SFBU was authoritative for finance and contact details even if a different region's event arrived more recently. But vehicle service history was authoritative from whichever region's service center actually performed the work, full stop — recency didn't matter there at all, ownership did. Encoding that as an ordered set of rules, instead of one generic "newest wins" function, is what got us from a system nobody trusted to one that reached 99.9% consistency in practice.
The lesson that mattered most
The technical mechanism for merging records is the easy 20%. The hard 80% is sitting down with each SFBU and agreeing, field by field, on who is allowed to be right. Skip that conversation and no amount of Kafka or CQRS saves you.
Decision 4: CQRS to Stop Writes and Reads From Fighting Each Other#
Once the golden record was reliably reconciling, we hit a second problem: customer service dashboards ran expensive aggregate queries against the same tables the conflict engine was writing to. Under regional load spikes (month-end finance close was the worst offender), read latency for support reps would spike right when they needed it least.
We split the model:
- Write side: the conflict resolution engine and golden record repository, optimized for correctness and auditability, partitioned by region for write locality.
- Read side: denormalized, cached projections rebuilt from the same Kafka event stream, optimized for the actual query patterns customer service and the mobile app used — "give me everything about customer X" in one round trip.
@KafkaListener(topics = "golden-record-updated", groupId = "cmdm-read-model-projector")
public void onGoldenRecordUpdated(GoldenRecordUpdatedEvent event) {
CustomerReadView view = CustomerReadView.projectFrom(event);
redisTemplate.opsForValue().set(
"customer:" + event.getCustomerKey(),
view,
Duration.ofHours(6)
);
readViewRepository.save(view); // durable copy behind the cache
}
Reads never touched the write-side tables again. That single change is where most of the 60% faster customer service resolution came from — not a faster database, but the right database for each side of the workload.
We didn't get CQRS right on the first attempt — our first "read model" was just a read replica of the write database, which meant a bad write-side query could still starve reads. It wasn't until the read model was rebuilt from Kafka events independently, with its own schema, that the two workloads actually stopped competing.
Where the Complexity Actually Lived#
Technical Challenges & Solutions
Global Data Synchronization at Scale
Challenge
Maintaining real-time data consistency across 15+ regional systems with different data formats, validation rules, and network latencies, while guaranteeing no data loss during regional outages.
Solution
Event-driven architecture on Apache Kafka with durable, replayable topics, plus a reconciliation job that re-walks unresolved conflicts on a schedule.
Approach
Outcome
99.9% data consistency across all 15 regions with sub-second propagation for the common case
Business Impact
Enabled a genuinely unified customer view instead of a best-effort approximation
Lessons Learned
Performance at Millions of Records Without Starving Writes
Challenge
Supporting millions of customer records with continuous write-side updates while still returning sub-second responses for customer service lookups.
Solution
CQRS with an independently-rebuildable read model, Redis-backed caching for hot lookups, and geographic partitioning on the write side.
Approach
Outcome
Sub-second customer lookups sustained even during month-end regional load spikes
Business Impact
60% improvement in customer service resolution time
Lessons Learned
Key Takeaways
We also had to build a compliance layer — GDPR and regional data-protection rules meant the audit trail on every golden-record field (that fieldSources provenance map) wasn't a nice-to-have, it was what let us answer "why does this record say what it says" during an actual audit. That's a governance story more than an architecture one, so I'm not going to stretch this post to cover it in depth — but it's worth knowing it rode on the same event log and versioning we'd already built for conflict resolution, rather than needing a separate system.
The Architecture Decisions, Side by Side#
Architectural Decisions & Design Patterns
Architectural Principles
Design Patterns Used
Event-Driven Microservices Over Polling ETL
Problem
Need real-time-enough data consistency across 15+ regional systems without forcing regional teams to change how they write data
Solution
Change data capture on each regional system publishing to Kafka, consumed by an independent integration layer
Rationale
Decouples regional systems from CMDM entirely — they keep writing exactly as before, we only listen. Failure in the integration layer never blocks a regional write.
Trade-offs
Impact
Sub-second propagation for the common case, 99.9% consistency after reconciliation
Golden Record Over Pairwise Sync
Problem
15 regional systems each held a partial, sometimes contradictory view of the same customer
Solution
A single master data repository holding one authoritative, versioned, audited record per customer, with regional systems remaining systems of entry
Rationale
Pairwise synchronization between N systems grows as N², and still doesn't answer 'what's true' — it just adds more places to disagree
Trade-offs
Impact
85% reduction in data inconsistencies across all touchpoints
CQRS for Read/Write Separation
Problem
Write-heavy conflict resolution and read-heavy customer service dashboards were contending for the same database, and it showed worst exactly when it mattered most
Solution
Independent read-side projections rebuilt from the Kafka event stream, cached separately from the write-side golden record store
Rationale
Reads and writes had fundamentally different performance profiles and query shapes; forcing one schema to serve both was the actual bottleneck, not raw hardware
Trade-offs
Impact
60% improvement in customer service resolution time
Leading the Build#
Team Leadership & Management
Team Size & Structure
12 engineers across backend, data, and DevOps specializations
Leadership Approach
Management Methodologies
Leadership Achievements
85% fewer data inconsistencies
Across all 15 regional touchpoints after the golden-record rollout
99.9% data consistency
Sustained across global operations after reconciliation
60% faster customer service resolution
From the CQRS read-model split relieving contention on the write path
Leadership Outcomes
What I'd Tell a Team Starting This Today#
Start with the event log, not the database schema. Every hard problem we solved — conflict resolution, read/write separation, compliance auditing — turned out to be easier once "what happened and when" was captured as an immutable event stream. The database schema is a projection of that log, not the source of truth.
Treat conflict resolution as a business-rules problem you'll revisit constantly. We shipped, then had to go back and rework field-ownership rules more than once as SFBUs raised edge cases we hadn't anticipated. Build the rules engine to be changed, not just to be correct on day one.
Don't build CQRS until the contention is real. We didn't split read and write models until dashboard queries were visibly starving the conflict resolution engine under load. Building it earlier would have been complexity we paid for before we needed it.
Observability is not optional in an event-driven system. The single biggest operational risk in this architecture is a stalled or lagging consumer that nobody notices for hours. Per-region synchronization lag needs to be a first-class metric, not an afterthought.
Next in this series: a look at the developer-experience side of scaling a platform team — how the "First 100" onboarding strategy and Super User Activation Process grew Freshworks' marketplace developer base from zero to 10,000+.
Have you built or maintained a master data platform across multiple regions? I'd be glad to compare notes — reach out on LinkedIn.
