Loading learning content...
Generic conflict resolution strategies like LWW treat all data the same: a conflicting shopping cart is resolved the same way as a conflicting user profile or a conflicting bank balance. But semantically, these are radically different situations.
When two users concurrently add items to a shopping cart, we probably want both items. When two users concurrently update a profile, we might want the latest. When two conflicting bank transactions occur, we might need to reject both and alert a human.
Application-level merge acknowledges that the database cannot understand your domain. It pushes conflict resolution to the application layer, where semantic knowledge exists to make intelligent decisions.
By the end of this page, you will understand how to design custom merge functions, common merge patterns across different domains, the architecture for exposing conflicts to applications, strategies for different data types, and the trade-offs between automatic and manual resolution.
Consider this scenario: two hospital staff members concurrently update a patient's medication record.
Nurse A adds: Aspirin 100mg
Doctor B adds: Blood thinner 50mg
With LWW: One medication is lost. Patient safety at risk. With Union Merge: Both kept—but are they compatible? Blood thinners + aspirin can cause dangerous bleeding! With Application Logic: Check drug interaction database. If conflict is safe, merge. If dangerous, alert pharmacist for review.
No database can make this decision; it requires domain knowledge. Application-level merge brings that knowledge to bear.
Application-level merge is more work. You must design, implement, test, and maintain custom resolution logic. It's justified when data semantics demand it—not for every field in every document. Use LWW or CRDTs where appropriate, and invest in custom merge where it matters.
Several architectural patterns exist for integrating application-level merge with distributed storage systems.
Pattern: Sibling Exposure (Riak-style)
The database stores all conflicting versions as 'siblings.' On read, all siblings are returned to the application, which must resolve them.
Flow:
Advantages:
Disadvantages:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748
interface SiblingReadResult<T> { values: T[]; // All sibling values context: VersionContext; // For writing back resolved value} async function readWithMerge<T>( key: string, mergeFn: (siblings: T[]) => T): Promise<T> { const result: SiblingReadResult<T> = await storage.read(key); if (result.values.length === 1) { return result.values[0]; // No conflict } // Conflict: apply merge function const merged = mergeFn(result.values); // Write back to resolve siblings await storage.write(key, merged, result.context); return merged;} // Example usage with a shopping cartasync function getCart(userId: string): Promise<Cart> { return readWithMerge(`cart:${userId}`, (siblings) => { // Merge: combine items from all cart versions const itemMap = new Map<string, CartItem>(); for (const cart of siblings) { for (const item of cart.items) { const existing = itemMap.get(item.productId); if (!existing) { itemMap.set(item.productId, item); } else { // Take max quantity if item exists in multiple siblings existing.quantity = Math.max( existing.quantity, item.quantity ); } } } return { items: Array.from(itemMap.values()) }; });}While merges are domain-specific, several common patterns recur across applications. Understanding these building blocks helps design custom merges.
| Strategy | Logic | Best For | Data Type |
|---|---|---|---|
| Union | Keep all values from all versions | Collections where additions accumulate | Sets, lists, tags |
| Intersection | Keep only values present in all versions | Collections where agreement matters | Permissions, access |
| Max/Min | Keep highest or lowest value | Numeric values with natural ordering | Counters, timestamps |
| Field-Level LWW | Each field resolved independently | Objects with independent fields | Profiles, settings |
| Priority Merge | Values from higher-priority source win | Multi-source data with trust levels | Aggregated data |
| Tombstone-Wins | Deletions always take precedence | Collections where delete = final | Cleanup systems |
| Add-Wins | Additions always take precedence | Collections where presence matters | Shopping carts |
| Custom Business Logic | Domain-specific rules | Complex constraints | Any |
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293
// Library of common merge strategies /** * Union merge: combine all elements */function unionMerge<T>(sets: Set<T>[]): Set<T> { const result = new Set<T>(); for (const set of sets) { for (const item of set) { result.add(item); } } return result;} /** * Intersection merge: keep only common elements */function intersectionMerge<T>(sets: Set<T>[]): Set<T> { if (sets.length === 0) return new Set(); const result = new Set(sets[0]); for (let i = 1; i < sets.length; i++) { for (const item of result) { if (!sets[i].has(item)) { result.delete(item); } } } return result;} /** * Field-level LWW: each field resolved by timestamp */interface FieldWithTimestamp<T> { value: T; updatedAt: number;} function fieldLevelLWW<T extends object>( versions: { data: T; fieldTimestamps: Record<keyof T, number> }[]): T { const result: Partial<T> = {}; const allFields = new Set( versions.flatMap(v => Object.keys(v.data as object)) ); for (const field of allFields) { let latestTimestamp = -1; let latestValue: any; for (const version of versions) { const fieldTs = version.fieldTimestamps[field as keyof T]; if (fieldTs > latestTimestamp) { latestTimestamp = fieldTs; latestValue = (version.data as any)[field]; } } if (latestValue !== undefined) { (result as any)[field] = latestValue; } } return result as T;} /** * Priority merge: higher priority source wins per field */function priorityMerge<T extends object>( versions: { data: T; priority: number }[]): T { // Sort by priority descending const sorted = [...versions].sort((a, b) => b.priority - a.priority); // Highest priority as base, fill gaps from lower priority const result: any = { ...sorted[0].data }; for (const version of sorted.slice(1)) { for (const [key, value] of Object.entries(version.data as object)) { if (result[key] === undefined || result[key] === null) { result[key] = value; } } } return result as T;}Let's examine merge logic for several real-world domains, highlighting the domain knowledge required.
Shopping Cart Merge Strategy
Goal: Never lose items the user added; handle quantity conflicts gracefully.
Domain Rules:
Complexity: Must track add vs remove operations, not just final state.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364
interface CartItem { productId: string; quantity: number; addedAt: number; removedAt?: number; // Track removals (tombstone)} interface Cart { items: CartItem[]; couponCode?: string; couponAppliedAt?: number;} function mergeShoppingCarts(carts: Cart[]): Cart { const itemMap = new Map<string, CartItem>(); for (const cart of carts) { for (const item of cart.items) { const existing = itemMap.get(item.productId); if (!existing) { itemMap.set(item.productId, { ...item }); } else { // Merge logic for existing item const merged: CartItem = { productId: item.productId, // Take earlier addedAt (first time item was added) addedAt: Math.min(existing.addedAt, item.addedAt), // Take higher quantity (user wanted at least this many) quantity: Math.max(existing.quantity, item.quantity), // Take latest removedAt if either has it removedAt: [existing.removedAt, item.removedAt] .filter((t): t is number => t !== undefined) .sort((a, b) => b - a)[0] }; itemMap.set(item.productId, merged); } } } // Filter out items that were removed (and removal is after addition) const finalItems = Array.from(itemMap.values()) .filter(item => { if (!item.removedAt) return true; return item.addedAt > item.removedAt; // Re-added after removal }); // Coupon: LWW let latestCoupon: { code?: string; appliedAt: number } = { appliedAt: 0 }; for (const cart of carts) { if (cart.couponAppliedAt && cart.couponAppliedAt > latestCoupon.appliedAt) { latestCoupon = { code: cart.couponCode, appliedAt: cart.couponAppliedAt }; } } return { items: finalItems, couponCode: latestCoupon.code, couponAppliedAt: latestCoupon.appliedAt || undefined };}For merge functions to work correctly in distributed systems, they must satisfy certain mathematical properties—especially when used in on-write merge patterns.
If your merge function violates these properties, different replicas may converge to different states! A non-deterministic merge (using Math.random()) would cause permanent divergence. A non-commutative merge would produce different results based on message ordering. Test your merge functions carefully.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354
// Testing merge function properties function testMergeProperties<T>( merge: (a: T, b: T) => T, samples: T[], equals: (a: T, b: T) => boolean): { valid: boolean; violations: string[] } { const violations: string[] = []; for (let i = 0; i < samples.length; i++) { for (let j = i + 1; j < samples.length; j++) { const a = samples[i]; const b = samples[j]; // Test commutativity const ab = merge(a, b); const ba = merge(b, a); if (!equals(ab, ba)) { violations.push(`Commutativity: merge(a[${i}], a[${j}]) ≠ merge(a[${j}], a[${i}])`); } // Test idempotence if (!equals(merge(a, a), a)) { violations.push(`Idempotence: merge(a[${i}], a[${i}]) ≠ a[${i}]`); } // Test associativity with a third element for (let k = j + 1; k < samples.length; k++) { const c = samples[k]; const ab_c = merge(merge(a, b), c); const a_bc = merge(a, merge(b, c)); if (!equals(ab_c, a_bc)) { violations.push( `Associativity: merge(merge(a[${i}], a[${j}]), a[${k}]) ≠ ` + `merge(a[${i}], merge(a[${j}], a[${k}]))` ); } } } } // Determinism: call same merge twice, compare results for (let i = 0; i < samples.length; i++) { for (let j = i + 1; j < samples.length; j++) { const result1 = merge(samples[i], samples[j]); const result2 = merge(samples[i], samples[j]); if (!equals(result1, result2)) { violations.push(`Determinism: merge(a[${i}], a[${j}]) not deterministic`); } } } return { valid: violations.length === 0, violations };}Some conflicts cannot—or should not—be resolved automatically. The stakes are too high, the semantics too complex, or the intent too ambiguous. In these cases, surfacing the conflict to a human is the right approach.
UX Patterns for Human Resolution:
| Pattern | Description | Best For |
|---|---|---|
| Side-by-side | Show both versions; user picks one | Simple value conflicts |
| Three-way diff | Show ancestor + both versions | Text/document conflicts |
| Interactive merge | Let user selectively choose parts | Mixed conflicts |
| Override warning | Show proposed auto-resolution; user confirms or overrides | Low-stakes with preference |
| Escalation queue | Conflict goes to specialist for later resolution | Async, high-stakes |
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172
interface ConflictResolutionUI<T> { /** * Display conflict to user and get their resolution. */ presentConflict( key: string, versions: T[], suggestedResolution?: T ): Promise<{ resolution: T; userChose: 'suggested' | 'version' | 'custom'; versionIndex?: number; }>;} // Example: Document conflict UIclass DocumentConflictResolver implements ConflictResolutionUI<Document> { async presentConflict( key: string, versions: Document[], suggestedResolution?: Document ) { // Open conflict resolution modal const modal = await openModal({ title: 'Document Conflict Detected', type: 'three-way-diff', versions: versions.map((v, i) => ({ label: `Version ${i + 1} (by ${v.author} at ${v.lastModified})`, content: v.body })), suggested: suggestedResolution }); // Wait for user to resolve const result = await modal.waitForResolution(); // Log for audit await auditLog.record({ action: 'conflict_resolved', key, versionsPresented: versions.length, userDecision: result.userChose, timestamp: Date.now() }); return result; }} // Workflow: Auto-resolve if possible, escalate if notasync function resolveWithFallback<T>( key: string, versions: T[], autoMerge: (versions: T[]) => T | 'CANNOT_AUTO_MERGE', humanResolver: ConflictResolutionUI<T>): Promise<T> { const autoResult = autoMerge(versions); if (autoResult !== 'CANNOT_AUTO_MERGE') { // Auto-merge succeeded; optionally show to user for confirmation return autoResult; } // Escalate to human const humanResult = await humanResolver.presentConflict( key, versions, undefined // No suggestion since auto-merge failed ); return humanResult.resolution;}Application-level merge is powerful but comes with costs. Here's how to balance the trade-offs and implement effectively.
Application-level merge brings domain knowledge to conflict resolution, enabling intelligent combining of concurrent updates rather than arbitrary selection. Let's consolidate the key insights:
What's Next:
Application-level merge gives us flexibility, but it requires writing and maintaining custom logic. What if we could use data structures that automatically merge without any custom code? The next page explores Conflict-free Replicated Data Types (CRDTs)—mathematical structures designed to merge correctly by construction.
You now understand how to design and implement application-level merge strategies—when to use them, common patterns, architectural options, and best practices. You can bring domain knowledge to bear on conflict resolution.