Azure  

Orchestrating Serverless Workflows: Calling Functions Within Azure Function Apps Using Durable Functions

Table of Contents

  • Introduction

  • Real-World Scenario: Healthcare Claims Processing Pipeline

  • Calling One Function from Another in the Same Function App

  • The Three Types of Durable Functions

  • Complete Implementation with Error Handling

  • Best Practices for Enterprise-Grade Durable Functions

  • Conclusion

Introduction

In enterprise cloud architectures, especially those built on serverless platforms like Azure Functions, orchestrating complex, stateful workflows across multiple functions is a common requirement. While simple HTTP-triggered functions are great for isolated tasks, real-world business logic often demands coordination—like chaining validations, external API calls, and human-in-the-loop approvals.

This is where Durable Functions, an extension of Azure Functions, shine. They enable you to write stateful workflows in a serverless compute model—without managing infrastructure.

In this article, we’ll explore how to call one function from another within the same Function App using Durable Functions, using a real-time healthcare claims adjudication pipeline as our scenario.

Real-World Scenario: Healthcare Claims Processing Pipeline

Imagine a health insurer processing thousands of medical claims daily. Each claim must:

  1. Be validated for format and completeness

  2. Checked against patient eligibility

  3. Sent to a fraud detection AI service

  4. Routed for manual review if suspicious

  5. Finally approved or denied with audit logging

This is a multi-step, long-running, stateful workflow—perfect for Durable Functions.

PlantUML Diagram

Calling One Function from Another in the Same Function App

In standard Azure Functions, you cannot directly call another function like a regular method. Instead, you invoke it via its trigger (e.g., HTTP, queue, or orchestration client).

But with Durable Functions, you use the orchestrator pattern: one orchestrator function coordinates calls to activity functions (which contain the actual business logic). All functions reside in the same Function App and share the same codebase.

Key principle

Orchestrator functions describe the workflow; activity functions do the work.

You call activity functions using context.callActivity() (in JavaScript) or IDurableOrchestrationContext.CallActivityAsync() (in C#).

The Three Types of Durable Functions

As a senior cloud architect, you must understand these three core types:

1. Orchestrator Functions

Stateful functions that define the workflow logic. They must be deterministic (no random, date/time, or direct I/O). They schedule and await activity functions.

2. Activity Functions

Stateless, idempotent functions that perform actual tasks (e.g., call APIs, query databases). These are the “workers” invoked by orchestrators.

3. Client Functions

Triggered by external events (HTTP, queue, timer). They start orchestrations by creating instances via DurableClient.

Complete Implementation with Error Handling (C#)

Below is a production-ready implementation of the healthcare claims pipeline:

// ClaimsProcessingOrchestrator.cs
public static class ClaimsProcessingOrchestrator
{
    [FunctionName(nameof(RunClaimsWorkflow))]
    public static async Task<ClaimResult> RunClaimsWorkflow(
        [OrchestrationTrigger] IDurableOrchestrationContext context)
    {
        var claim = context.GetInput<ClaimSubmission>();

        try
        {
            // Step 1: Validate claim format
            var validation = await context.CallActivityAsync<ValidationResult>(
                nameof(ValidateClaim), claim);

            if (!validation.IsValid)
                return new ClaimResult { Status = "Rejected", Reason = validation.ErrorMessage };

            // Step 2: Check patient eligibility
            var eligibility = await context.CallActivityAsync<bool>(
                nameof(CheckEligibility), claim.PatientId);

            if (!eligibility)
                return new ClaimResult { Status = "Denied", Reason = "Patient not eligible" };

            // Step 3: Fraud detection (external AI service)
            var fraudScore = await context.CallActivityAsync<decimal>(
                nameof(AnalyzeFraudRisk), claim);

            // Step 4: Human review if high risk
            if (fraudScore > 0.8m)
            {
                // Wait for human approval (external event)
                var reviewApproved = await context.WaitForExternalEvent<bool>("ReviewApproved");
                if (!reviewApproved)
                    return new ClaimResult { Status = "Denied", Reason = "Failed manual review" };
            }

            // Step 5: Final approval
            await context.CallActivityAsync(nameof(ApproveClaim), claim.ClaimId);
            return new ClaimResult { Status = "Approved", ClaimId = claim.ClaimId };
        }
        catch (Exception ex)
        {
            // Log and fail gracefully
            await context.CallActivityAsync(nameof(LogError), 
                new { ClaimId = claim.ClaimId, Error = ex.Message });
            return new ClaimResult { Status = "Failed", Reason = "System error" };
        }
    }
}

// Activity Functions
public static class ClaimActivities
{
    [FunctionName(nameof(ValidateClaim))]
    public static ValidationResult ValidateClaim([ActivityTrigger] ClaimSubmission claim)
    {
        // Business logic: validate fields, schema, etc.
        return string.IsNullOrEmpty(claim.ProviderId) 
            ? new ValidationResult(false, "Missing provider ID") 
            : new ValidationResult(true);
    }

    [FunctionName(nameof(CheckEligibility))]
    public static async Task<bool> CheckEligibility([ActivityTrigger] string patientId)
    {
        // Call eligibility microservice
        using var client = new HttpClient();
        var response = await client.GetAsync($"https://eligibility-api/verify/{patientId}");
        return response.IsSuccessStatusCode;
    }

    [FunctionName(nameof(AnalyzeFraudRisk))]
    public static async Task<decimal> AnalyzeFraudRisk([ActivityTrigger] ClaimSubmission claim)
    {
        // Call AI fraud detection model
        var payload = JsonSerializer.Serialize(claim);
        using var client = new HttpClient();
        var res = await client.PostAsync("https://fraud-ai/predict", 
            new StringContent(payload, Encoding.UTF8, "application/json"));
        var json = await res.Content.ReadAsStringAsync();
        return JsonDocument.Parse(json).RootElement.GetProperty("riskScore").GetDecimal();
    }

    [FunctionName(nameof(ApproveClaim))]
    public static async Task ApproveClaim([ActivityTrigger] string claimId)
    {
        // Update database, send notification, etc.
        await CosmosDbService.UpdateClaimStatus(claimId, "Approved");
    }

    [FunctionName(nameof(LogError))]
    public static void LogError([ActivityTrigger] object errorData)
    {
        // Send to Application Insights or Log Analytics
        TelemetryClient.TrackException(new Exception($"Claim error: {errorData}"));
    }
}

// HTTP Client Function (Entry Point)
public static class ClaimsHttpStarter
{
    [FunctionName("StartClaimsProcessing")]
    public static async Task<IActionResult> StartClaimsProcessing(
        [HttpTrigger(AuthorizationLevel.Function, "post")] HttpRequest req,
        [DurableClient] IDurableClient client)
    {
        var claim = await req.ReadFromJsonAsync<ClaimSubmission>();
        var instanceId = await client.StartNewAsync(nameof(RunClaimsWorkflow), claim);
        
        return new AcceptedResult(
            $"Started claim processing. Instance ID: {instanceId}",
            new { instanceId });
    }

    // Endpoint to approve high-risk claims manually
    [FunctionName("ApproveManualReview")]
    public static async Task<IActionResult> ApproveManualReview(
        [HttpTrigger(AuthorizationLevel.Function, "post", Route = "review/{instanceId}")] 
        HttpRequest req,
        string instanceId,
        [DurableClient] IDurableClient client)
    {
        var approve = await req.ReadFromJsonAsync<ReviewDecision>();
        await client.RaiseEventAsync(instanceId, "ReviewApproved", approve.IsApproved);
        return new OkResult();
    }
}

Data Models:

public record ClaimSubmission(string ClaimId, string PatientId, string ProviderId, decimal Amount);
public record ValidationResult(bool IsValid, string ErrorMessage = null);
public record ClaimResult(string Status, string Reason = null, string ClaimId = null);
public record ReviewDecision(bool IsApproved);

1

2

3

4

Best Practices for Enterprise-Grade Durable Functions

  1. Never perform I/O in orchestrators – only in activity functions.

  2. Make activity functions idempotent – retries are common.

  3. Use WaitForExternalEvent for human-in-the-loop steps – avoids polling.

  4. Monitor with Application Insights – Durable Functions emit rich telemetry.

  5. Version your orchestrators carefully – breaking changes can corrupt in-flight instances.

  6. Use durable timers for SLA enforcement – e.g., auto-reject if review not completed in 72h.

Conclusion

Calling one function from another in Azure isn’t about direct method invocation—it’s about orchestrating stateful workflows using the right patterns. With Durable Functions’ three pillars—orchestrator, activity, and client functions—you can build resilient, scalable, and auditable enterprise workflows like healthcare claims processing, supply chain approvals, or loan underwriting. By leveraging this pattern, you maintain the serverless advantage (auto-scaling, pay-per-use) while solving real-world business complexity—all within a single Function App, with full observability and recovery semantics.

In cloud-native architecture, orchestration is not optional—it’s essential.