Fan-Out Fan-In Pattern

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

Mastering the Fan-Out/Fan-In Pattern with Azure Durable Functions

Introduction: Why Parallel Processing Matters

In modern cloud architecture, we often encounter tasks that require processing large volumes of data or executing multiple independent sub-tasks simultaneously. Imagine you are building a system that processes thousands of invoices, generates reports for different departments, or performs batch image processing. If you were to handle these tasks sequentially—waiting for each one to finish before starting the next—your system would suffer from significant latency, poor throughput, and potential timeouts. This is where the "Fan-Out/Fan-In" pattern becomes essential.

The Fan-Out/Fan-In pattern is a design strategy used to distribute work across multiple parallel tasks (the "fan-out" phase) and then aggregate the results of those tasks once they are all complete (the "fan-in" phase). In the context of Azure Durable Functions, this pattern allows you to write complex, stateful workflows that manage parallel execution without worrying about the underlying infrastructure, state persistence, or complex coordination logic. By mastering this pattern, you enable your applications to scale horizontally, handle high-volume workloads efficiently, and maintain reliability even when individual tasks fail.

This lesson will guide you through the conceptual framework of the Fan-Out/Fan-In pattern, provide hands-on implementation strategies using C#, and discuss the architectural considerations necessary to build production-grade systems.


Not read yet

Understanding the Fan-Out/Fan-In Workflow

At its core, the pattern consists of three distinct stages within a single Orchestrator Function. Understanding these stages is critical to writing effective Durable Functions.

  1. The Orchestrator Start: The workflow begins in an Orchestrator Function, which acts as the "brain" of the operation. It receives the initial request, breaks it down into individual units of work, and prepares to trigger the parallel tasks.
  2. The Fan-Out Phase: The Orchestrator creates a collection of tasks (usually by calling Activity Functions) and schedules them to run concurrently. Durable Functions handles the heavy lifting of distributing these tasks across the available compute resources in your Function App.
  3. The Fan-In Phase: Once all the triggered tasks have completed, the Orchestrator receives the results from each task. It then aggregates, processes, or summarizes these results into a final output, which is returned to the original caller.

Callout: Orchestrator vs. Activity Functions It is vital to remember that an Orchestrator Function must be deterministic. It should not perform I/O, interact with databases, or use random number generators. All side effects, such as calling an API or reading from a database, must happen inside Activity Functions. The Orchestrator simply defines the sequence and logic of the workflow.


Not read yet

Step-by-Step Implementation

To implement this pattern, we will look at a common scenario: processing a list of customer orders to calculate their total value and apply a discount.

1. Define the Activity Function

The Activity Function is the worker unit. It takes an input, performs the work, and returns a result.

[FunctionName("ProcessOrderActivity")]
public static decimal ProcessOrder([ActivityTrigger] Order order, ILogger log)
{
    log.LogInformation($"Processing order {order.OrderId} for customer {order.CustomerId}.");
    
    // Simulate complex business logic
    decimal total = order.Items.Sum(i => i.Price * i.Quantity);
    decimal discount = total * 0.1m; // 10% discount
    
    return total - discount;
}

2. Implement the Orchestrator

The Orchestrator contains the "fan" logic. We create a list of tasks and use Task.WhenAll to wait for them to finish.

[FunctionName("OrderProcessorOrchestrator")]
public static async Task<decimal> RunOrchestrator(
    [OrchestrationTrigger] IDurableOrchestrationContext context)
{
    var orders = context.GetInput<List<Order>>();
    var tasks = new List<Task<decimal>>();

    // Fan-Out: Trigger all activities in parallel
    foreach (var order in orders)
    {
        Task<decimal> task = context.CallActivityAsync<decimal>("ProcessOrderActivity", order);
        tasks.Add(task);
    }

    // Fan-In: Wait for all activities to complete
    decimal[] results = await Task.WhenAll(tasks);

    // Final processing
    return results.Sum();
}

3. Triggering the Workflow

You trigger this via an HTTP-start function, which returns an orchestration ID that can be used to track the status of the process.

[FunctionName("HttpStart")]
public static async Task<HttpResponseMessage> HttpStart(
    [HttpTrigger(AuthorizationLevel.Function, "post")] HttpRequestMessage req,
    [DurableClient] IDurableOrchestrationClient starter)
{
    var orders = await req.Content.ReadAsAsync<List<Order>>();
    string instanceId = await starter.StartNewAsync("OrderProcessorOrchestrator", orders);
    return starter.CreateCheckStatusResponse(req, instanceId);
}

Not read yet

Architectural Considerations and Performance

While the code above looks straightforward, running this in a production environment requires a deeper understanding of how Durable Functions manages state and execution.

Concurrency Limits

By default, Azure Functions has limits on how many concurrent executions can occur on a single instance. If you have 1,000 orders to process, the orchestrator will attempt to schedule 1,000 tasks. If your function app is not configured to scale, or if the underlying resources are limited, you may encounter bottlenecking.

Note: Durable Functions uses an internal queue to manage work items. When you "Fan-Out," you are essentially adding messages to these queues. The number of concurrent tasks is governed by the maxConcurrentActivityFunctions setting in your host.json file.

Throttling and Rate Limiting

If your Activity Functions call an external API (like a payment gateway or a legacy web service), "fanning out" too aggressively can lead to rate limiting on the downstream system. You must ensure that your concurrency settings in host.json align with the throughput capabilities of the services your activities interact with.

Feature Description Recommendation
maxConcurrentActivityFunctions Controls how many activities run on one instance. Adjust based on memory and external API limits.
maxConcurrentOrchestratorFunctions Controls how many orchestrators run on one instance. Keep this lower than activity concurrency.
partitionCount Controls the number of partitions in the task hub. Increase for high-throughput, high-volume scenarios.

Not read yet

Handling Failures and Retries

One of the greatest advantages of using Durable Functions for the Fan-Out/Fan-In pattern is the built-in support for retries. In a distributed system, transient failures—such as a network glitch or a brief database outage—are inevitable.

You can configure an ActivityOptions policy to handle these failures automatically without writing complex try-catch blocks in your orchestrator.

var retryOptions = new RetryOptions(
    firstRetryInterval: TimeSpan.FromSeconds(5),
    maxNumberOfAttempts: 3)
{
    BackoffCoefficient = 2.0
};

// Inside the orchestrator loop
Task<decimal> task = context.CallActivityWithRetryAsync<decimal>(
    "ProcessOrderActivity", 
    retryOptions, 
    order);

By using CallActivityWithRetryAsync, the framework will automatically wait and retry the task based on your defined policy. If all retries fail, the orchestrator will throw an exception, which you can catch to perform compensating transactions or log the failure for manual intervention.


Not read yet

Common Pitfalls to Avoid

Even experienced developers fall into common traps when implementing the Fan-Out/Fan-In pattern. Being aware of these will save you hours of debugging.

1. Non-Deterministic Orchestrator Code

As mentioned earlier, the orchestrator must be deterministic. If you use DateTime.Now or Guid.NewGuid() inside an orchestrator, the code will fail during replay. Durable Functions "replays" the orchestrator code to rebuild its state. If the second execution produces a different result than the first, the orchestration will crash.

2. Over-Parallelization

Just because you can run 10,000 tasks in parallel doesn't mean you should. If you fan out too many tasks, you may saturate your database connections or exceed the limits of downstream services. Always consider batching your inputs if you are dealing with massive datasets.

3. Ignoring Large Payloads

Durable Functions persists state to Azure Storage. If your input or output objects are extremely large (e.g., several megabytes), you will incur high costs and potentially hit storage limits. Pass references (like a Blob Storage URL) rather than the raw data whenever possible.

Callout: The Replay Mechanism When a Durable Function resumes after an await point, the entire function code is re-executed from the beginning. It uses the history of completed activities stored in the task hub to avoid re-executing them. This is why non-deterministic code is strictly forbidden; the replay must match the original execution exactly.


Not read yet

Advanced Pattern: Batching for Throughput

If you have 50,000 items to process, spawning 50,000 individual activity tasks might be inefficient due to the overhead of message processing in the underlying queues. In such cases, a "Chunked Fan-Out" approach is preferred.

Instead of fanning out one task per item, divide your data into chunks (e.g., 100 items per chunk) and send each chunk to an activity function.

// Logic to chunk a list
var chunks = orders.Chunk(100); 

foreach (var chunk in chunks)
{
    tasks.Add(context.CallActivityAsync<decimal>("ProcessBatchActivity", chunk));
}

This significantly reduces the number of messages in the task hub, lowers infrastructure overhead, and allows your Activity Functions to perform more efficiently by processing items in bulk (e.g., using a single database transaction for 100 items rather than 100 individual calls).


Monitoring and Observability

When running large fan-out operations, observability becomes paramount. You need to know if an orchestration is stuck, if specific tasks are failing, or if the latency is increasing.

Application Insights

Azure Durable Functions integrates deeply with Application Insights. You can query your logs to track the health of your orchestrations using Kusto Query Language (KQL).

dependencies
| where type == "Azure Function"
| where name == "OrderProcessorOrchestrator"
| project timestamp, id, success, duration

By using custom telemetry in your Activity Functions, you can track the progress of individual tasks, allowing you to visualize how many items have been processed versus how many are still pending.

The Durable Functions Monitor

There is an open-source tool called the "Durable Functions Monitor" which provides a graphical user interface to inspect the status of orchestrations, view history, and even manually trigger or terminate tasks. While not a replacement for formal logging, it is an essential tool during the development and debugging phase.


Not read yet

Best Practices for Production

To ensure your implementation is robust and maintainable, follow these industry-standard practices:

  • Idempotency: Ensure your Activity Functions are idempotent. If a task is retried after a partial failure, it should not cause duplicate side effects (like charging a customer twice).
  • Version Control: Always use versioning for your orchestrations. If you change the logic of an orchestrator, existing "in-flight" orchestrations might fail during replay. Use the [FunctionName("OrchestratorName", "v1")] syntax to manage versions.
  • External State: Use Azure Blob Storage or Cosmos DB to store the actual data. Pass the reference (URI or ID) to the activity function. This keeps the orchestration state small and improves performance.
  • Error Handling: Always implement a try-catch block around your await Task.WhenAll call. This allows you to handle cases where one or more tasks fail, enabling you to perform cleanup or trigger notifications.
  • Naming Conventions: Use clear, descriptive names for your activity functions. Since they are called by string name in the orchestrator, it is helpful to use nameof() to avoid typos.
// Better way to call activities
context.CallActivityAsync<decimal>(nameof(ProcessOrderActivity), order);

Not read yet

Summary and Key Takeaways

The Fan-Out/Fan-In pattern is a foundational concept for building scalable, resilient workflows in Azure. By distributing work across multiple parallel activities and aggregating the results, you can handle high-volume processing tasks that would otherwise be impossible to manage sequentially.

Key Takeaways:

  1. Orchestrators are Brains, Activities are Workers: Keep orchestration logic simple and deterministic; put all side effects and business logic inside activity functions.
  2. Concurrency Management: Use host.json settings to tune your application's performance based on the limits of your infrastructure and external dependencies.
  3. Leverage Built-in Retries: Utilize CallActivityWithRetryAsync to gracefully handle transient failures without manual intervention.
  4. Batching for Efficiency: For extremely high volumes, use chunking to reduce the number of individual tasks, which improves throughput and reduces queue overhead.
  5. Deterministic Code is Non-Negotiable: Never use non-deterministic functions (like DateTime.Now) in your orchestrator, as it will break the replay mechanism.
  6. Observability: Use Application Insights to monitor the health and performance of your fan-out operations.
  7. Idempotency Matters: Design your worker functions to be safe to run multiple times, ensuring that retries do not result in corrupted data or duplicate transactions.

By applying these principles, you will be well-equipped to design and implement complex workflows that are not only performant but also maintainable and reliable in a production environment. Whether you are processing images, calculating financial reports, or orchestrating microservices, the Fan-Out/Fan-In pattern provides the structure you need to succeed in the cloud.


Not read yet

Frequently Asked Questions (FAQ)

Q: Can I call an orchestrator from another orchestrator? A: Yes, this is known as "Sub-orchestration." It is useful for breaking down massive workflows into smaller, more manageable pieces. However, be mindful of the complexity this adds to your state management.

Q: How do I handle partial failures in the Fan-In phase? A: If one task fails after retries, Task.WhenAll will throw an exception. You can wrap this in a try-catch to inspect the exception. Alternatively, you can use Task.WhenAll to await all tasks and then inspect the returned values for specific error markers if your activity functions return objects instead of simple types.

Q: Is there a limit to how many tasks I can fan out? A: Technically, you are limited by the memory of the orchestrator function and the capacity of the task hub. Practically, you should aim to keep the number of tasks in a single orchestration within a reasonable limit (e.g., a few hundred to a few thousand) and use batching if you need to process significantly more items.

Q: Why does my orchestrator restart from the beginning? A: This is the "replay" behavior. Durable Functions saves the history of what has happened. When it wakes up, it re-runs the code to reach the point where it left off, using the saved history to "skip" the work that has already been completed. This is why you must avoid any non-deterministic code.

Not read yet

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