MinimalWorker 3.1.5

dotnet add package MinimalWorker --version 3.1.5
                    
NuGet\Install-Package MinimalWorker -Version 3.1.5
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="MinimalWorker" Version="3.1.5" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="MinimalWorker" Version="3.1.5" />
                    
Directory.Packages.props
<PackageReference Include="MinimalWorker" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add MinimalWorker --version 3.1.5
                    
#r "nuget: MinimalWorker, 3.1.5"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package MinimalWorker@3.1.5
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=MinimalWorker&version=3.1.5
                    
Install as a Cake Addin
#tool nuget:?package=MinimalWorker&version=3.1.5
                    
Install as a Cake Tool

MinimalWorker

Publish NuGet Package NuGet Downloads NuGet Version codecov

Worker

MinimalWorker is a lightweight .NET library that simplifies background worker registration in ASP.NET Core and .NET applications using the IHost interface. It offers three simple extension methods to map background tasks that run continuously or periodically, with support for dependency injection and cancellation tokens.


โœจ Features

  • ๐Ÿš€ Register background workers with a single method call
  • โฑ Support for periodic background tasks
  • ๐Ÿ”„ Built-in support for CancellationToken
  • ๐Ÿงช Works seamlessly with dependency injection (IServiceProvider)
  • ๐Ÿงผ Minimal and clean API
  • ๐Ÿ“ˆ Built-in telemetry with automatic metrics and distributed tracing
  • ๐ŸŽ๏ธ AOT Compilation Support
  • โฐ Configurable execution timeouts
  • ๐Ÿ” Automatic retry with configurable attempts and delays

๐Ÿ“ฆ Installation

Install from NuGet:

dotnet add package MinimalWorker

Or via the NuGet Package Manager:

Install-Package MinimalWorker

๐Ÿ›  Usage

Continuous Background Worker

app.RunBackgroundWorker(async (MyService service, CancellationToken token) =>
{
    while (!token.IsCancellationRequested)
    {
        await service.DoWorkAsync();
        await Task.Delay(1000, token);
    }
});

Periodic Background Worker

app.RunPeriodicBackgroundWorker(TimeSpan.FromMinutes(5), async (MyService service, CancellationToken token) =>
{
    await service.CleanupAsync();
});

Cron-scheduled Background Worker

app.RunCronBackgroundWorker("0 0 * * *", async (CancellationToken ct, MyService service) =>
{
    await service.SendDailyProgressReport();
});

Fluent Configuration with Builder Pattern

All worker methods return an IWorkerBuilder for fluent configuration of names and error handlers:

// Named continuous worker with error handling
app.RunBackgroundWorker(async (OrderService service, CancellationToken token) =>
{
    await service.ProcessOrders();
})
.WithName("order-processor")
.WithErrorHandler(ex => Console.WriteLine($"Order processing failed: {ex.Message}"));

// Named periodic worker
app.RunPeriodicBackgroundWorker(TimeSpan.FromMinutes(30), async (CacheService cache) =>
{
    await cache.Cleanup();
})
.WithName("cache-cleanup");

// Named cron worker with error handling
app.RunCronBackgroundWorker("0 2 * * *", async (ReportService reports) =>
{
    await reports.GenerateDailyReport();
})
.WithName("nightly-report")
.WithErrorHandler(ex => logger.LogError(ex, "Nightly report failed"));

Worker names appear in:

  • Logs: Worker 'order-processor' started (Type: continuous, Id: 1)
  • Metrics: worker.name="order-processor" tag
  • Traces: worker.name attribute on spans

If no name is provided, a default name is generated (e.g., worker-1).

All methods automatically resolve services from the DI container and inject the CancellationToken if it's a parameter.

Workers are automatically initialized and started when the application starts - no additional calls needed!

Error Handling

You can handle errors as part of your Run Worker, with eg. try / catch or you can use the .WithErrorHandler() builder method for handling exceptions:

app.RunBackgroundWorker(async (MyService service, CancellationToken token) =>
{
    await service.DoRiskyWork();
})
.WithErrorHandler(ex =>
{
    // Custom error handling - log, alert, etc.
    Console.WriteLine($"Worker error: {ex.Message}");
    // Worker continues running after error
});

Important:

  • If .WithErrorHandler() is not provided, exceptions are rethrown and will stop all the workers
  • If .WithErrorHandler() is provided, the exception is passed to your handler and the worker continues
  • OperationCanceledException is always handled gracefully during shutdown
Using Dependency Injection in Error Handlers

The .WithErrorHandler() callback currently does not support dependency injection directly. As a workaround, you can capture services from the service provider:

// Capture logger at startup
var logger = app.Services.GetRequiredService<ILogger<Program>>();

app.RunBackgroundWorker(async (CancellationToken token) =>
{
    await DoWork();
})
.WithErrorHandler(ex =>
{
    logger.LogError(ex, "Worker failed");
    // Use the captured logger
});

Note: This captures singleton services. For scoped services, this approach has limitations. Native DI support for error handlers is being considered for a future release.

Timeout Configuration

Use .WithTimeout() to automatically cancel long-running worker executions:

app.RunPeriodicBackgroundWorker(TimeSpan.FromMinutes(5), async (DataService data, CancellationToken token) =>
{
    await data.ProcessBatch(token); // Will be cancelled if takes > 4 minutes
})
.WithTimeout(TimeSpan.FromMinutes(4))
.WithErrorHandler(ex =>
{
    if (ex is TimeoutException)
    {
        Console.WriteLine("Processing timed out!");
    }
});

Behavior:

  • A TimeoutException is thrown when the timeout is exceeded
  • The CancellationToken passed to your delegate is cancelled on timeout
  • Timeouts are not retried (if using .WithRetry())
  • Works with all worker types: continuous, periodic, and cron

Retry Configuration

Use .WithRetry() to automatically retry failed worker executions:

app.RunPeriodicBackgroundWorker(TimeSpan.FromMinutes(5), async (ApiClient api, CancellationToken token) =>
{
    await api.SendData(token); // Will retry up to 3 times on failure
})
.WithRetry(maxAttempts: 3, delay: TimeSpan.FromSeconds(5))
.WithErrorHandler(ex =>
{
    // Called only after all retries are exhausted
    Console.WriteLine($"All retries failed: {ex.Message}");
});

Behavior:

  • maxAttempts - Maximum number of execution attempts (default: 3)
  • delay - Time to wait between retry attempts (default: 5 seconds)
  • Error handler is only called after all retries are exhausted
  • OperationCanceledException (graceful shutdown) is never retried
  • Timeouts are not retried when combined with .WithTimeout()

Combining Timeout and Retry

app.RunPeriodicBackgroundWorker(TimeSpan.FromMinutes(10), async (SyncService sync, CancellationToken token) =>
{
    await sync.SyncData(token);
})
.WithTimeout(TimeSpan.FromMinutes(2))   // Each attempt times out after 2 minutes
.WithRetry(maxAttempts: 3, delay: TimeSpan.FromSeconds(30))  // Retry regular failures, not timeouts
.WithName("data-sync")
.WithErrorHandler(ex => logger.LogError(ex, "Sync failed"));
Startup Dependency Validation

MinimalWorker validates that all required dependencies for your workers are registered during application startup. If any dependencies are missing, the application will fail immediately with a clear error message:

builder.Services.AddSingleton<IMyService, MyService>();
// Forgot to register IOtherService!

app.RunBackgroundWorker((IMyService myService, IOtherService otherService) =>
{
    // This worker will never run
});

await app.RunAsync(); 
// Application terminates immediately:
// FATAL: Worker dependency validation failed: 
// No service for type 'IOtherService' has been registered.

Behavior:

  • โœ… Fail-fast - Application exits immediately during startup (not on first execution)
  • โœ… Clear error messages - Shows exactly which dependency is missing
  • โœ… Exit code 1 - Proper error code for container orchestrators and CI/CD
  • โœ… Production-safe - Prevents workers from running with missing dependencies

This ensures you catch configuration errors early, before deploying to production. The validation happens after all services are registered but before workers start executing, using the same dependency resolution mechanism as the workers themselves.

๐Ÿงช Testing Workers with TimeProvider

MinimalWorker supports .NET's TimeProvider abstraction, enabling instant unit testing of periodic and CRON workers without waiting for real timers!

How It Works

By default, MinimalWorker uses TimeProvider.System (the real system clock). For testing, simply register a FakeTimeProvider from Microsoft.Extensions.TimeProvider.Testing:

dotnet add package Microsoft.Extensions.TimeProvider.Testing

For reliable tests, especially on CI machines, advance time in small steps with delays between each step. This gives timers and async continuations time to fire at each intermediate point:

using Microsoft.Extensions.Time.Testing;

/// <summary>
/// Helper for advancing time reliably in tests.
/// </summary>
public static class WorkerTestHelper
{
    public static FakeTimeProvider CreateTimeProvider()
    {
        // Start at a known time for predictable assertions
        return new FakeTimeProvider(new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero));
    }

    public static async Task AdvanceTimeAsync(FakeTimeProvider timeProvider, TimeSpan amount, int steps = 10)
    {
        var stepSize = TimeSpan.FromTicks(amount.Ticks / steps);
        for (int i = 0; i < steps; i++)
        {
            timeProvider.Advance(stepSize);
            await Task.Yield(); // Allow async continuations to be scheduled
            await Task.Delay(5); // Give time for async work to complete
        }
    }
}

Unit Testing Periodic Workers

[Fact]
public async Task PeriodicWorker_Should_Execute_Multiple_Times()
{
    // Arrange
    var executionCount = 0;
    var timeProvider = WorkerTestHelper.CreateTimeProvider();

    using var host = Host.CreateDefaultBuilder()
        .ConfigureServices(services =>
        {
            // Register FakeTimeProvider - this is the only change needed!
            services.AddSingleton<TimeProvider>(timeProvider);
        })
        .Build();

    host.RunPeriodicBackgroundWorker(TimeSpan.FromMinutes(5), (CancellationToken token) =>
    {
        executionCount++;
        return Task.CompletedTask;
    });

    // Act
    await host.StartAsync();
    
    // Advance 30 minutes - PeriodicTimer fires AFTER each interval
    // Ticks at: 5, 10, 15, 20, 25 min = 5 executions within 30-minute window
    await WorkerTestHelper.AdvanceTimeAsync(timeProvider, TimeSpan.FromMinutes(30));
    
    await host.StopAsync();

    // Assert
    Assert.Equal(5, executionCount);
}

Unit Testing CRON Workers

[Fact]
public async Task CronWorker_Should_Execute_At_Scheduled_Times()
{
    // Arrange
    var executionCount = 0;
    var timeProvider = WorkerTestHelper.CreateTimeProvider();

    using var host = Host.CreateDefaultBuilder()
        .ConfigureServices(services =>
        {
            services.AddSingleton<TimeProvider>(timeProvider);
        })
        .Build();

    // Every hour at minute 0
    host.RunCronBackgroundWorker("0 * * * *", (CancellationToken token) =>
    {
        executionCount++;
        return Task.CompletedTask;
    });

    // Act
    await host.StartAsync();
    
    // Advance 3+ hours - triggers at 01:00, 02:00, 03:00
    await WorkerTestHelper.AdvanceTimeAsync(timeProvider, TimeSpan.FromHours(3).Add(TimeSpan.FromMinutes(1)));
    
    await host.StopAsync();

    // Assert
    Assert.Equal(3, executionCount);
}

Key Benefits

Without TimeProvider With TimeProvider
Test takes 5+ minutes for a 5-min interval Test completes instantly
CRON tests impossible (1+ min minimum) CRON tests work instantly
Flaky timing-dependent tests Deterministic, exact counts
Limited CI/CD testing Full coverage possible

Tips

  • Production code requires no changes - TimeProvider.System is used automatically when no custom provider is registered
  • Only register FakeTimeProvider in tests - Production DI containers don't need any TimeProvider registration
  • Advance time in steps - Use AdvanceTimeAsync() with multiple steps rather than a single Advance() call for reliability
  • Understand PeriodicTimer behavior - PeriodicTimer fires AFTER each interval, so a 5-minute interval over 30 minutes gives 5 executions (at 5, 10, 15, 20, 25 min), not 6
  • Start from a known time - new FakeTimeProvider(new DateTimeOffset(...)) makes assertions predictable
  • Use Task.Yield() + small delay - Between time advances, await Task.Yield(); await Task.Delay(5); ensures async continuations complete, especially on slower CI machines

๐Ÿ”ง How It Works

  • RunBackgroundWorker runs a background task once the application starts, and continues until shutdown.
  • RunPeriodicBackgroundWorker runs your task repeatedly at a fixed interval using PeriodicTimer.
  • RunCronBackgroundWorker runs your task repeatedly based on a CRON expression (UTC time), using NCrontab for timing.
  • Workers are initialized using source generators for AOT compatibility - no reflection at runtime!
  • Workers automatically start when the application starts via lifetime.ApplicationStarted.Register()
  • Services and parameters are resolved per execution using CreateScope() to support scoped dependencies.

๐Ÿ“ก Observability & OpenTelemetry

MinimalWorker provides production-grade observability out of the box with zero configuration required. All workers automatically emit metrics and distributed traces using native .NET APIs (System.Diagnostics.Activity and System.Diagnostics.Metrics).

๐ŸŽฏ What's Automatically Instrumented

Every worker execution is automatically instrumented with:

โœ… Distributed Tracing - Activity spans for each execution
โœ… Metrics - Execution count, error count, and duration histograms
โœ… Tags/Dimensions - Worker ID, type, iteration count, cron expression
โœ… Exception Recording - Full exception details in traces
โœ… Zero Breaking Changes - Works with or without OpenTelemetry configured

๐Ÿ“Š For detailed metrics documentation see METRICS.md

๐Ÿ“š Learn More and example

๐Ÿ’ก Example dashboard

I have included a example dashboard for Grafana in samples/MinimalWorker.OpenTelemetry.Sample project. Below is screenshot of the dashboard.

dashboard logs

๐Ÿงฉ Missing metrics / traces / logs?

If you feel like there is missing some telemetry of any kind. Feel free to submit an issue or contact me.


๐Ÿš€ AOT Compilation Support

MinimalWorker is fully compatible with .NET Native AOT compilation! The library uses source generators instead of reflection, making it perfect for AOT scenarios.

Publishing as AOT

To publish your application as a native AOT binary:

dotnet publish -c Release

Make sure your project file includes:

<PropertyGroup>
  <PublishAot>true</PublishAot>
</PropertyGroup>

This will produce a self-contained native executable with:

  • No .NET runtime dependency - runs on machines without .NET installed
  • Fast startup - native code execution from the start
  • Small binary size - approximately 4-5MB for a minimal application
  • AOT-safe - all worker registration happens via source generators, no reflection

See the MinimalWorker.Aot.Sample project for a complete example.

Below is a screenshot of MinimalWorker.OpenTelemetry.Sample compiled with AOT to a 14 MB binary and running, versus compiling it as a normal standalone build where the size is approximately 80 MB.

assets/aot.png


๐Ÿค– Using AI Coding Assistants

MinimalWorker includes an LLM Reference Guide - a structured document optimized for AI coding assistants like GitHub Copilot, Claude, Cursor, and others.

Why Use the LLM Reference?

The README.llm file contains:

  • Complete API signatures and constraints
  • Common patterns and anti-patterns
  • Testing setup with FakeTimeProvider
  • Scoping behavior differences between worker types
  • Error handling patterns

Getting Started with AI Assistants

When using an AI coding assistant, try prompts like:

Read the https://raw.githubusercontent.com/TopSwagCode/MinimalWorker/refs/heads/master/README.llm, then help me create a
periodic background worker that sends email notifications every 5 minutes.
Using the MinimalWorker library documented in https://raw.githubusercontent.com/TopSwagCode/MinimalWorker/refs/heads/master/README.llm, create a
cron worker that generates daily reports at midnight UTC with proper
error handling.
Based on https://raw.githubusercontent.com/TopSwagCode/MinimalWorker/refs/heads/master/README.llm, write unit tests for my periodic worker using
FakeTimeProvider. The worker runs every 30 seconds.

Example ChatGPT prompt

Tips for Better Results

  1. Reference the file explicitly - Tell the AI to read README.llm first
  2. Be specific about worker type - Continuous, periodic, or cron
  3. Mention testing needs - The guide includes complete testing patterns
  4. Ask about anti-patterns - The guide lists common mistakes to avoid

๐Ÿ‘‹

Thank you for reading this far ๐Ÿ˜ƒ Hope you find it usefull. Feel free to open issues, give feedback or just say hi ๐Ÿ˜„

Product Compatible and additional computed target framework versions.
.NET net8.0 is compatible.  net8.0-android was computed.  net8.0-browser was computed.  net8.0-ios was computed.  net8.0-maccatalyst was computed.  net8.0-macos was computed.  net8.0-tvos was computed.  net8.0-windows was computed.  net9.0 is compatible.  net9.0-android was computed.  net9.0-browser was computed.  net9.0-ios was computed.  net9.0-maccatalyst was computed.  net9.0-macos was computed.  net9.0-tvos was computed.  net9.0-windows was computed.  net10.0 is compatible.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
3.1.5 425 2/12/2026
3.1.4 102 1/31/2026
3.1.3 107 1/19/2026
3.1.2 350 1/18/2026
3.1.1 1,058 12/27/2025
3.1.0 190 12/22/2025
3.0.0 480 12/10/2025
2.0.7 439 12/10/2025
2.0.5 441 12/10/2025
2.0.3 451 12/10/2025
2.0.2 436 12/9/2025
2.0.1 440 12/9/2025
2.0.0 443 12/9/2025
1.0.16 423 4/27/2025
1.0.13 229 4/27/2025
1.0.10 195 4/27/2025
1.0.1 207 4/27/2025
1.0.0 224 4/27/2025
0.0.8 197 4/27/2025
0.0.6 235 4/24/2025
Loading failed