Azure Functions Advanced Patterns and Best Practices

As Azure Functions applications grow from single functions into complex, multi-function systems, certain architectural patterns and coding practices become essential. This topic covers the most important advanced patterns that separate maintainable, production-grade function apps from fragile ones.

Pattern 1 – Event-Driven Architecture

Event-driven architecture means functions communicate through events rather than direct calls. One function fires an event and does not wait for another function to respond. The second function reacts to the event independently.

┌──────────────────────────────────────────────────────────────┐
│            EVENT-DRIVEN ARCHITECTURE                         │
│                                                              │
│  WITHOUT Events (Tight Coupling):                            │
│  OrderFunction → directly calls → EmailFunction             │
│  If EmailFunction fails, OrderFunction fails too             │
│                                                              │
│  WITH Events (Loose Coupling):                               │
│  OrderFunction → puts event on queue                         │
│                      │                                       │
│                      ├──► EmailFunction (reads queue)        │
│                      ├──► InventoryFunction (reads queue)    │
│                      └──► AnalyticsFunction (reads queue)    │
│                                                              │
│  OrderFunction does not know or care about the others.       │
│  Each function works independently.                          │
└──────────────────────────────────────────────────────────────┘

Implementation

// OrderFunction: places event on queue after order saved
module.exports = async function (context, req) {
    const order = req.body;

    // Save order to database
    const savedOrder = await saveOrder(order);

    // Fire event — multiple consumers will react independently
    context.bindings.orderCreatedQueue = JSON.stringify({
        event: "OrderCreated",
        orderId: savedOrder.id,
        customerId: order.customerId,
        total: order.total,
        timestamp: new Date().toISOString()
    });

    context.res = { status: 201, body: { orderId: savedOrder.id } };
};

Pattern 2 – Retry Pattern with Exponential Backoff

External services (databases, APIs, queues) sometimes fail temporarily. A naive retry tries again immediately, which can overload the failing service. Exponential backoff waits progressively longer between each retry.

┌──────────────────────────────────────────────────────────────┐
│           EXPONENTIAL BACKOFF DIAGRAM                        │
│                                                              │
│  Attempt 1: Fails → Wait 1 second                            │
│  Attempt 2: Fails → Wait 2 seconds                           │
│  Attempt 3: Fails → Wait 4 seconds                           │
│  Attempt 4: Fails → Wait 8 seconds                           │
│  Attempt 5: Success!                                         │
│                                                              │
│  Total wait: 15 seconds instead of hammering every 0.1s      │
└──────────────────────────────────────────────────────────────┘
async function callWithRetry(fn, maxRetries = 4) {

    let lastError;

    for (let attempt = 0; attempt < maxRetries; attempt++) {

        try {
            return await fn();  // Try the operation
        } catch (err) {
            lastError = err;

            if (attempt < maxRetries - 1) {
                // Wait 2^attempt seconds: 1s, 2s, 4s, 8s
                const delay = Math.pow(2, attempt) * 1000;
                console.log(`Attempt ${attempt + 1} failed. Retrying in ${delay}ms...`);
                await new Promise(resolve => setTimeout(resolve, delay));
            }
        }
    }

    throw new Error(`All ${maxRetries} attempts failed: ${lastError.message}`);
}

// Usage in function
module.exports = async function (context, req) {
    const data = await callWithRetry(
        () => callExternalPaymentAPI(req.body)
    );
    context.res = { status: 200, body: data };
};

Pattern 3 – Idempotency

An idempotent function produces the same result whether it runs once or ten times with the same input. This is critical when queue messages can be delivered more than once (Azure guarantees at-least-once delivery, not exactly-once).

┌──────────────────────────────────────────────────────────────┐
│               IDEMPOTENCY                                    │
│                                                              │
│  NOT Idempotent:                                             │
│  Queue message arrives 3 times (network issue)               │
│  → Function runs 3 times                                     │
│  → Email sent 3 times → User complains                       │
│                                                              │
│  Idempotent:                                                 │
│  Queue message arrives 3 times                               │
│  → Function runs 3 times                                     │
│  → First run: records "email sent for orderId=101"           │
│  → Second run: checks, sees already sent → skips             │
│  → Third run: checks, sees already sent → skips              │
│  → User receives 1 email ✓                                   │
└──────────────────────────────────────────────────────────────┘
module.exports = async function (context, orderMessage) {

    const orderId = orderMessage.orderId;

    // Check if this order was already processed
    const alreadyProcessed = await checkProcessedFlag(orderId);

    if (alreadyProcessed) {
        context.log(`Order ${orderId} already processed. Skipping.`);
        return;  // Exit without processing again
    }

    // Process the order
    await sendConfirmationEmail(orderMessage);

    // Record that this order has been processed
    await setProcessedFlag(orderId);

    context.log(`Order ${orderId} processed successfully.`);
};

Pattern 4 – Separation of Concerns

Placing all logic directly inside the function handler creates functions that are difficult to test and maintain. Separating business logic into dedicated service modules keeps the function handler clean.

// BAD: All logic inside handler
module.exports = async function (context, req) {
    const order = req.body;
    if (!order.productId || !order.quantity) { ... } // validation
    const product = await cosmosClient.query(...);   // data access
    order.total = product.price * order.quantity;    // calculation
    await sendEmail(order.email, ...);               // email sending
    context.res = { status: 201, body: order };
};

// GOOD: Logic separated into services
// services/orderService.js
const OrderService = {
    validate: (order) => order.productId && order.quantity > 0,
    calculate: (order, product) => ({ ...order, total: product.price * order.quantity }),
    save: async (order) => { /* database logic */ },
    notify: async (order) => { /* email logic */ }
};
module.exports = OrderService;

// functions/CreateOrder/index.js
const OrderService = require("../../services/orderService");

module.exports = async function (context, req) {
    const order = req.body;

    if (!OrderService.validate(order)) {
        context.res = { status: 400, body: "Missing required fields" };
        return;
    }

    const product   = await getProduct(order.productId);
    const fullOrder = OrderService.calculate(order, product);
    await OrderService.save(fullOrder);
    await OrderService.notify(fullOrder);

    context.res = { status: 201, body: fullOrder };
};

Pattern 5 – Circuit Breaker

If an external service is down, retrying hundreds of times wastes resources and delays recovery. A circuit breaker detects repeated failures and stops calling the failing service for a cooling period, then tries again.

┌──────────────────────────────────────────────────────────────┐
│              CIRCUIT BREAKER STATES                          │
│                                                              │
│  CLOSED (normal) → Calls pass through                        │
│       │                                                      │
│       │ 5 failures in a row                                  │
│       ▼                                                      │
│  OPEN (broken) → All calls rejected immediately              │
│  (No more calls to failing service — saves time/resources)   │
│       │                                                      │
│       │ After 30 seconds cooling period                      │
│       ▼                                                      │
│  HALF-OPEN → One trial call allowed                          │
│       │                                                      │
│       ├── Trial succeeds → Back to CLOSED ✓                  │
│       └── Trial fails    → Back to OPEN ✗                    │
└──────────────────────────────────────────────────────────────┘

Best Practices Summary

Code Quality

PracticeWhy It Matters
One function, one responsibilityEasier to test, deploy, and scale independently
Async/await everywherePrevents blocking, improves throughput
Always handle errors with try/catchPrevents unhandled rejections that crash the function
Validate all inputsReturns clear 400 errors instead of cryptic 500 errors
Log start, end, and key decisionsMakes debugging in production far easier

Architecture

PracticeWhy It Matters
Use queues to decouple functionsOne failure does not cascade to other functions
Design for idempotencySafe to retry without side effects
Use Managed Identity for Azure servicesNo passwords stored in configuration
Keep function execution time shortReduces cost and timeout risk
Offload long work to Durable FunctionsHandles long workflows reliably

Testing

// Unit testing a function handler (using Jest)
const httpTrigger = require("./index");

test("returns 200 with greeting when name provided", async () => {
    const context = { log: jest.fn(), res: {} };
    const req = { query: { name: "Ravi" }, body: {} };

    await httpTrigger(context, req);

    expect(context.res.status).toBe(200);
    expect(context.res.body).toContain("Ravi");
});

test("returns 400 when name is missing", async () => {
    const context = { log: jest.fn(), res: {} };
    const req = { query: {}, body: {} };

    await httpTrigger(context, req);

    expect(context.res.status).toBe(400);
});

Function App Project Structure – Recommended

my-function-app/
├── host.json
├── local.settings.json        ← In .gitignore
├── package.json
├── .gitignore
├── services/                  ← Business logic (testable)
│   ├── orderService.js
│   ├── emailService.js
│   └── paymentService.js
├── utils/                     ← Shared helpers
│   ├── retry.js
│   └── validator.js
├── CreateOrder/               ← One folder per function
│   ├── function.json
│   └── index.js
├── GetOrder/
│   ├── function.json
│   └── index.js
└── ProcessOrderQueue/
    ├── function.json
    └── index.js

Leave a Comment