Conflict Resolution Strategies
Application-Level Merge
Read aloud is unavailable in this browser.
When the Database Can't Know What You Mean
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.
The Case for Application-Level 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.
- •Business rules affect resolution — Merging cart items is fine; merging overdraft transactions requires different logic.
- •Field-level semantics differ — A profile's 'name' should use LWW; 'tags' should union; 'deleted_at' should use earliest.
- •Conflicts require human review — Some situations cannot be automatically resolved and must be surfaced to users.
- •Merge is domain-specific — A document merge differs from an image merge differs from a config file merge.
- •Complex integrity constraints — Only the application knows that 'assigned_to' must reference a valid user with permissions.
- •Audit/compliance requirements — Some domains require logging the conflict and resolution, not just the final state.
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.
Architecture for Application Merging
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:
- Concurrent writes create siblings in the database
- Application reads key, receives all siblings
- Application merges siblings into resolved value
- Application writes resolved value back (with parent context)
- Siblings are replaced with single resolved version
Advantages:
- Clean separation of concerns (DB stores, app resolves)
- No data loss (all versions preserved until explicit resolution)
- Application has full context for resolution
Disadvantages:
- Increased read latency (must return all siblings)
- Storage overhead (multiple versions stored)
- Application complexity (every read must handle potential siblings)
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()) }; });}Common Merge Strategies
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;}Domain-Specific Merge Examples
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:
- Added items should never silently disappear
- Quantity should favor higher (benefit of doubt to user intent)
- Removed items should stay removed (deletion is intentional)
- Cart-level fields (coupon code) use LWW
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 };}Requirements for Merge Functions
For merge functions to work correctly in distributed systems, they must satisfy certain mathematical properties—especially when used in on-write merge patterns.
- •Deterministic — Given the same input versions, always produce the same output. No randomness, no current time, no external calls. Essential for convergence across replicas.
- •Commutative — merge(A, B) = merge(B, A). The order inputs arrive shouldn't matter. Different replicas may receive versions in different orders.
- •Associative — merge(merge(A, B), C) = merge(A, merge(B, C)). Grouping shouldn't matter. Three-way merges can be decomposed into two-way merges.
- •Idempotent — merge(A, A) = A. Merging the same version with itself produces no change. Retries and duplicates are harmless.
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 };}Human-in-the-Loop Resolution
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.
- •High-stakes data — Financial transactions, medical records, legal documents where wrong merge has serious consequences.
- •Semantic ambiguity — When user intent is genuinely unclear, and 'best guess' isn't good enough.
- •Complex constraints — When merging might violate business rules that only a human can evaluate.
- •Audit requirements — When regulations require human review of certain kinds of data changes.
- •User preference — When users explicitly want to be involved in conflict resolution.
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;}Trade-offs and Best Practices
Application-level merge is powerful but comes with costs. Here's how to balance the trade-offs and implement effectively.
- •Semantically correct resolution
- •No data loss (both versions preserved)
- •Can handle complex constraints
- •Can invoke human judgment
- •Full flexibility for domain needs
- •Engineering complexity
- •Testing burden (must verify properties)
- •Performance overhead
- •Tight coupling to domain model
- •Must be maintained as domain evolves
- •Start Simple — Use LWW or CRDTs where they suffice. Reserve custom merge for data where it truly matters.
- •Test Properties — Verify determinism, commutativity, associativity, and idempotence with property-based testing.
- •Design for Evolution — Schema changes happen. Design merges that gracefully handle fields that appear/disappear across versions.
- •Log and Monitor — Track conflict frequency, merge outcomes, and escalation rates. High conflict rates may indicate data model issues.
- •Document Semantics — Each merge strategy is a business rule. Document it so future engineers understand intent.
- •Prefer Composition — Build merges from composable primitives (union sets, LWW timestamps, max values) rather than monolithic functions.
- •Consider Field Granularity — Sometimes field-level merge beats object-level merge. Sometimes document-level beats field-level. Choose based on conflict patterns.
- •Have a Fallback — If complex merge fails, have a fallback strategy (e.g., human review) rather than crashing.
KEEP LEARNING
There’s more to understand.
You’re reading the introduction. Create a free account to read the complete lesson, save notes and keep track of your progress.