Modular Logic Apps — Linking and Composing Workflows
Why Go Modular?
A single monolithic Logic App that handles everything — validation, transformation, routing, error handling, notifications — quickly becomes difficult to maintain, test, and scale. Modular design splits that monolith into focused, reusable workflows that communicate with each other.
Microsoft Reference: Call, trigger, or nest logic apps
The Case for Modular Logic Apps
| Concern | Monolithic Workflow | Modular Workflows |
|---|---|---|
| Maintainability | Single large workflow is hard to navigate | Each workflow has a clear, focused purpose |
| Reusability | Logic is duplicated across workflows | Shared child workflows eliminate duplication |
| Scaling | Entire workflow shares one concurrency limit | Each module scales independently |
| Error isolation | One failure can cascade through everything | Failures are contained within their module |
| Team ownership | One team must understand the whole flow | Teams own specific modules |
| Deployment | Redeploy everything for a small change | Deploy only the changed module |
| Testing | Must test the entire end-to-end flow | Test each module in isolation |
| Action limits | Risk hitting the 500-action limit per workflow | Each module stays well within limits |
When Modular Design is the Right Choice
Modular Logic Apps are the recommended approach when:
- Your workflow exceeds 50+ actions or is becoming difficult to follow in the designer
- You have shared logic (e.g. address validation, notification sending) used by multiple workflows
- Different parts of the workflow need different scaling or concurrency settings
- Multiple teams contribute to the same integration
- You need independent deployment of workflow components
- You are approaching the 500-action limit per workflow (Consumption) or want to keep workflows manageable
For simple, self-contained workflows with fewer than 20 actions, a single Logic App is perfectly fine — do not over-engineer.
Linking Patterns
There are several ways to connect Logic Apps together. Each pattern has different characteristics for coupling, latency, and reliability.
Pattern 1 — Child Workflow (Synchronous, Tightly Coupled)
The native Workflow action calls a child Logic App directly and waits for its response. This is the simplest and most common pattern.
{
"Call_Validation_Workflow": {
"type": "Workflow",
"inputs": {
"host": {
"triggerName": "manual",
"workflow": {
"id": "/subscriptions/{sub}/resourceGroups/{rg}/providers/Microsoft.Logic/workflows/la-validate-order-prod-uksouth"
}
},
"body": {
"orderId": "@triggerBody()?['orderId']",
"items": "@triggerBody()?['lineItems']",
"customer": "@triggerBody()?['customer']"
}
}
}
}
The child workflow must have a Request trigger (manual) and should return a Response action with the result.
Child workflow (Request trigger):
{
"triggers": {
"manual": {
"type": "Request",
"kind": "Http",
"inputs": {
"schema": {
"type": "object",
"properties": {
"orderId": { "type": "string" },
"items": { "type": "array" },
"customer": { "type": "object" }
},
"required": ["orderId", "items"]
}
}
}
}
}
Child workflow (Response action):
{
"Return_Validation_Result": {
"type": "Response",
"inputs": {
"statusCode": 200,
"body": {
"isValid": true,
"orderId": "@triggerBody()?['orderId']",
"validatedAt": "@utcNow()"
}
}
}
}
Characteristics:
| Aspect | Detail |
|---|---|
| Coupling | Tight — parent references child by resource ID |
| Latency | Low — direct invocation within Azure fabric |
| Response | Synchronous — parent waits for child to complete |
| Error handling | Child failures propagate to parent as failed actions |
| Authentication | Automatic — uses Azure Resource Manager identity |
| Cost | Single connector invocation (no HTTP metering) |
Microsoft Reference: Call nested or child logic apps
Pattern 2 — HTTP Call (Synchronous, Loosely Coupled)
Call another Logic App via its HTTP endpoint. This is useful when the child is in a different subscription, tenant, or when you want URL-based decoupling.
{
"Call_Transform_Service": {
"type": "Http",
"inputs": {
"method": "POST",
"uri": "https://prod-01.uksouth.logic.azure.com:443/workflows/{workflowId}/triggers/manual/paths/invoke?api-version=2016-10-01&sp=%2Ftriggers%2Fmanual%2Frun&sv=1.0&sig={sig}",
"body": {
"sourceFormat": "CSV",
"targetFormat": "JSON",
"payload": "@body('Get_File_Content')"
},
"retryPolicy": {
"type": "exponential",
"count": 3,
"interval": "PT10S",
"maximumInterval": "PT1H"
}
}
}
}
Tip: Store the callback URL in an App Configuration or Key Vault reference rather than hardcoding it. This allows you to swap child endpoints across environments without redeploying the parent.
Characteristics:
| Aspect | Detail |
|---|---|
| Coupling | Loose — parent only knows a URL |
| Latency | Slightly higher — full HTTP round-trip |
| Response | Synchronous by default; use 202 pattern for long-running |
| Error handling | HTTP status codes; configure retry policies |
| Authentication | SAS token in URL, or use Managed Identity with OAuth |
| Cost | HTTP connector action cost applies |
Pattern 3 — Service Bus Decoupling (Asynchronous, Loosely Coupled)
Use Azure Service Bus as a message broker between Logic Apps for fully asynchronous, reliable communication.
Producer workflow (sends message):
{
"Send_Order_To_Queue": {
"type": "ApiConnection",
"inputs": {
"host": {
"connection": {
"name": "@parameters('$connections')['servicebus']['connectionId']"
}
},
"method": "post",
"path": "/@{encodeURIComponent('order-processing')}/messages",
"body": {
"ContentData": "@{base64(triggerBody())}",
"Properties": {
"orderType": "@triggerBody()?['type']",
"priority": "@triggerBody()?['priority']",
"correlationId": "@triggerBody()?['orderId']"
}
}
}
}
}
Consumer workflow (receives and processes):
{
"triggers": {
"When_a_message_is_received": {
"type": "ApiConnection",
"inputs": {
"host": {
"connection": {
"name": "@parameters('$connections')['servicebus']['connectionId']"
}
},
"method": "get",
"path": "/@{encodeURIComponent('order-processing')}/messages/head",
"queries": {
"queueType": "Main"
}
},
"recurrence": {
"frequency": "Second",
"interval": 30
}
}
}
}
Characteristics:
| Aspect | Detail |
|---|---|
| Coupling | Very loose — producer and consumer share only a queue name |
| Latency | Variable — depends on polling interval |
| Response | Asynchronous — producer does not wait for consumer |
| Error handling | Dead-letter queues, message retry, sessions |
| Reliability | Messages persist in the queue if consumer is unavailable |
| Cost | Service Bus + connector action costs |
Microsoft Reference: Service Bus connector
Pattern 4 — Event Grid (Event-Driven, Loosely Coupled)
Use Azure Event Grid for reactive, event-driven chaining. One Logic App publishes an event and one or more Logic Apps subscribe to it.
{
"Publish_Order_Completed_Event": {
"type": "Http",
"inputs": {
"method": "POST",
"uri": "https://{topic-name}.uksouth-1.eventgrid.azure.net/api/events",
"headers": {
"aeg-sas-key": "@parameters('eventGridKey')",
"Content-Type": "application/json"
},
"body": [
{
"id": "@guid()",
"eventType": "Order.Completed",
"subject": "/orders/@{triggerBody()?['orderId']}",
"dataVersion": "1.0",
"data": {
"orderId": "@triggerBody()?['orderId']",
"total": "@triggerBody()?['total']",
"completedAt": "@utcNow()"
}
}
]
}
}
}
Multiple subscriber Logic Apps can react to the same event — for example, one sends a confirmation email, another updates inventory, and a third feeds analytics.
Characteristics:
| Aspect | Detail |
|---|---|
| Coupling | Very loose — fan-out to multiple subscribers |
| Latency | Near real-time push delivery |
| Response | Asynchronous — publisher does not wait |
| Scaling | Event Grid handles millions of events per second |
| Use case | One event triggers multiple independent downstream workflows |
Microsoft Reference: Event Grid trigger for Logic Apps
Choosing the Right Pattern
| Scenario | Recommended Pattern |
|---|---|
| Shared validation or transformation logic | Child Workflow |
| Need the child result before continuing | Child Workflow or HTTP |
| Cross-subscription or cross-tenant calls | HTTP |
| Producer should not wait for consumer | Service Bus |
| Guaranteed delivery even if consumer is down | Service Bus |
| One event should trigger multiple workflows | Event Grid |
| Content-based routing to different processors | Service Bus (Topics + Subscriptions) |
| Simple reusable utility (e.g. send email) | Child Workflow |
Modular Architecture Example
Here is a real-world order processing system broken into modular Logic Apps:
┌──────────────────┐
│ la-order-api │
│ (Entry Point) │
└───────┬──────────┘
│
┌───────────┼───────────┐
▼ ▼ ▼
┌─────────────┐ ┌──────────┐ ┌──────────────┐
│ la-validate │ │ la-enrich│ │ la-check- │
│ -order │ │ -customer│ │ inventory │
│ (Child) │ │ (Child) │ │ (Child) │
└──────┬───────┘ └────┬─────┘ └──────┬───────┘
│ │ │
└──────────────┼───────────────┘
▼
┌───────────────────┐
│ Service Bus │
│ order-processing │
└────────┬──────────┘
│
┌────────────┼────────────┐
▼ ▼ ▼
┌──────────────┐ ┌──────────┐ ┌───────────────┐
│ la-process │ │ la-send │ │ la-update │
│ -payment │ │ -confirm │ │ -analytics │
│ (Consumer) │ │ (Consumer│ │ (Consumer) │
└──────────────┘ └──────────┘ └───────────────┘
How it works:
- la-order-api receives the incoming order via HTTP trigger
- It calls three child workflows in parallel (validate, enrich, check inventory) using the Workflow action
- If all three succeed, it publishes a message to a Service Bus queue
- Three independent consumer Logic Apps pick up the message and handle payment, confirmation email, and analytics — each at their own pace
This design means the validation logic can be reused by other entry points (e.g. a batch import workflow), payment processing can scale independently, and a failure in email sending does not block payment processing.
Consumption vs Standard — Modular Approaches
The hosting model affects how you implement modular designs.
Consumption (Multi-Tenant)
In Consumption, each Logic App is a separate Azure resource. Modular design means creating multiple Logic App resources:
Resource Group: rg-orders-prod-uksouth
├── la-order-api-prod-uksouth (parent)
├── la-validate-order-prod-uksouth (child)
├── la-enrich-customer-prod-uksouth (child)
├── la-check-inventory-prod-uksouth (child)
└── la-process-payment-prod-uksouth (consumer)
- Use the Workflow action type to call child Logic Apps by resource ID
- Each Logic App has its own trigger, scaling, and run history
- Deploy each independently via ARM/Bicep
- Connector connections are shared within the resource group
Standard (Single-Tenant)
In Standard, a single Logic App resource can contain multiple workflows. This is a natural fit for modular design:
Logic App: la-orders-prod-uksouth
├── workflows/
│ ├── order-api/ (stateful - entry point)
│ ├── validate-order/ (stateless - fast validation)
│ ├── enrich-customer/ (stateless - lookup + transform)
│ ├── check-inventory/ (stateful - external API call)
│ └── process-payment/ (stateful - durable processing)
- Workflows within the same Logic App call each other directly using the
Invoke Workflowaction - Stateless workflows are ideal for lightweight child modules (lower latency, no run history overhead)
- Stateful workflows are better for modules that need durable execution and run history
- All workflows share the same App Service Plan, VNET integration, and deployment unit
- Deploy the entire Logic App as one unit, but update individual workflow definitions independently
Standard workflow invocation:
{
"Invoke_Validate_Order": {
"type": "InvokeWorkflow",
"inputs": {
"host": {
"workflow": {
"id": "validate-order"
}
},
"body": {
"orderId": "@triggerBody()?['orderId']",
"items": "@triggerBody()?['lineItems']"
}
}
}
}
Microsoft Reference: Create Standard Logic App workflows
Passing Data Between Modules
Request/Response Body
The most common pattern — pass data in the request body and receive results in the response body:
{
"Call_Enrichment": {
"type": "Workflow",
"inputs": {
"body": {
"customerId": "@triggerBody()?['customerId']"
}
}
},
"Use_Enrichment_Result": {
"type": "Compose",
"inputs": {
"customerName": "@body('Call_Enrichment')?['name']",
"creditLimit": "@body('Call_Enrichment')?['creditLimit']"
},
"runAfter": {
"Call_Enrichment": ["Succeeded"]
}
}
}
Correlation with Message Properties
When using Service Bus or Event Grid (asynchronous patterns), use correlation IDs to link related messages across workflows:
{
"Send_To_Queue": {
"inputs": {
"body": {
"Properties": {
"correlationId": "@triggerBody()?['orderId']",
"sourceWorkflow": "@workflow().name",
"timestamp": "@utcNow()"
}
}
}
}
}
The consumer can then use the correlationId to track the message back to the originating workflow, query related data, and maintain an audit trail.
Shared State via External Storage
For data too large to pass in message bodies (Logic Apps has a 100 MB message size limit), use external storage:
{
"Store_Payload_In_Blob": {
"type": "ApiConnection",
"inputs": {
"path": "/v2/datasets/default/files",
"body": "@triggerBody()?['largePayload']",
"queries": {
"folderPath": "/workflow-data/@{triggerBody()?['correlationId']}",
"name": "payload.json"
}
}
},
"Call_Processor_With_Reference": {
"type": "Workflow",
"inputs": {
"body": {
"correlationId": "@triggerBody()?['correlationId']",
"blobPath": "/workflow-data/@{triggerBody()?['correlationId']}/payload.json"
}
},
"runAfter": {
"Store_Payload_In_Blob": ["Succeeded"]
}
}
}
This is the Claim Check pattern — pass a reference instead of the full payload.
Microsoft Reference: Handle large messages in Logic Apps
Error Handling Across Modules
Child Workflow Failures
When a child workflow fails, the parent receives a failed action. Use runAfter with status filters to handle this:
{
"Call_Validation": {
"type": "Workflow",
"inputs": { }
},
"Handle_Validation_Success": {
"type": "Compose",
"runAfter": {
"Call_Validation": ["Succeeded"]
}
},
"Handle_Validation_Failure": {
"type": "Compose",
"inputs": {
"error": "Validation workflow failed",
"details": "@body('Call_Validation')",
"failedAt": "@utcNow()"
},
"runAfter": {
"Call_Validation": ["Failed", "TimedOut"]
}
}
}
Returning Structured Errors from Child Workflows
Design child workflows to return meaningful error responses rather than just failing:
{
"Scope_Validation_Logic": {
"type": "Scope",
"actions": {
"Validate_Fields": { },
"Check_Business_Rules": { }
}
},
"Return_Success": {
"type": "Response",
"inputs": {
"statusCode": 200,
"body": { "isValid": true }
},
"runAfter": {
"Scope_Validation_Logic": ["Succeeded"]
}
},
"Return_Error": {
"type": "Response",
"inputs": {
"statusCode": 400,
"body": {
"isValid": false,
"errorCode": "VALIDATION_FAILED",
"message": "@result('Scope_Validation_Logic')?[0]?['error']?['message']"
}
},
"runAfter": {
"Scope_Validation_Logic": ["Failed"]
}
}
}
The parent can then check the status code and act accordingly, rather than relying solely on action failure states.
Dead-Letter Handling for Async Patterns
When using Service Bus, configure dead-letter handling so failed messages are not lost:
{
"triggers": {
"When_deadletter_message_received": {
"type": "ApiConnection",
"inputs": {
"path": "/@{encodeURIComponent('order-processing')}/messages/head",
"queries": {
"queueType": "DeadLetter"
}
},
"recurrence": {
"frequency": "Minute",
"interval": 5
}
}
}
}
Create a dedicated Logic App that monitors the dead-letter queue, logs the failures, and optionally retries or alerts.
Deployment Considerations
Naming Convention
Follow a consistent naming pattern so modular workflows are easy to identify:
la-{domain}-{function}-{environment}-{region}
Examples:
la-orders-validate-prod-uksouth (child - validation)
la-orders-enrich-prod-uksouth (child - enrichment)
la-orders-api-prod-uksouth (parent - entry point)
la-orders-dlq-handler-prod-uksouth (utility - dead-letter)
Deploying Modular Logic Apps with Bicep
Deploy related modular Logic Apps together using a Bicep module:
// modules/logic-app-child.bicep
param logicAppName string
param location string
param definition object
param connections object = {}
resource logicApp 'Microsoft.Logic/workflows@2019-05-01' = {
name: logicAppName
location: location
properties: {
state: 'Enabled'
definition: definition
parameters: {
'$connections': {
value: connections
}
}
}
}
output callbackUrl string = logicApp.listCallbackUrl().value
output resourceId string = logicApp.id
// main.bicep
param environment string
param location string = resourceGroup().location
module validateOrder 'modules/logic-app-child.bicep' = {
name: 'deploy-validate-order'
params: {
logicAppName: 'la-orders-validate-${environment}-uksouth'
location: location
definition: loadJsonContent('workflows/validate-order.json')
}
}
module enrichCustomer 'modules/logic-app-child.bicep' = {
name: 'deploy-enrich-customer'
params: {
logicAppName: 'la-orders-enrich-${environment}-uksouth'
location: location
definition: loadJsonContent('workflows/enrich-customer.json')
}
}
module orderApi 'modules/logic-app-child.bicep' = {
name: 'deploy-order-api'
params: {
logicAppName: 'la-orders-api-${environment}-uksouth'
location: location
definition: loadJsonContent('workflows/order-api.json')
}
dependsOn: [
validateOrder
enrichCustomer
]
}
Tip: Deploy child workflows before parent workflows so the parent can reference valid resource IDs. Use
dependsOnin Bicep to enforce this ordering.
Environment-Specific Configuration
Use Logic App parameters to manage environment differences without changing workflow definitions:
{
"definition": {
"$schema": "https://schema.management.azure.com/providers/Microsoft.Logic/schemas/2016-06-01/workflowdefinition.json#",
"parameters": {
"childWorkflowId": {
"type": "String",
"defaultValue": ""
},
"serviceBusConnection": {
"type": "String",
"defaultValue": ""
}
},
"actions": {
"Call_Child": {
"type": "Workflow",
"inputs": {
"host": {
"workflow": {
"id": "@parameters('childWorkflowId')"
}
}
}
}
}
}
}
Set childWorkflowId per environment in your deployment pipeline so the same workflow definition works in dev, test, and production.
Best Practices
- Start simple — Begin with a monolithic workflow and refactor into modules when complexity justifies it
- Define clear contracts — Document the request and response schema for each child workflow
- Use child workflows for synchronous reuse — When you need the result before continuing
- Use Service Bus for async decoupling — When producer and consumer should operate independently
- Keep modules focused — Each workflow should do one thing well (single responsibility)
- Return structured errors — Child workflows should return error codes and messages, not just fail
- Add correlation IDs — Thread a correlation ID through all linked workflows for end-to-end tracing
- Use Application Insights — Enable tracked properties with the correlation ID for cross-workflow monitoring
- Deploy children first — Ensure child workflows exist before deploying parents that reference them
- Parameterise references — Use Logic App parameters for child workflow IDs and URLs so environments stay independent
- Prefer Standard for complex modular systems — Multiple workflows in one Logic App simplify networking, deployment, and cost management
- Monitor the full chain — Set up alerts on each module, not just the entry point