Skip to main content

Product Linked Data - Eliminating Duplication in Engagements

· 5 min read
Coded Ventures Team
Engineering Team

We're migrating product data from inline duplication in every line item to a linked data model where products are stored once and referenced. This is about efficiency, consistency, and future flexibility.

The Problem

Right now, when you add a product to an engagement, we copy the entire product record into lineItem.good:

const lineItem = {
good: {
id: "product_123",
title: "Timber 90x45 2.4m",
unit_price: 25.50,
specifications: {...},
uomConfiguration: {...} // ← Duplicated for every line item
}
};

Order 100 units of the same product across 5 line items? That's 5 copies of the same product data.

The issues:

  • Duplication increases engagement size
  • Updates require changing multiple places
  • Memory usage scales with line item count
  • Cache efficiency suffers from redundant data

The Solution

Store products once in engagement.linked_data, reference from line items:

const engagement = {
lineItems: [
{
good: {
id: "product_123", // ← Reference only
productId: "product_123" // ← Reference only
}
}
],
linked_data: [
{
id: "product_123",
type: "product",
data: {
// Full product data stored once
title: "Timber 90x45 2.4m",
unit_price: 25.50,
specifications: {...},
uomConfiguration: {...}
}
}
]
};

Benefits:

  • Single source of truth per product
  • Smaller engagement payloads
  • Update once, affects all line items
  • Better cache utilization

Linked Data as a Pattern

This isn't just about products. linked_data is becoming our universal reference store:

  • Products (now)
  • Locations (future)
  • Orders (future)
  • Pricing snapshots (future)
  • Fulfillment plans (future)

The principle: Store heavy, reusable data once. Reference it from where it's needed.

Migration Strategy

We're not flipping a switch. This is a gradual, backward-compatible migration:

Phase 1: Dual Storage

During migration, store data in both places:

// Keep existing structure
lineItem.good.title = "Timber 90x45 2.4m";

// Also add to linked_data
engagement.linked_data.push({
id: "product_123",
type: "product",
data: fullProduct
});

This ensures zero breaking changes while we migrate.

Phase 2: Feature Flags

Control the rollout with flags:

if (FEATURE_FLAGS.USE_LINKED_DATA_PRODUCTS) {
return engagement.getLinkedProduct(productId);
} else {
return lineItem.good; // Fallback
}

Test in production with a small percentage of traffic. Validate behavior, measure performance, iterate.

Phase 3: Full Migration

Once validated, remove duplicate data:

const lineItem = {
good: {
id: "product_123", // ← Reference only
productId: "product_123" // ← Reference only
// No more: title, unit_price, uomConfiguration
}
};

Update all access points to fetch from linked_data. Remove feature flags. Clean up.

Implementation Details

EngagementModel Methods

We're adding linked data methods to the engagement model:

class EngagementModel {
getLinkedProduct(productId: string): ProductModel | null
addLinkedProduct(product: ProductModel): void
removeLinkedProduct(productId: string): void
updateLinkedProduct(productId: string, updates: Partial<ProductModel>): void
}

Encapsulation matters — the model knows how to manage its own linked data.

ProductService

A new service handles product operations:

class ProductService {
async getProductWithUom(productId: string): Promise<ProductModel>
addProductToEngagement(engagement: EngagementModel, product: ProductModel): void
getProductFromEngagement(engagement: EngagementModel, productId: string): ProductModel | null
}

Single responsibility — one service, one concern: products.

Bridge Integration

The Bridge gets new product methods:

bridge.product_getProductWithUom(productId)
bridge.engagement_getEngagementWithProducts(engagementId)

This follows our everything through the Bridge pattern.

Touchpoint Updates

The UI needs to adapt:

Before:

// Full product data embedded in line item
const lineItem = {
good: fullProduct
};

After:

// Add product to linked_data
productService.addProductToEngagement(engagement, fullProduct);

// Line item just references it
const lineItem = {
good: {
id: product.id,
productId: product.id
}
};

The change is transparent to users — same functionality, different data structure.

Worker Updates

Workers need to ensure products are in linked_data:

async ensureProductsInLinkedData(engagement: EngagementModel) {
const productIds = engagement.lineItems.map(li => li.good.id);

for (const productId of productIds) {
if (!engagement.getLinkedProduct(productId)) {
const product = await bridge.product_getProductWithUom(productId);
engagement.addLinkedProduct(product);
}
}
}

Defensive programming — assume data might be incomplete, ensure consistency.

Performance Impact

Early testing shows:

Engagement Size:

  • Before: ~250KB for 20-item order
  • After: ~180KB for same order
  • 28% reduction

Memory Usage:

  • Before: Multiple product copies in memory
  • After: Single copy, multiple references
  • Scales better with line item count

Cache Efficiency:

  • Before: Cache entire engagement
  • After: Cache products separately
  • Better hit rates, more granular invalidation

The Risks

1. Additional Lookups

Accessing product data requires a lookup:

// Before: Direct access
const title = lineItem.good.title;

// After: Lookup required
const title = engagement.getLinkedProduct(lineItem.good.id)?.title;

Mitigation: Cache lookups, batch operations, optimize access patterns.

2. Migration Complexity

Dual storage adds complexity during migration.

Mitigation: Comprehensive testing, feature flags, gradual rollout, clear rollback plan.

3. Breaking Changes

Existing code expects lineItem.good.title to exist.

Mitigation: Backward compatibility period, update all access points, maintain sync during migration.

What We're Learning

1. Normalize Early

Data duplication is technical debt. We're paying it now, but we're learning: normalize from the start.

2. Feature Flags Enable Confidence

Gradual rollout with flags means we can test in production safely. This is how you ship risky changes with confidence.

3. Encapsulation Matters

Adding methods like getLinkedProduct() to the model means access patterns are consistent. No ad-hoc queries, no scattered logic.

4. Migration Over Rewrite

We could have rebuilt the engagement model. Instead, we're evolving it in place. Less risk, continuous delivery.

What's Next

Phase 1: Infrastructure (Current)

  • ✅ Update EngagementModel with linked data methods
  • ✅ Create ProductService
  • ✅ Add Bridge methods
  • 🔄 Add feature flags

Phase 2: UI Updates (Next Week)

  • Update product search components
  • Update trade ordering service
  • Add data synchronization

Phase 3: Worker Updates (Following Week)

  • Update engagement recalculation worker
  • Update order processing workers
  • Add validation

Phase 4: Full Migration (Next Month)

  • Remove duplicate data
  • Remove feature flags
  • Update documentation
  • Monitor performance

The Pattern

Start with references, not copies. Store centrally, access locally.

This pattern applies beyond products:

  • User data referenced from multiple contexts
  • Configuration data linked from many places
  • Catalog data shared across engagements

Linked data is how you scale without drowning in duplication.

Why It Matters

This might seem like an internal optimization, but it has real impacts:

  • Faster load times from smaller payloads
  • Lower bandwidth costs from reduced data transfer
  • Better user experience from snappier interfaces
  • Easier debugging from single source of truth

Performance isn't just about algorithms. It's about data structure.


Building systems that scale through structure, not brute force.