Denormalization with Change Feed

Earn 25 points (50 with Pro) in two steps

  1. ① Read through the lesson — each section gets a ✓ as you scroll through it.
  2. ② When every section has a ✓, tap Complete lesson.

0 of 10 read · keep scrolling

✦ See fewer ads and earn double points — 50 a lesson instead of 25 — with Pro

Module: Integrate Azure Cosmos DB Solution

Section: Cross-Service Integration

Lesson: Denormalization with Change Feed

Introduction: Why Denormalization Matters in NoSQL

When you first start working with Azure Cosmos DB, it is tempting to apply the same relational database modeling techniques you used in SQL Server or PostgreSQL. You might be inclined to create separate containers for Users, Orders, and Products, linking them with foreign keys and attempting to perform complex joins. However, Cosmos DB is a globally distributed, multi-model database designed for horizontal scale, not for complex relational joins. In this environment, the most effective way to optimize read performance and reduce request unit (RU) consumption is through denormalization.

Denormalization is the practice of embedding related data directly into a single document or creating "materialized views" of data across different containers. By doing this, you ensure that your application can retrieve all the information it needs to render a page or process a request in a single read operation. The challenge, of course, is data consistency. If you store a user’s name in both the User document and the Order document, what happens when that user changes their name?

This is where the Cosmos DB Change Feed comes in. The Change Feed acts as a persistent record of all changes within a container. By listening to this stream of events, you can automatically propagate updates from one container to another, ensuring that your denormalized data remains consistent without requiring your application code to manage multiple write operations. This lesson will teach you how to design these patterns, implement them using the Change Feed, and avoid the common pitfalls that can lead to performance degradation or data corruption.


Not read yet

Understanding the Change Feed Mechanism

The Change Feed in Azure Cosmos DB is a background process that listens to a container for any modifications. It provides a sorted list of documents that have been changed, in the order in which the modifications occurred. This is not a polling mechanism that puts a load on your main database; instead, it is a dedicated service that tracks the transaction log of the container.

When a document is created or updated, the Change Feed captures the new version of that document. It does not capture deletes by default (unless you enable "Full Fidelity" mode), and it only provides the latest version of the document if multiple changes occur in rapid succession. This makes it an ideal trigger for asynchronous tasks like updating secondary containers, sending notifications, or triggering serverless functions.

Callout: Change Feed vs. Polling Traditional polling requires your application to periodically query the database for new records, which consumes Request Units (RUs) and adds latency to your system. The Change Feed, by contrast, is a push-based model. It effectively "streams" changes to your processing logic, which is significantly more efficient and allows for near real-time synchronization between different parts of your data architecture.


Not read yet

Designing for Denormalization

Before diving into the code, you must understand when and why you should denormalize. In a relational database, you normalize to avoid data redundancy. In Cosmos DB, you denormalize to optimize for the most common query patterns. If 90% of your traffic involves reading a user's profile alongside their recent orders, you should store the relevant user information inside the Order document.

Common Denormalization Strategies

  1. Embedded Data: Storing sub-items directly within the parent document. For example, storing an array of "ShippingAddresses" inside a "User" document.
  2. Reference Data (Materialized Views): Storing a copy of read-heavy reference data in a separate container, or duplicating it across documents. For example, keeping the product name and price in an Order document so that if the Product catalog changes, the historical order record remains accurate.

Note: Denormalization is a trade-off. You are trading storage space and the complexity of maintaining consistency for significantly faster read performance. Always ensure your access patterns justify the extra effort of maintaining synchronized data.


Not read yet

Implementing Change Feed with Azure Functions

The most popular way to consume the Change Feed is through Azure Functions using the Cosmos DB Trigger. This approach is serverless, meaning you don’t have to manage a persistent worker process. The Function app will automatically scale as the volume of changes in your container increases.

Step-by-Step Implementation

  1. Create the Source and Target Containers: Ensure you have a source container (e.g., Products) and a target container (e.g., Orders) where the denormalized data will live.

  2. Enable the Lease Container: The Change Feed needs a place to store "checkpoints" so that if your function restarts, it knows exactly where it left off. Create a dedicated container called leases with a partition key of /id.

  3. Configure the Azure Function: Use the CosmosDBTrigger attribute in your function code. You will need to provide the connection string, the database name, the collection (container) name, and the lease collection name.

[FunctionName("UpdateProductInOrders")]
public static async Task Run(
    [CosmosDBTrigger(
        databaseName: "StoreDB",
        collectionName: "Products",
        ConnectionStringSetting = "CosmosDBConnection",
        LeaseCollectionName = "leases",
        CreateLeaseCollectionIfNotExists = true)] IReadOnlyList<Document> input,
    [CosmosDB(
        databaseName: "StoreDB",
        collectionName: "Orders",
        ConnectionStringSetting = "CosmosDBConnection")] IAsyncCollector<dynamic> ordersOut,
    ILogger log)
{
    foreach (var product in input)
    {
        // Logic to find all orders containing this product and update them
        // This is a simplified example; in practice, you might use a query
        log.LogInformation($"Product updated: {product.Id}. Syncing to Orders...");
        
        // Update logic goes here
    }
}

Explaining the Code

The CosmosDBTrigger monitors the Products container. Every time a document is inserted or updated, the input list is populated. The IAsyncCollector allows you to write the updated data into the Orders container. By using the leases container, the Azure Function keeps track of its progress, ensuring no changes are missed during scaling events or deployments.


Not read yet

Handling Data Consistency Challenges

One of the most common mistakes developers make is assuming that the Change Feed will always be "instant." While it is typically very fast, it is still an asynchronous process. This means there will be a small window of time where the source data has changed, but the target (denormalized) data has not yet been updated.

Strategies for Eventual Consistency

  • Version Stamping: Include a version or timestamp field in your documents. If an application reads a document, it can check if the data meets the expected version.
  • UI Refresh: Design your frontend to handle "stale" data gracefully. For example, show a loading spinner or a "syncing" indicator if the data is currently being updated.
  • Idempotent Operations: Ensure your Change Feed processor can handle the same message multiple times without corrupting data. If a function fails halfway through and retries, it should not create duplicate entries or perform incorrect arithmetic on your data.

Warning: Avoid creating circular triggers. If your Change Feed processor updates a document in the same container it is listening to, you will create an infinite loop that will consume your RU budget and potentially crash your application. Always ensure the "sink" (target) is different from the "source."


Not read yet

Best Practices for Large Scale Systems

1. Partitioning the Lease Container

The leases container should be partitioned by /id. As your system grows and you add more partitions to your source container, the Change Feed processor will distribute the work across multiple function instances. If your lease container is not partitioned correctly, you will hit a bottleneck where all processing is forced onto a single partition.

2. Monitoring the Lag

You should always monitor the "Change Feed Lag." This metric tells you how far behind your processor is compared to the latest change in the database. If the lag is consistently high, it means your Azure Function cannot process the changes as fast as they are arriving. You may need to increase the number of function instances or optimize the processing logic.

3. Handling Deletes

By default, the Change Feed does not provide a notification when a document is deleted. If you need to remove denormalized data when a source document is deleted, you have two options:

  • Soft Deletes: Instead of deleting a document, add a field like isDeleted: true. The Change Feed will capture this update, and your processor can then remove or hide the data in the target container.
  • Full Fidelity Mode: If you absolutely must track hard deletes, enable Full Fidelity mode on the Change Feed. This allows you to see the delete event, though it requires more complex logic to handle the payload.

Not read yet

Comparison: When to Denormalize vs. When to Link

Feature Denormalization Relational Linking (Join)
Read Speed Extremely fast (single read) Slower (multiple reads or joins)
Write Speed Slower (requires propagation) Fast (write once)
Data Integrity Eventual consistency Strong consistency
Complexity High (needs background sync) Low (managed by database)
Use Case High-traffic read applications Low-traffic, highly relational data

Common Pitfalls to Avoid

The "Everything in One Document" Trap

While denormalization is powerful, avoid putting too much data into a single document. Each document in Cosmos DB has a size limit (usually 2MB). If you embed an array of items that could grow indefinitely (like "all orders for a customer"), you will eventually exceed the document size limit. Always use a hybrid approach: embed only the data that is necessary for the specific view, and store large lists in separate containers.

Ignoring Error Handling

What happens if your Azure Function fails to process a change? If the function throws an exception, the Change Feed processor will keep retrying that specific batch of documents. If the error is due to a data issue (e.g., a malformed document), your processor will be stuck in a loop. Always wrap your processing logic in try-catch blocks, log the errors to a tool like Application Insights, and move "poison" messages to a dead-letter queue.

Over-utilizing Request Units

Every update triggered by the Change Feed consumes Request Units. If you have a high-volume container with frequent updates, your Change Feed processor might trigger thousands of writes per second to your target container. This can quickly exhaust your throughput. Always monitor the RU consumption of your background processors and consider using "Batch" operations to group updates together.

Callout: The Power of Batching When updating target containers, use the TransactionalBatch API. This allows you to perform multiple operations on a single partition key as an atomic unit. It reduces the number of round trips to the server and ensures that if one part of the update fails, the entire batch is rolled back, keeping your denormalized data in a consistent state.


Not read yet

Step-by-Step: Setting Up a Change Feed Processor (C# SDK)

If you prefer not to use Azure Functions, you can implement the ChangeFeedProcessor directly in a .NET worker service. This provides more control over the processing logic and scaling.

  1. Initialize the Processor: Use the GetChangeFeedProcessorBuilder method on your container object.

  2. Define the Handle Changes Delegate: This is the function that will be executed for every batch of changes.

Container sourceContainer = client.GetContainer("StoreDB", "Products");
Container leaseContainer = client.GetContainer("StoreDB", "leases");

ChangeFeedProcessor processor = sourceContainer
    .GetChangeFeedProcessorBuilder<Product>(processorName: "ProductSyncProcessor", onChangesDelegate: HandleChanges)
    .WithInstanceName("WorkerInstance1")
    .WithLeaseContainer(leaseContainer)
    .Build();

await processor.StartAsync();

// Delegate method
static async Task HandleChanges(
    IReadOnlyCollection<Product> changes, 
    CancellationToken cancellationToken)
{
    foreach (var product in changes)
    {
        // Update logic here
    }
}
  1. Graceful Shutdown: Always ensure your service listens for cancellation tokens so that it shuts down cleanly, finishing its current batch of work before stopping.

Not read yet

Advanced Scenarios: Multi-Region and Global Distribution

When operating in a global environment, denormalization becomes even more critical. If your users are spread across continents, you want their data to be "local" to them. You can use the Change Feed to replicate data from a central "source of truth" container to regional containers.

For example, you might have a global ProductCatalog container. When a price update occurs, the Change Feed can trigger a process that updates regional LocalCatalog containers in various Azure regions. This reduces latency for users who can now read from a local container rather than crossing oceans to query the central database.

However, remember that cross-region replication involves network latency. The Change Feed will process these updates as quickly as the network allows, but you must account for this "replication lag" in your application design. Your application should be able to handle cases where a user in Europe might see a slightly different product price than a user in the US for a few seconds.


Not read yet

Best Practices Checklist

  • Use Partitioning Wisely: Ensure your lease container is partitioned by /id to allow for horizontal scaling.
  • Monitor with Metrics: Use Azure Monitor to track the "Change Feed Lag" and RU consumption.
  • Handle Errors: Implement dead-letter queues for documents that cannot be processed.
  • Keep Documents Manageable: Do not embed unbounded arrays; use separate containers for long-running lists.
  • Use Batching: Group updates to minimize RU consumption and improve performance.
  • Avoid Circularity: Ensure the target container is never the source container for the same trigger.
  • Test for Idempotency: Ensure your logic can handle multiple executions of the same change without side effects.

Conclusion and Key Takeaways

Denormalization is a fundamental pattern for building high-performance applications on Azure Cosmos DB. By shifting from a relational mindset to one focused on access patterns, you can create systems that are incredibly fast and scalable. The Change Feed is the engine that makes this possible, allowing you to maintain consistency across denormalized data structures without manual intervention.

Key Takeaways:

  1. Prioritize Read Performance: Use denormalization to ensure your most common queries can be satisfied by reading a single document.
  2. Embrace Eventual Consistency: Understand that Change Feed synchronization is asynchronous and design your application to handle the small window of "stale" data.
  3. Leverage Serverless: Use Azure Functions with the Cosmos DB Trigger to simplify the implementation of Change Feed processors without managing infrastructure.
  4. Partition for Scale: Always partition your lease containers by /id to avoid bottlenecks as your data volume grows.
  5. Protect Your Throughput: Use batching and monitor your RU consumption to ensure your background synchronization tasks do not starve your user-facing operations.
  6. Design for Idempotency: Ensure your sync logic can safely handle the same update multiple times, as the Change Feed guarantees "at-least-once" delivery, not "exactly-once."
  7. Monitor System Health: Regularly check your Change Feed lag to ensure your synchronization is keeping pace with your data growth.

By mastering these concepts, you transition from simply "using" a database to building a robust, distributed data architecture that can handle the demands of modern, global applications. Continue practicing these patterns in your development environment, and remember: in the world of NoSQL, the best design is the one that best serves your user's experience.

Not read yet

Each section gets a ✓ as you scroll through it. Tap the button to jump to the next one.