9 minute read

The Problem: Scheduled Workflows Are Overcomplicated

You need to run a multi-step business process on a schedule: nightly order reconciliation, hourly data syncs, daily report generation. The typical approach? Hangfire for scheduling, custom code for the workflow logic, and manual error handling for each step. That’s three concerns tangled together, plus a Redis or SQL dependency just for scheduling.

What if you could have:

  • Zero external dependencies for scheduling (no Redis, no SQL)
  • Automatic compensation if any step fails (saga pattern, built-in)
  • Microsecond execution instead of milliseconds
  • Clean separation between “when to run” and “what to run”

That’s exactly what happens when you pair Coravel (scheduling) with WorkflowForge (workflow orchestration). I like that split. Took me a while to stop stuffing everything into one scheduler-shaped lump.

I’ve built a complete runnable sample that demonstrates this pattern end-to-end:

You can access the entire code from my GitHub Repo


The Architecture

┌─────────────────────────────────────────────────────────┐
│                  .NET Console Host                      │
├─────────────────────────────────────────────────────────┤
│  Coravel Scheduler                                      │
│  └─ "Run ReconciliationJob every N seconds"             │
│       │                                                 │
│       ▼                                                 │
│  WorkflowForge Workflow                                 │
│  └─ Step 1: Fetch unprocessed orders                    │
│  └─ Step 2: Process payments    ← (refund on failure)   │
│  └─ Step 3: Reserve inventory   ← (release on failure)  │
│  └─ Step 4: [MaybeFail]         ← toggle to demo saga   │
│  └─ Step 5: Send confirmations                          │
└─────────────────────────────────────────────────────────┘

Coravel decides when to run. WorkflowForge handles what to run, including automatic rollback of completed steps when something fails downstream.


Solution Layout

The sample splits concerns cleanly across two projects:

WorkflowForge/
├── AnimatLabs.WorkflowForge.Workflows.Sample/        # Business logic
│   └── NightlyReconciliation/
│       ├── Workflow.cs                                # Workflow builder
│       ├── ReconciliationKeys.cs                      # Shared state keys
│       ├── Models/
│       │   ├── Order.cs
│       │   └── PaymentTransaction.cs
│       ├── Operations/
│       │   ├── FetchUnprocessedOrdersOperation.cs
│       │   ├── ProcessPaymentsOperation.cs
│       │   ├── UpdateInventoryOperation.cs
│       │   ├── MaybeFailOperation.cs
│       │   └── SendConfirmationEmailsOperation.cs
│       └── Services/                                  # Interfaces only
│           ├── IOrderRepository.cs
│           ├── IPaymentService.cs
│           ├── IInventoryService.cs
│           └── IEmailSender.cs
├── AnimatLabs.WorkflowForge.CoravelScheduledWorkflows/  # Host + fakes
│   ├── Program.cs
│   ├── appsettings.json
│   ├── Jobs/ReconciliationJob.cs
│   ├── Options/ReconciliationJobOptions.cs
│   └── Services/
│       ├── InMemoryOrderRepository.cs
│       ├── FakePaymentService.cs
│       ├── FakeInventoryService.cs
│       └── FakeEmailSender.cs
└── AnimatLabs.WorkflowForge.sln

The workflows project contains only business logic: operations, models, and service interfaces. It has a single dependency: WorkflowForge.

The host project wires Coravel scheduling, provides fake service implementations for demo purposes, and depends on Coravel, WorkflowForge, and Microsoft.Extensions.Hosting.


Setup: Two NuGet Packages

dotnet add package Coravel
dotnet add package WorkflowForge

No database migrations. No connection strings for the scheduler.


The Workflow Definition

The entire workflow is defined in one method. Chain the operations and build:

using AnimatLabs.WorkflowForge.Workflows.Sample.NightlyReconciliation.Operations;
using AnimatLabs.WorkflowForge.Workflows.Sample.NightlyReconciliation.Services;
using WorkflowForge.Abstractions;
using WF = WorkflowForge.WorkflowForge;

namespace AnimatLabs.WorkflowForge.Workflows.Sample.NightlyReconciliation;

public static class NightlyReconciliationWorkflow
{
    public static IWorkflow Build(
        IOrderRepository orderRepository,
        IPaymentService paymentService,
        IInventoryService inventoryService,
        IEmailSender emailSender)
    {
        return WF
            .CreateWorkflow("NightlyReconciliation")
            .AddOperation(new FetchUnprocessedOrdersOperation(orderRepository))
            .AddOperation(new ProcessPaymentsOperation(paymentService))
            .AddOperation(new UpdateInventoryOperation(inventoryService))
            .AddOperation(new MaybeFailOperation())
            .AddOperation(new SendConfirmationEmailsOperation(emailSender))
            .Build();
    }
}

Dependencies are injected into operations via constructors (clean, testable, no service locator).


The Models

Simple domain objects that flow through the workflow:

public sealed class Order
{
    public required string Id { get; init; } = null!;
    public decimal Amount { get; init; }
    public required IReadOnlyList<string> Items { get; init; } = null!;
}

public sealed class PaymentTransaction
{
    public required string TransactionId { get; init; } = null!;
    public required string OrderId { get; init; } = null!;
    public decimal Amount { get; init; }
}

Shared State Keys

Operations communicate through the foundry’s property bag. The keys are simple constants, no magic strings scattered across the codebase:

public static class ReconciliationKeys
{
    public const string BatchSize = "recon.batch_size";
    public const string DemoFailure = "recon.demo_failure";
    public const string Orders = "recon.orders";
    public const string PaymentTransactions = "recon.payment_transactions";
}

Operations with Automatic Compensation

This is where WorkflowForge shines. Each operation extends WorkflowOperationBase and can implement both execution (ForgeAsyncCore) and compensation (RestoreAsync). If a downstream step fails, completed operations are automatically rolled back in reverse order.

Fetching Orders

The first step loads unprocessed orders and stores them in the foundry for downstream operations:

public sealed class FetchUnprocessedOrdersOperation : WorkflowOperationBase
{
    private readonly IOrderRepository _orderRepository;

    public FetchUnprocessedOrdersOperation(IOrderRepository orderRepository)
    {
        _orderRepository = orderRepository;
    }

    public override string Name => "FetchUnprocessedOrders";

    protected override async Task<object?> ForgeAsyncCore(
        object? inputData, IWorkflowFoundry foundry, CancellationToken cancellationToken)
    {
        var batchSize = foundry.GetPropertyOrDefault<int>(ReconciliationKeys.BatchSize, 3);
        foundry.Logger.LogInformation("Fetching up to {BatchSize} order(s)", batchSize);

        var orders = await _orderRepository
            .GetUnprocessedOrdersAsync(batchSize, cancellationToken)
            .ConfigureAwait(false);

        foundry.SetProperty(ReconciliationKeys.Orders, orders);
        foundry.Logger.LogInformation("Fetched {Count} order(s)", orders.Count);
        return inputData;
    }

    public override Task RestoreAsync(
        object? outputData, IWorkflowFoundry foundry, CancellationToken cancellationToken)
    {
        foundry.Properties.TryRemove(ReconciliationKeys.Orders, out _);
        return Task.CompletedTask;
    }
}

Processing Payments (with Refund Compensation)

This is the core compensation pattern. ForgeAsyncCore charges each order and stores the transactions. RestoreAsync refunds them if anything fails downstream:

public sealed class ProcessPaymentsOperation : WorkflowOperationBase
{
    private readonly IPaymentService _paymentService;

    public ProcessPaymentsOperation(IPaymentService paymentService)
    {
        _paymentService = paymentService;
    }

    public override string Name => "ProcessPayments";

    protected override async Task<object?> ForgeAsyncCore(
        object? inputData, IWorkflowFoundry foundry, CancellationToken cancellationToken)
    {
        var orders = foundry.GetPropertyOrDefault<IReadOnlyList<Order>>(ReconciliationKeys.Orders)
            ?? Array.Empty<Order>();

        foundry.Logger.LogInformation("Processing payments for {Count} order(s)", orders.Count);

        var transactions = new List<PaymentTransaction>(orders.Count);
        foreach (var order in orders)
        {
            cancellationToken.ThrowIfCancellationRequested();
            var tx = await _paymentService.ChargeAsync(order, cancellationToken).ConfigureAwait(false);
            transactions.Add(tx);
        }

        foundry.SetProperty(ReconciliationKeys.PaymentTransactions, transactions);
        foundry.Logger.LogInformation("Processed {Count} payment(s)", transactions.Count);
        return inputData;
    }

    public override async Task RestoreAsync(
        object? outputData, IWorkflowFoundry foundry, CancellationToken cancellationToken)
    {
        var transactions = foundry.GetPropertyOrDefault<IReadOnlyList<PaymentTransaction>>(
            ReconciliationKeys.PaymentTransactions) ?? Array.Empty<PaymentTransaction>();

        foundry.Logger.LogWarning("Compensating payments: refunding {Count} transaction(s)",
            transactions.Count);

        foreach (var tx in transactions)
        {
            cancellationToken.ThrowIfCancellationRequested();
            await _paymentService.RefundAsync(tx, cancellationToken).ConfigureAwait(false);
        }
    }
}

Updating Inventory (with Release Compensation)

Same pattern: reserve on execute, release on compensate:

public sealed class UpdateInventoryOperation : WorkflowOperationBase
{
    private readonly IInventoryService _inventoryService;

    public UpdateInventoryOperation(IInventoryService inventoryService)
    {
        _inventoryService = inventoryService;
    }

    public override string Name => "UpdateInventory";

    protected override async Task<object?> ForgeAsyncCore(
        object? inputData, IWorkflowFoundry foundry, CancellationToken cancellationToken)
    {
        var orders = foundry.GetPropertyOrDefault<IReadOnlyList<Order>>(ReconciliationKeys.Orders)
            ?? Array.Empty<Order>();

        foundry.Logger.LogInformation("Updating inventory for {Count} order(s)", orders.Count);

        foreach (var order in orders)
        {
            cancellationToken.ThrowIfCancellationRequested();
            await _inventoryService.ReserveAsync(order.Id, order.Items, cancellationToken)
                .ConfigureAwait(false);
        }

        return inputData;
    }

    public override async Task RestoreAsync(
        object? outputData, IWorkflowFoundry foundry, CancellationToken cancellationToken)
    {
        var orders = foundry.GetPropertyOrDefault<IReadOnlyList<Order>>(ReconciliationKeys.Orders)
            ?? Array.Empty<Order>();

        foundry.Logger.LogWarning("Compensating inventory: releasing {Count} reservation(s)",
            orders.Count);

        foreach (var order in orders)
        {
            cancellationToken.ThrowIfCancellationRequested();
            await _inventoryService.ReleaseAsync(order.Id, order.Items, cancellationToken)
                .ConfigureAwait(false);
        }
    }
}

The Simulated Failure Toggle

This operation exists purely to demonstrate compensation. When DemoFailure is set to true, it throws, which makes WorkflowForge roll back payments and inventory automatically:

public sealed class MaybeFailOperation : WorkflowOperationBase
{
    public override string Name => "MaybeFail";

    protected override Task<object?> ForgeAsyncCore(
        object? inputData, IWorkflowFoundry foundry, CancellationToken cancellationToken)
    {
        var demoFailure = foundry.GetPropertyOrDefault<bool>(ReconciliationKeys.DemoFailure, false);

        if (demoFailure)
        {
            foundry.Logger.LogError("Simulated failure triggered to demonstrate compensation");
            throw new InvalidOperationException("Simulated failure (DemoFailure=true)");
        }

        return Task.FromResult(inputData);
    }

    public override Task RestoreAsync(
        object? outputData, IWorkflowFoundry foundry, CancellationToken cancellationToken)
        => Task.CompletedTask;
}

Sending Confirmations

The final step. No meaningful compensation needed (you can’t unsend an email):

public sealed class SendConfirmationEmailsOperation : WorkflowOperationBase
{
    private readonly IEmailSender _emailSender;

    public SendConfirmationEmailsOperation(IEmailSender emailSender)
    {
        _emailSender = emailSender;
    }

    public override string Name => "SendConfirmationEmails";

    protected override async Task<object?> ForgeAsyncCore(
        object? inputData, IWorkflowFoundry foundry, CancellationToken cancellationToken)
    {
        var orders = foundry.GetPropertyOrDefault<IReadOnlyList<Order>>(ReconciliationKeys.Orders)
            ?? Array.Empty<Order>();

        foundry.Logger.LogInformation("Sending confirmations for {Count} order(s)", orders.Count);

        foreach (var order in orders)
        {
            cancellationToken.ThrowIfCancellationRequested();
            await _emailSender.SendConfirmationAsync(order.Id, cancellationToken)
                .ConfigureAwait(false);
        }

        return inputData;
    }

    public override Task RestoreAsync(
        object? outputData, IWorkflowFoundry foundry, CancellationToken cancellationToken)
        => Task.CompletedTask;
}

Why do non-mutating operations override RestoreAsync? WorkflowForge triggers compensation when any operation that overrides RestoreAsync has completed before a failure. Even no-op restore implementations (like MaybeFail and SendConfirmationEmails) should override it so the engine knows they participated in the workflow’s compensation chain.


The Coravel Scheduling Layer

The Job

The ReconciliationJob implements Coravel’s IInvocable. It builds the workflow, creates a foundry with initial properties, and runs it:

public sealed class ReconciliationJob : IInvocable
{
    private readonly IOrderRepository _orderRepository;
    private readonly IPaymentService _paymentService;
    private readonly IInventoryService _inventoryService;
    private readonly IEmailSender _emailSender;
    private readonly IOptions<ReconciliationJobOptions> _options;
    private readonly ILogger<ReconciliationJob> _logger;

    public ReconciliationJob(
        IOrderRepository orderRepository,
        IPaymentService paymentService,
        IInventoryService inventoryService,
        IEmailSender emailSender,
        IOptions<ReconciliationJobOptions> options,
        ILogger<ReconciliationJob> logger)
    {
        _orderRepository = orderRepository;
        _paymentService = paymentService;
        _inventoryService = inventoryService;
        _emailSender = emailSender;
        _options = options;
        _logger = logger;
    }

    public async Task Invoke()
    {
        var options = _options.Value;
        _logger.LogInformation(
            "Starting scheduled reconciliation workflow (DemoFailure={DemoFailure})",
            options.DemoFailure);

        var workflow = NightlyReconciliationWorkflow.Build(
            _orderRepository, _paymentService, _inventoryService, _emailSender);

        using var foundry = WF.CreateFoundry(
            workflowName: workflow.Name,
            initialProperties: new Dictionary<string, object?>
            {
                [ReconciliationKeys.BatchSize] = 3,
                [ReconciliationKeys.DemoFailure] = options.DemoFailure
            });

        using var smith = WF.CreateSmith(new ConsoleLogger("WF"));

        try
        {
            await smith.ForgeAsync(workflow, foundry).ConfigureAwait(false);
            _logger.LogInformation("Reconciliation workflow finished successfully");
        }
        catch (Exception ex)
        {
            _logger.LogError(ex,
                "Reconciliation workflow failed (compensation should have run for completed steps)");
        }
    }
}

Configuration

{
  "ReconciliationJob": {
    "ScheduleSeconds": 10,
    "DemoFailure": false
  }
}
public sealed class ReconciliationJobOptions
{
    public const string SectionName = "ReconciliationJob";
    public int ScheduleSeconds { get; set; } = 10;
    public bool DemoFailure { get; set; } = false;
}

Host Setup (Program.cs)

Standard .NET hosting with Coravel scheduler. The entire host in one file:

var builder = Host.CreateApplicationBuilder(args);

builder.Logging.ClearProviders();
builder.Logging.AddSimpleConsole(options =>
{
    options.SingleLine = true;
    options.TimestampFormat = "HH:mm:ss ";
});

builder.Services.Configure<ReconciliationJobOptions>(
    builder.Configuration.GetSection(ReconciliationJobOptions.SectionName));

builder.Services.AddScheduler();
builder.Services.AddTransient<ReconciliationJob>();

builder.Services.AddSingleton<IOrderRepository, InMemoryOrderRepository>();
builder.Services.AddSingleton<IPaymentService, FakePaymentService>();
builder.Services.AddSingleton<IInventoryService, FakeInventoryService>();
builder.Services.AddSingleton<IEmailSender, FakeEmailSender>();

var host = builder.Build();

host.Services.UseScheduler(scheduler =>
{
    var options = host.Services.GetRequiredService<IOptions<ReconciliationJobOptions>>().Value;

    scheduler
        .Schedule<ReconciliationJob>()
        .EverySeconds(options.ScheduleSeconds)
        .PreventOverlapping(nameof(ReconciliationJob));
});

await host.RunAsync();

PreventOverlapping ensures a new run doesn’t start if the previous one is still going. Important for workflows that touch external systems.


Try It Yourself

# Happy path -- all steps succeed
dotnet run --project AnimatLabs.WorkflowForge.CoravelScheduledWorkflows

# Toggle simulated failure to see compensation in action
dotnet run --project AnimatLabs.WorkflowForge.CoravelScheduledWorkflows -- \
    ReconciliationJob:DemoFailure=true

With DemoFailure=true, the MaybeFailOperation throws after payments and inventory are processed. Watch the logs. You’ll see WorkflowForge automatically refund payments and release inventory reservations. The saga pattern, without writing a single line of orchestration code.


Why This Combination Works

Concern Who Handles It
When to run Coravel (cron-like expressions, overlap prevention)
What to run WorkflowForge (operation sequence, data flow)
What if it fails WorkflowForge (automatic compensation in reverse)
Configuration Standard .NET IOptions<T>
DI Standard IServiceCollection

Each tool does one thing well. No overlap, no conflict.


When to Use This Pattern

Good fit:

  • Multi-step business processes on a schedule
  • Operations that need rollback if later steps fail (payments, inventory, external APIs)
  • Teams that want to avoid Hangfire/Quartz complexity
  • Applications where in-memory scheduling is sufficient

Not ideal:

  • Jobs that must survive application restarts (use Hangfire with persistence)
  • Distributed job coordination across instances (use Hangfire or Quartz)
  • Visual workflow designers for business users (use Elsa)

Resources

What Where
WorkflowForge GitHub | NuGet | Docs
Coravel GitHub | Docs
This Sample Playground Repo
Benchmarks 511x faster than alternatives

If you’ve tried a different scheduling approach with WorkflowForge, I’d be curious how it compared.

If WorkflowForge has been useful to you, a ⭐ star on GitHub helps it reach more .NET developers. And if you'd like to support the work behind it, Ko-fi is always open!


See Also

Comments