Multi-Application Workflows is a capability that extends workflow execution beyond a single application's boundary. A workflow can now delegate activities or spawn child workflows in entirely separate applications, all while preserving the durability, reliability, and security guarantees that Dapr's workflow engine provides.
In practice, very few real-world business processes live within a single application. Certain steps may demand specialised infrastructure that only specific nodes or environments can provide. Some activities may need access to credentials scoped to a particular identity or locale. Data residency requirements may dictate that portions of a workflow execute in a specific geographic region or a business process may simply span multiple teams, each owning and deploying its own service.
These are not edge cases, they are the norm in distributed systems. Multi-application workflows offer a structured way to address them: cross-application order processing, multi-stage approval chains, and complex orchestrations that coordinate across independently deployed services all become tractable, without sacrificing workflow consistency.
This blog post walks through how Dapr Workflow routes execution across application boundaries, along with the primitives it exposes child workflows and activities. A working demo, orchestrated through Aspire, ties it all together.
Prerequisites
- A basic understanding of how Dapr and Aspire (formerly .NET Aspire) work. You may also refer to my earlier posts for more information on Dapr and Aspire.
- Docker Desktop
- Dapr
- Aspire
Demo App
The demo application consists of multiple APIs (or services) built on ASP.NET Core Minimal API, along with Aspire's AppHost and ServiceDefaults, to demonstrate the capabilities of Multi-Application Workflows.
The solution structure should look similar to the image given below.

The CommunityToolkit.Aspire.Hosting.Dapr package (version 13.0.0) has been added to the AppHost, and Dapr.Workflow (version 1.17.9) has been added to the Inventory, Order Orchestrator, Payment, and Recommendation projects.
dotnet add package CommunityToolkit.Aspire.Hosting.Dapr --version 13.0.0dotnet add package Dapr.Workflow --version 1.17.9The primary OrderProcessingWorkflow runs in the Order Orchestrator service and manages the complete order lifecycle. It first executes ValidateOrderActivity, then starts PaymentProcessingWorkflow as a child workflow in the Payment service, invokes ReserveInventoryActivity and GeneratePersonalizedRecommendationsActivity in their respective services, and finishes with CompleteOrderActivity. The child payment workflow keeps payment processing independently durable by running ValidatePaymentMethodActivity, followed by AuthorizePaymentActivity, before returning both results to the primary workflow.
The diagram below describes how everything is connected and how information flows.

AppHost
Aspire's AppHost is responsible for orchestrating all applications, their Dapr sidecars, and other supported dependencies, e.g., the Diagrid Dev Dashboard and the actor-enabled state store component. The Diagrid Dev Dashboard helps to inspect the underlying workflow state and the historical execution data. The dashboard runs locally as a container and is powered by the data stored in your local actor state store.
// AppHost.cs
using CommunityToolkit.Aspire.Hosting.Dapr;
var builder = DistributedApplication.CreateBuilder(args);
builder.AddDapr(options => options.EnableTelemetry = true);
// 01 - Diagrid Dev Dashboard
var diagridDashboard = builder.AddContainer("diagrid-dashboard", "ghcr.io/diagridio/diagrid-dashboard", "latest")
.WithHttpEndpoint(8083, 8080, name: "dashboard");
// 02 -Configure Redis state store component
var stateStore = builder
.AddDaprComponent("statestore", "state.redis", new())
.WithMetadata("redisHost", "localhost:6379")
.WithMetadata("redisPassword", string.Empty)
.WithMetadata("enableTLS", "false")
.WithMetadata("actorStateStore", "true");
// 03 - Order Orchestrator
builder.AddProject<Projects.MultiAppWorkflow_OrderOrchestrator>("order-orchestrator")
.WithHttpEndpoint(port: 50001, targetPort: 50001, name: "http", isProxied: false)
.WithDaprSidecar(sidecar =>
{
sidecar.WithOptions(new DaprSidecarOptions
{
AppId = "order-orchestrator",
AppPort = 50001,
AppProtocol = "http",
DaprHttpPort = 3505,
DaprGrpcPort = 50014,
MetricsPort = 9091,
EnableAppHealthCheck = true,
AppHealthCheckPath = "/health"
});
sidecar.WithReference(stateStore);
});
// 04 - Payment Service
builder.AddProject<Projects.MultiAppWorkflow_PaymentService>("payment-service")
.WithHttpEndpoint(port: 50002, targetPort: 50002, name: "http", isProxied: false)
.WithDaprSidecar(sidecar =>
{
sidecar.WithOptions(new DaprSidecarOptions
{
AppId = "payment-service",
AppPort = 50002,
AppProtocol = "http",
DaprHttpPort = 3506,
DaprGrpcPort = 50015,
MetricsPort = 9092,
EnableAppHealthCheck = true,
AppHealthCheckPath = "/health"
});
sidecar.WithReference(stateStore);
});
// 05 - Inventory Service
builder.AddProject<Projects.MultiAppWorkflow_InventoryService>("inventory-service")
.WithHttpEndpoint(port: 50003, targetPort: 50003, name: "http", isProxied: false)
.WithDaprSidecar(sidecar =>
{
sidecar.WithOptions(new DaprSidecarOptions
{
AppId = "inventory-service",
AppPort = 50003,
AppProtocol = "http",
DaprHttpPort = 3507,
DaprGrpcPort = 50016,
MetricsPort = 9093,
EnableAppHealthCheck = true,
AppHealthCheckPath = "/health"
});
sidecar.WithReference(stateStore);
});
// 06 - Recommendation Service
builder.AddProject<Projects.MultiAppWorkflow_RecommendationService>("recommendation-service")
.WithHttpEndpoint(port: 50004, targetPort: 50004, name: "http", isProxied: false)
.WithDaprSidecar(sidecar =>
{
sidecar.WithOptions(new DaprSidecarOptions
{
AppId = "recommendation-service",
AppPort = 50004,
AppProtocol = "http",
DaprHttpPort = 3508,
DaprGrpcPort = 50017,
MetricsPort = 9094,
EnableAppHealthCheck = true,
AppHealthCheckPath = "/health"
});
sidecar.WithReference(stateStore);
});
builder.Build().Run();Contracts
MultiAppWorkflow.Contracts contains the shared request, result, and workflow status types used across the applications. These contracts provide a consistent data model for orders, payments, inventory reservations, recommendations, workflow outputs, and failure details as calls move between services.
// Inventory.cs
namespace MultiAppWorkflow.Contracts;
public sealed record InventoryResult(
bool Success,
IReadOnlyList<Item> ReservedItems,
string Message);// Orders.cs
namespace MultiAppWorkflow.Contracts;
public sealed record OrderRequest(
string OrderId,
string CustomerId,
IReadOnlyList<Item> Items,
string PaymentMethod,
decimal Total = 0)
{
public OrderRequest WithCalculatedTotal() =>
this with { Total = Items.Sum(item => item.Price * item.Quantity) };
}
public sealed record Item(
string ProductId,
string Name,
decimal Price,
int Quantity);
public sealed record OrderValidationResult(
bool Valid,
decimal Total,
string Message);
public sealed record OrderResult(
string OrderId,
string CustomerId,
string Status,
decimal Total,
string Message);
public sealed record OrderProcessingResult(
OrderValidationResult Validation,
PaymentProcessingResult Payment,
InventoryResult Inventory,
RecommendationResult Recommendations,
OrderResult Order);// Payments.cs
namespace MultiAppWorkflow.Contracts;
public sealed record PaymentResult(
bool Success,
string PaymentMethod,
string Message);
public sealed record PaymentAuthorizationResult(
bool Authorized,
string AuthorizationCode,
decimal Amount,
string Message);
public sealed record PaymentProcessingResult(
PaymentResult Validation,
PaymentAuthorizationResult Authorization);// Recommendations.cs
namespace MultiAppWorkflow.Contracts;
public sealed record RecommendedItem(
string ProductId,
string Name,
decimal Price,
string Reason);
public sealed record RecommendationResult(
bool Success,
IReadOnlyList<RecommendedItem> Recommendations,
string Message);// Workflow.cs
namespace MultiAppWorkflow.Contracts;
public sealed record WorkflowStatusResponse(
string InstanceId,
string WorkflowName,
string RuntimeStatus,
DateTimeOffset CreatedAt,
DateTimeOffset LastUpdatedAt,
OrderRequest? Input,
OrderProcessingResult? Output,
WorkflowFailure? Failure);
public sealed record WorkflowFailure(string ErrorType, string ErrorMessage);The project structure should look similar to the image given below.

InventoryService
MultiAppWorkflow.InventoryService hosts the ReserveInventoryActivity, which is called remotely by the primary order workflow. The activity processes the order items and returns the result of reserving the required inventory.
// Program.cs
using Dapr.Workflow;
using InventoryService.Activities;
var builder = WebApplication.CreateBuilder(args);
builder.AddServiceDefaults();
builder.Services.AddDaprWorkflow(options =>
options.RegisterActivity<ReserveInventoryActivity>());
var app = builder.Build();
app.MapDefaultEndpoints();
app.MapGet("/", () => Results.Ok(new
{
service = "inventory-service",
activity = nameof(ReserveInventoryActivity)
}));
app.Run();// ReserveInventoryActivity.cs
using Dapr.Workflow;
using MultiAppWorkflow.Contracts;
namespace InventoryService.Activities;
public sealed class ReserveInventoryActivity(
ILogger<ReserveInventoryActivity> logger)
: WorkflowActivity<OrderRequest, InventoryResult>
{
public override Task<InventoryResult> RunAsync(
WorkflowActivityContext context,
OrderRequest input)
{
logger.LogInformation(
"Reserving {ItemCount} items for order {OrderId}",
input.Items.Count,
input.OrderId);
return Task.FromResult(new InventoryResult(
true,
input.Items.ToArray(),
"Inventory reserved successfully by inventory service"));
}
}The project structure should look similar to the image given below.

OrderOrchestrator
MultiAppWorkflow.OrderOrchestrator exposes the HTTP API for submitting orders and checking workflow status. It hosts the primary OrderProcessingWorkflow along with the local ValidateOrderActivity and CompleteOrderActivity, and it coordinates the child workflow and remote activities provided by other services.
// Program.cs
using Dapr.Workflow;
using MultiAppWorkflow.Contracts;
using OrderOrchestrator.Activities;
using OrderOrchestrator.Workflows;
var builder = WebApplication.CreateBuilder(args);
builder.AddServiceDefaults();
builder.Services.AddProblemDetails();
builder.Services.AddDaprWorkflow(options =>
{
options.RegisterWorkflow<OrderProcessingWorkflow>();
options.RegisterActivity<ValidateOrderActivity>();
options.RegisterActivity<CompleteOrderActivity>();
});
var app = builder.Build();
app.UseExceptionHandler();
app.MapDefaultEndpoints();
app.MapGet("/", () => Results.Ok(new
{
service = "order-orchestrator",
workflow = nameof(OrderProcessingWorkflow),
endpoints = new[]
{
"POST /workflows?wait=false",
"GET /workflows/{instanceId}"
}
}));
app.MapPost("/workflows", async (
OrderRequest request,
bool wait,
DaprWorkflowClient client,
CancellationToken cancellationToken) =>
{
var validationErrors = OrderValidator.Validate(request);
if (validationErrors.Count > 0)
{
return Results.ValidationProblem(new Dictionary<string, string[]>
{
["order"] = validationErrors.ToArray()
});
}
var input = request.WithCalculatedTotal();
var instanceId = CreateInstanceId(input.OrderId);
await client.ScheduleNewWorkflowAsync(
nameof(OrderProcessingWorkflow),
instanceId,
input,
startTime: null,
cancellationToken);
if (!wait)
{
return Results.Accepted(
$"/workflows/{instanceId}",
new { instanceId, runtimeStatus = "Scheduled" });
}
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeout.CancelAfter(TimeSpan.FromSeconds(60));
var state = await client.WaitForWorkflowCompletionAsync(
instanceId,
cancellation: timeout.Token);
return Results.Ok(ToResponse(instanceId, state));
});
app.MapGet("/workflows/{instanceId}", async (
string instanceId,
DaprWorkflowClient client,
CancellationToken cancellationToken) =>
{
var state = await client.GetWorkflowStateAsync(
instanceId,
cancellation: cancellationToken);
return state is null
? Results.NotFound()
: Results.Ok(ToResponse(instanceId, state));
});
app.Run();
static string CreateInstanceId(string orderId) =>
$"{orderId}-{Guid.NewGuid():N}";
static WorkflowStatusResponse ToResponse(string instanceId, WorkflowState state) =>
new(
instanceId,
nameof(OrderProcessingWorkflow),
state.RuntimeStatus.ToString(),
state.CreatedAt,
state.LastUpdatedAt,
state.ReadInputAs<OrderRequest>(),
state.ReadOutputAs<OrderProcessingResult>(),
state.FailureDetails is null
? null
: new WorkflowFailure(
state.FailureDetails.ErrorType,
state.FailureDetails.ErrorMessage));// OrderProcessingWorkflow.cs
using Dapr.Workflow;
using MultiAppWorkflow.Contracts;
using OrderOrchestrator.Activities;
namespace OrderOrchestrator.Workflows;
public sealed class OrderProcessingWorkflow : Workflow<OrderRequest, OrderProcessingResult>
{
public override async Task<OrderProcessingResult> RunAsync(
WorkflowContext context,
OrderRequest input)
{
var validation = await context.CallActivityAsync<OrderValidationResult>(
nameof(ValidateOrderActivity),
input);
var payment = await context.CallChildWorkflowAsync<PaymentProcessingResult>(
"PaymentProcessingWorkflow",
input,
new ChildWorkflowTaskOptions(
InstanceId: $"{context.InstanceId}-payment",
TargetAppId: "payment-service"));
var inventory = await context.CallActivityAsync<InventoryResult>(
"ReserveInventoryActivity",
input,
new WorkflowTaskOptions(TargetAppId: "inventory-service"));
var recommendations = await context.CallActivityAsync<RecommendationResult>(
"GeneratePersonalizedRecommendationsActivity",
input,
new WorkflowTaskOptions(TargetAppId: "recommendation-service"));
var order = await context.CallActivityAsync<OrderResult>(
nameof(CompleteOrderActivity),
input);
return new OrderProcessingResult(
validation,
payment,
inventory,
recommendations,
order);
}
}// CompleteOrderActivity.cs
using Dapr.Workflow;
using MultiAppWorkflow.Contracts;
namespace OrderOrchestrator.Activities;
public sealed class CompleteOrderActivity(ILogger<CompleteOrderActivity> logger)
: WorkflowActivity<OrderRequest, OrderResult>
{
public override async Task<OrderResult> RunAsync(
WorkflowActivityContext context,
OrderRequest input)
{
logger.LogInformation("Completing order {OrderId}", input.OrderId);
await Task.Delay(TimeSpan.FromSeconds(1));
return new OrderResult(
input.OrderId,
input.CustomerId,
"completed",
input.Total,
"Order completed successfully");
}
}// ValidateOrderActivity.cs
using Dapr.Workflow;
using MultiAppWorkflow.Contracts;
namespace OrderOrchestrator.Activities;
public sealed class ValidateOrderActivity(ILogger<ValidateOrderActivity> logger)
: WorkflowActivity<OrderRequest, OrderValidationResult>
{
public override async Task<OrderValidationResult> RunAsync(
WorkflowActivityContext context,
OrderRequest input)
{
logger.LogInformation("Validating order {OrderId}", input.OrderId);
await Task.Delay(TimeSpan.FromSeconds(1));
var errors = OrderValidator.Validate(input);
if (errors.Count > 0)
{
throw new InvalidOperationException(string.Join("; ", errors));
}
return new OrderValidationResult(
true,
input.Total,
"Order validation successful");
}
}
public static class OrderValidator
{
public static IReadOnlyList<string> Validate(OrderRequest order)
{
var errors = new List<string>();
if (string.IsNullOrWhiteSpace(order.OrderId))
{
errors.Add("OrderId is required");
}
if (string.IsNullOrWhiteSpace(order.CustomerId))
{
errors.Add("CustomerId is required");
}
if (string.IsNullOrWhiteSpace(order.PaymentMethod))
{
errors.Add("PaymentMethod is required");
}
if (order.Items is null || order.Items.Count == 0)
{
errors.Add("At least one item is required");
return errors;
}
if (order.Items.Any(item =>
string.IsNullOrWhiteSpace(item.ProductId) ||
string.IsNullOrWhiteSpace(item.Name) ||
item.Price < 0 ||
item.Quantity <= 0))
{
errors.Add("Every item must have a product id, name, non-negative price, and positive quantity");
}
return errors;
}
}The project structure should look similar to the image given below.

PaymentService
MultiAppWorkflow.PaymentService hosts the PaymentProcessingWorkflow, which runs as a child of the primary order workflow. It validates the selected payment method with ValidatePaymentMethodActivity, authorizes the order total with AuthorizePaymentActivity, and returns both results to the parent workflow.
// Program.cs
using Dapr.Workflow;
using PaymentService.Activities;
using PaymentService.Workflows;
var builder = WebApplication.CreateBuilder(args);
builder.AddServiceDefaults();
builder.Services.AddDaprWorkflow(options =>
{
options.RegisterWorkflow<PaymentProcessingWorkflow>();
options.RegisterActivity<ValidatePaymentMethodActivity>();
options.RegisterActivity<AuthorizePaymentActivity>();
});
var app = builder.Build();
app.MapDefaultEndpoints();
app.MapGet("/", () => Results.Ok(new
{
service = "payment-service",
workflow = nameof(PaymentProcessingWorkflow),
activities = new[]
{
nameof(ValidatePaymentMethodActivity),
nameof(AuthorizePaymentActivity)
}
}));
app.Run();// PaymentProcessingWorkflow.cs
using Dapr.Workflow;
using MultiAppWorkflow.Contracts;
using PaymentService.Activities;
namespace PaymentService.Workflows;
public sealed class PaymentProcessingWorkflow
: Workflow<OrderRequest, PaymentProcessingResult>
{
public override async Task<PaymentProcessingResult> RunAsync(
WorkflowContext context,
OrderRequest input)
{
var validation = await context.CallActivityAsync<PaymentResult>(
nameof(ValidatePaymentMethodActivity),
input);
var authorization = await context.CallActivityAsync<PaymentAuthorizationResult>(
nameof(AuthorizePaymentActivity),
input);
return new PaymentProcessingResult(validation, authorization);
}
}// AuthorizePaymentActivity.cs
using Dapr.Workflow;
using MultiAppWorkflow.Contracts;
namespace PaymentService.Activities;
public sealed class AuthorizePaymentActivity(
ILogger<AuthorizePaymentActivity> logger)
: WorkflowActivity<OrderRequest, PaymentAuthorizationResult>
{
public override Task<PaymentAuthorizationResult> RunAsync(
WorkflowActivityContext context,
OrderRequest input)
{
logger.LogInformation(
"Authorizing payment of {Amount} for order {OrderId}",
input.Total,
input.OrderId);
if (input.Total <= 0)
{
throw new InvalidOperationException(
$"Payment amount for order '{input.OrderId}' must be greater than zero");
}
return Task.FromResult(new PaymentAuthorizationResult(
true,
$"AUTH-{input.OrderId}",
input.Total,
"Payment authorized successfully by payment service"));
}
}// ValidatePaymentMethodActivity.cs
using Dapr.Workflow;
using MultiAppWorkflow.Contracts;
namespace PaymentService.Activities;
public sealed class ValidatePaymentMethodActivity(
ILogger<ValidatePaymentMethodActivity> logger)
: WorkflowActivity<OrderRequest, PaymentResult>
{
private static readonly HashSet<string> SupportedMethods =
new(StringComparer.OrdinalIgnoreCase)
{
"credit_card",
"debit_card",
"paypal"
};
public override Task<PaymentResult> RunAsync(
WorkflowActivityContext context,
OrderRequest input)
{
logger.LogInformation(
"Validating payment method for order {OrderId}",
input.OrderId);
if (!SupportedMethods.Contains(input.PaymentMethod))
{
throw new InvalidOperationException(
$"Payment method '{input.PaymentMethod}' is not supported");
}
return Task.FromResult(new PaymentResult(
true,
input.PaymentMethod,
"Payment validated successfully by payment service"));
}
}The project structure should look similar to the image given below.

RecommendationService
MultiAppWorkflow.RecommendationService hosts the GeneratePersonalizedRecommendationsActivity, which is invoked remotely by the primary order workflow to produce product recommendations for the customer after inventory has been reserved.
// Program.cs
using Dapr.Workflow;
using RecommendationService.Activities;
var builder = WebApplication.CreateBuilder(args);
builder.AddServiceDefaults();
builder.Services.AddDaprWorkflow(options =>
options.RegisterActivity<GeneratePersonalizedRecommendationsActivity>());
var app = builder.Build();
app.MapDefaultEndpoints();
app.MapGet("/", () => Results.Ok(new
{
service = "recommendation-service",
activity = nameof(GeneratePersonalizedRecommendationsActivity)
}));
app.Run();// GeneratePersonalizedRecommendationsActivity.cs
using Dapr.Workflow;
using MultiAppWorkflow.Contracts;
namespace RecommendationService.Activities;
public sealed class GeneratePersonalizedRecommendationsActivity(
ILogger<GeneratePersonalizedRecommendationsActivity> logger)
: WorkflowActivity<OrderRequest, RecommendationResult>
{
public override Task<RecommendationResult> RunAsync(
WorkflowActivityContext context,
OrderRequest input)
{
logger.LogInformation(
"Generating recommendations for customer {CustomerId}",
input.CustomerId);
RecommendationResult result = new(
true,
[
new RecommendedItem(
"PROD-003",
"Wireless Headphones",
99.99m,
"Based on your laptop purchase"),
new RecommendedItem(
"PROD-004",
"USB-C Hub",
49.99m,
"Complements your laptop setup")
],
"AI recommendations generated successfully by recommendation service");
return Task.FromResult(result);
}
}The project structure should look similar to the image given below.

Firing It Up
With all the pieces in place, we are now ready to run the applications and observe their behavior in action.
aspire run


From the Aspire dashboard, you can access all services along with console & structured logs, traces, and metrics as well as the Diagrid Dev Dashboard, to see the live workflow state.
How do you start the workflow?
To keep things simple, a .http file has been created for starting the workflow and checking its status.
// request.http
@orchestrator = http://localhost:50001
### Schedule an order
POST {{orchestrator}}/workflows?wait=true
Content-Type: application/json
{
"orderId": "ORDER-002",
"customerId": "CUST-002",
"items": [
{
"productId": "PROD-001",
"name": "Laptop",
"price": 999.99,
"quantity": 1
}
],
"paymentMethod": "credit_card"
}
### Replace the id with the value returned by POST /workflows
GET {{orchestrator}}/workflows/ORDER-002-9c3a08b48b92434c8f429ec9042804cbAfter submitting, you can observe the live status of the workflow using the Diagrid Dev Dashboard, and from the Aspire Dashboard, you can explore various options to understand the complete flow from initiation to completion.


Conclusion
Dapr multi-application workflows make it practical to orchestrate real business processes across independently deployed services without losing durability or consistency. In this example, the Order Orchestrator validates and completes orders, delegates payment as a child workflow, and invokes inventory and recommendation activities in other services, showing how cross-service coordination can stay clean, reliable, and maintainable.
With Aspire managing the app topology and Diagrid dashboard providing workflow visibility, the end-to-end developer experience becomes much smoother. Together, these tools help you build resilient, production-ready distributed workflows that scale across teams, regions, and service boundaries with confidence.