Task Creation

This guide covers creating and configuring tasks and handlers in EverTask.

Table of Contents

Creating Task Requests

Task requests are plain data objects that implement IEverTask. Think of them as the instructions for what work needs to be done, bundled with the parameters that work needs.

Basic Request

public record ProcessOrderTask(int OrderId, string CustomerEmail) : IEverTask;

Complex Request with Multiple Parameters

public record GenerateReportTask(
    Guid ReportId,
    DateTimeOffset StartDate,
    DateTimeOffset EndDate,
    string Format,
    List<string> Recipients) : IEverTask;

Request Design Guidelines

DO:

  • ✅ Use record types for immutability
  • ✅ Use primitive types whenever possible (int, string, DateTime, etc.)
  • ✅ Keep data structures simple and flat
  • ✅ Use List<T> or arrays for collections
  • ✅ Include all necessary context in the request

DON’T:

  • ❌ Include services, DbContexts, or other dependencies
  • ❌ Use complex object graphs with circular references
  • ❌ Include non-serializable types
  • ❌ Store sensitive data in plain text (consider encryption for sensitive fields)

Why these guidelines? EverTask serializes tasks to JSON for persistence, and simple, flat structures are the ones that still deserialize correctly after an application restart.

Creating Task Handlers

Handlers define the logic for executing tasks. They inherit from EverTaskHandler<TTask> and implement the Handle method.

Basic Handler

public class ProcessOrderHandler : EverTaskHandler<ProcessOrderTask>
{
    private readonly IOrderService _orderService;
    private readonly ILogger<ProcessOrderHandler> _logger;

    public ProcessOrderHandler(
        IOrderService orderService,
        ILogger<ProcessOrderHandler> logger)
    {
        _orderService = orderService;
        _logger = logger;
    }

    public override async Task Handle(
        ProcessOrderTask task,
        CancellationToken cancellationToken)
    {
        _logger.LogInformation("Processing order {OrderId}", task.OrderId);

        await _orderService.ProcessAsync(task.OrderId, cancellationToken);

        _logger.LogInformation("Order {OrderId} processed successfully", task.OrderId);
    }
}

Dependency Injection

Handlers support constructor dependency injection: inject whatever services you need.

public class SendNotificationHandler : EverTaskHandler<SendNotificationTask>
{
    private readonly IEmailService _emailService;
    private readonly ISmsService _smsService;
    private readonly IDbContext _dbContext;
    private readonly ILogger<SendNotificationHandler> _logger;

    public SendNotificationHandler(
        IEmailService emailService,
        ISmsService smsService,
        IDbContext dbContext,
        ILogger<SendNotificationHandler> logger)
    {
        _emailService = emailService;
        _smsService = smsService;
        _dbContext = dbContext;
        _logger = logger;
    }

    public override async Task Handle(
        SendNotificationTask task,
        CancellationToken cancellationToken)
    {
        // Handler implementation
    }
}

Note: Each handler execution gets its own service scope, so scoped services (like DbContext) are properly isolated per task.

Execution Context

The payload tells the handler what to do. The execution context tells it which run this is: the row being executed, the slot it stands for, the attempt number, and how late the delivery started. Handlers deriving from EverTaskHandler<TTask> read it from the Context property, in Handle and in every lifecycle callback.

public class SendDigestHandler(IDigestService digests) : EverTaskHandler<SendDigestTask>
{
    public override async Task Handle(SendDigestTask task, CancellationToken cancellationToken)
    {
        // A run that starts long after its slot may be building a digest nobody wants anymore.
        if (Context.Misfire is { Kind: MisfireKind.Late } misfire &&
            misfire.Lateness > TimeSpan.FromMinutes(30))
        {
            Logger.LogWarning("Digest for slot {Slot} is {Late} late, sending a summary instead",
                Context.ScheduledAtUtc, misfire.Lateness);
        }

        await digests.SendAsync(task.UserId, Context.ScheduledAtUtc ?? Context.StartedAtUtc, cancellationToken);
    }
}

What it carries

Member Type Value
TaskId Guid Persistence id of the row being executed
ScheduleId Guid? The recurring schedule this delivery is an occurrence of, null when it is not one
TaskKey string? The idempotency key the task was dispatched with
ScheduledAtUtc DateTimeOffset? The slot this delivery stands for: the scheduled time of a delayed task, the occurrence time of a recurring one, null for a task dispatched to run immediately
ScheduledAtLocal DateTimeOffset? The same slot in the schedule’s own time zone, offset included, null when the schedule carries no zone. See Time Zones
TimeZoneId string? IANA id of that zone, null when there is none
StartedAtUtc DateTimeOffset When this delivery actually started
Attempt int 1-based execution attempt: 1 on the first run of Handle, 2 on the first retry
RunNumber int 1-based run within a recurring series (1 for a one-shot). Survives restarts: it comes from the durable counter
ScheduleVersion int Version of the schedule definition behind this delivery
IsRecurring bool The delivery belongs to a recurring series
IsOccurrence bool The delivery is an occurrence row owned by a schedule row
Misfire MisfireInfo? null when the delivery ran on time; otherwise Kind, Lateness, and the missed range with the number of slots it holds (MissedCountIsExact says whether that number is the real total or a lower bound)

ScheduledAtUtc is the slot the task was scheduled for, and it does not move. A rate-limit deferral parks the task at a later slot of its own, but the context keeps reporting the original one, so StartedAtUtc - ScheduledAtUtc measures how late the run really is.

Attempt changes while the delivery is alive. Read it where you need it instead of copying it into a field at the top of Handle. Inside OnRetry it is already the attempt about to start; inside OnError, the last one that ran.

Reading it outside the handler

A repository or a logging enricher deep in the dependency graph reads the same context through ITaskExecutionContextAccessor, registered by AddEverTask:

public class AuditingRepository(ITaskExecutionContextAccessor tasks, AppDbContext db)
{
    public async Task SaveAsync(Invoice invoice, CancellationToken ct)
    {
        invoice.ProducedByTaskId = tasks.Current?.TaskId;   // null outside a task execution
        await db.SaveChangesAsync(ct);
    }
}

Current follows the asynchronous flow of the delivery, so two tasks running side by side never see each other’s context, and it is null everywhere else: in a controller, in a hosted service, in a handler’s constructor.

Handlers that implement IEverTaskHandler<TTask> directly (without the base class) have no Context property. Use the accessor, or implement SetExecutionContext yourself. It is a default interface member with an empty body, so handlers written before it existed keep compiling and running unchanged.

When a delivery counts as late

A delivery is a misfire when it starts more than SetMisfireThreshold (default 5 seconds) after its slot. This is an observation threshold, and only that: it decides what Context.Misfire reports, never whether the task runs. A durable schedule applies the same threshold one step earlier, to decide whether the occurrence it is about to create stands for missed work.

services.AddEverTask(opt => opt
    .RegisterTasksFromAssembly(typeof(Program).Assembly)
    .SetMisfireThreshold(TimeSpan.FromMinutes(1)));   // seconds of drift are not worth reporting here

Lifecycle Hooks

EverTask gives you optional hooks to observe and react to task events as they happen.

OnStarted

Called right when a task begins execution:

public class MyTaskHandler : EverTaskHandler<MyTask>
{
    private readonly ILogger<MyTaskHandler> _logger;

    public MyTaskHandler(ILogger<MyTaskHandler> logger)
    {
        _logger = logger;
    }

    public override ValueTask OnStarted(Guid taskId)
    {
        _logger.LogInformation("Task {TaskId} started at {Time}", taskId, DateTime.UtcNow);
        return ValueTask.CompletedTask;
    }

    public override async Task Handle(MyTask task, CancellationToken cancellationToken)
    {
        // Task execution logic
    }
}

OnCompleted

Called when a task completes successfully:

public override ValueTask OnCompleted(Guid taskId)
{
    _logger.LogInformation("Task {TaskId} completed successfully", taskId);

    // Could dispatch follow-up tasks here
    // await _dispatcher.Dispatch(new FollowUpTask(taskId));

    return ValueTask.CompletedTask;
}

OnError

Called when a task ultimately fails after exhausting all retry attempts:

public override ValueTask OnError(Guid taskId, Exception? exception, string? message)
{
    _logger.LogError(
        exception,
        "Task {TaskId} failed: {Message}",
        taskId,
        message);

    // Could send alerts, update status, dispatch compensation tasks, etc.

    return ValueTask.CompletedTask;
}

OnRetry

Called before each retry attempt, after the retry delay has elapsed and immediately before Handle runs again. It is not called for the initial attempt, only for retries:

public override ValueTask OnRetry(Guid taskId, int attemptNumber, Exception exception, TimeSpan delay)
{
    _logger.LogWarning(
        exception,
        "Task {TaskId} retry {Attempt} after {DelayMs}ms",
        taskId,
        attemptNumber,
        delay.TotalMilliseconds);

    // Useful for retry metrics, alerting on excessive retries, etc.

    return ValueTask.CompletedTask;
}

The attemptNumber is 1-based, so the first retry is attempt 1. See Resilience & Error Handling for how retries are configured.

DisposeAsyncCore

Called during handler disposal for any cleanup you need:

protected override ValueTask DisposeAsyncCore()
{
    _logger.LogInformation("Handler resources being cleaned up");

    // Perform any custom cleanup

    return base.DisposeAsyncCore();
}

Complete Lifecycle Example

public class CompleteLifecycleHandler : EverTaskHandler<CompleteLifecycleTask>
{
    private readonly ILogger<CompleteLifecycleHandler> _logger;
    private readonly ITaskDispatcher _dispatcher;

    public CompleteLifecycleHandler(
        ILogger<CompleteLifecycleHandler> logger,
        ITaskDispatcher dispatcher)
    {
        _logger = logger;
        _dispatcher = dispatcher;
    }

    public override ValueTask OnStarted(Guid taskId)
    {
        _logger.LogInformation("Task {TaskId} started", taskId);
        return ValueTask.CompletedTask;
    }

    public override async Task Handle(
        CompleteLifecycleTask task,
        CancellationToken cancellationToken)
    {
        _logger.LogInformation("Executing task logic for {Data}", task.Data);

        // Simulate work
        await Task.Delay(1000, cancellationToken);
    }

    public override async ValueTask OnCompleted(Guid taskId)
    {
        _logger.LogInformation("Task {TaskId} completed, dispatching follow-up", taskId);

        // Chain to next task
        await _dispatcher.Dispatch(new FollowUpTask());
    }

    public override ValueTask OnRetry(Guid taskId, int attemptNumber, Exception exception, TimeSpan delay)
    {
        _logger.LogWarning(exception, "Task {TaskId} retry {Attempt} after {Delay}", taskId, attemptNumber, delay);
        return ValueTask.CompletedTask;
    }

    public override ValueTask OnError(Guid taskId, Exception? exception, string? message)
    {
        _logger.LogError(exception, "Task {TaskId} failed: {Message}", taskId, message);

        // Could dispatch error handling task, send alerts, etc.

        return ValueTask.CompletedTask;
    }

    protected override ValueTask DisposeAsyncCore()
    {
        _logger.LogInformation("Handler disposed");
        return base.DisposeAsyncCore();
    }
}

Handler Configuration

You can customize individual handlers by overriding their virtual properties.

Custom Timeout

Need more (or less) time for a particular handler? Override the global timeout:

public class LongRunningTaskHandler : EverTaskHandler<LongRunningTask>
{
    // This handler gets 10 minutes instead of the global default
    public override TimeSpan? Timeout => TimeSpan.FromMinutes(10);

    public override async Task Handle(LongRunningTask task, CancellationToken cancellationToken)
    {
        // Long-running work here
        // CancellationToken will be cancelled after 10 minutes
    }
}

Custom Retry Policy

Some tasks need more aggressive retries than others. You can override the global policy per handler:

public class CriticalTaskHandler : EverTaskHandler<CriticalTask>
{
    // Retry 5 times with 1 second between attempts
    public override IRetryPolicy? RetryPolicy => new LinearRetryPolicy(5, TimeSpan.FromSeconds(1));

    public override async Task Handle(CriticalTask task, CancellationToken cancellationToken)
    {
        // Critical work with more aggressive retries
    }
}

See Resilience & Error Handling for more details on retry policies.

Queue Routing

Want to isolate certain workloads? Route tasks to specific queues:

public class HighPriorityHandler : EverTaskHandler<HighPriorityTask>
{
    public override string? QueueName => "high-priority";

    public override async Task Handle(HighPriorityTask task, CancellationToken cancellationToken)
    {
        // This task runs in the "high-priority" queue
    }
}

See Multi-Queue Support for more details.

Combined Configuration

public class CustomizedHandler : EverTaskHandler<CustomizedTask>
{
    public override TimeSpan? Timeout => TimeSpan.FromMinutes(5);
    public override IRetryPolicy? RetryPolicy => new LinearRetryPolicy(3, TimeSpan.FromSeconds(2));
    public override string? QueueName => "background";

    public override async Task Handle(CustomizedTask task, CancellationToken cancellationToken)
    {
        // Custom timeout, retry policy, and queue
    }
}

Best Practices

Task Design

  1. Keep tasks focused - Each task should do one thing well. Breaking work into smaller tasks makes them easier to test and debug.
  2. Make tasks idempotent - Design them so they’re safe to retry if execution gets interrupted partway through.
  3. Include correlation IDs - When you have workflows that chain multiple tasks together, correlation IDs make tracing much easier.
  4. Version your tasks - If your task structure might evolve over time, include a version field. Future you will thank present you.
// Good: Focused, idempotent, traceable
public record ProcessPaymentTask(
    Guid PaymentId,
    Guid OrderId,
    Guid CorrelationId,
    int Version = 1) : IEverTask;

Handler Design

  1. Use CancellationToken - Always check and respect the cancellation token, especially before expensive operations.
  2. Log appropriately - Use Info for start/complete, Error for actual failures. Don’t spam the logs with Debug messages nobody will read.
  3. Handle errors gracefully - Let retry policies handle transient failures. Don’t catch and swallow exceptions that should trigger retries.
  4. Keep handlers stateless - Each execution should be independent. Don’t store state between task executions.
public class WellDesignedHandler : EverTaskHandler<WellDesignedTask>
{
    private readonly IService _service;
    private readonly ILogger<WellDesignedHandler> _logger;

    public WellDesignedHandler(IService service, ILogger<WellDesignedHandler> logger)
    {
        _service = service;
        _logger = logger;
    }

    public override async Task Handle(WellDesignedTask task, CancellationToken cancellationToken)
    {
        _logger.LogInformation("Starting task {TaskId}", task.Id);

        try
        {
            // Check cancellation before expensive operations
            cancellationToken.ThrowIfCancellationRequested();

            var result = await _service.ProcessAsync(task, cancellationToken);

            _logger.LogInformation("Task {TaskId} completed with result {Result}", task.Id, result);
        }
        catch (OperationCanceledException)
        {
            _logger.LogWarning("Task {TaskId} was cancelled", task.Id);
            throw; // Re-throw to mark as cancelled
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Task {TaskId} failed", task.Id);
            throw; // Re-throw to trigger retry policy
        }
    }
}

Performance Considerations

  1. Async all the way - Use async/await for all I/O operations. Don’t mix sync and async code.
  2. Avoid blocking calls - Never use .Wait() or .Result. They’ll deadlock in some contexts and hurt scalability in others.
  3. Batch database operations - When you’re processing multiple items, batch your database calls instead of hitting the DB for each item.
  4. Use appropriate queues - CPU-intensive tasks belong in a queue with low parallelism so they don’t starve I/O-bound tasks.
// Good: Fully async, batched operations
public override async Task Handle(BatchProcessTask task, CancellationToken cancellationToken)
{
    var items = await _repository.GetBatchAsync(task.BatchId, cancellationToken);

    // Process in batches for better performance
    foreach (var batch in items.Chunk(100))
    {
        cancellationToken.ThrowIfCancellationRequested();
        await _service.ProcessBatchAsync(batch, cancellationToken);
    }
}

Next Steps


Copyright © 2026 Giampaolo Gabba. Distributed under the MIT License.

This site uses Just the Docs, a documentation theme for Jekyll.