Skip to content

Job Types

NexJob provides two job interfaces. Choose based on whether your job needs input data.


IJob — No Input

Use when the job is self-contained and doesn't need external data.

public sealed class CleanupOldLogsJob : IJob
{
    private readonly IDbContext _db;

    public CleanupOldLogsJob(IDbContext db) => _db = db;

    public async Task ExecuteAsync(CancellationToken ct)
    {
        var cutoff = DateTimeOffset.UtcNow.AddDays(-30);
        await _db.Logs.Where(l => l.CreatedAt < cutoff).ExecuteDeleteAsync(ct);
    }
}

// Enqueue
await scheduler.EnqueueAsync<CleanupOldLogsJob>(cancellationToken: ct);

When to use: The job knows what to do from its dependencies alone. Examples: cleanup, health checks, periodic syncs where the job queries for its own work.


IJob — Structured Input

Use when the job needs specific data to execute.

public sealed class ProcessOrderJob : IJob<ProcessOrderInput>
{
    private readonly IOrderProcessor _processor;

    public ProcessOrderJob(IOrderProcessor processor) => _processor = processor;

    public async Task ExecuteAsync(ProcessOrderInput input, CancellationToken ct)
    {
        await _processor.ProcessAsync(input.OrderId, ct);
    }
}

public sealed record ProcessOrderInput(Guid OrderId);

// Enqueue with input
await scheduler.EnqueueAsync<ProcessOrderJob, ProcessOrderInput>(
    new ProcessOrderInput(orderId),
    cancellationToken: ct);

When to use: The job needs data determined at enqueue time. Examples: process a specific order, send email to a specific user, call a webhook with specific payload.

Input Serialization

Input is serialized to JSON and stored in the JobRecord. Requirements:

  • Input types must be JSON-serializable
  • Use record types for immutability
  • Keep input minimal — only what the job needs to execute

Dependency Injection, Scopes & DbContext

A common question is: "How does NexJob handle scoped services, Entity Framework Core, or DbContext?"

Execution Lifecycle

When the dispatcher picks up a job for execution: 1. Isolated IServiceScope: NexJob creates a new DI scope via IServiceProvider.CreateScope() specifically for that job execution. 2. Transient Job Resolution: The job class (e.g. ProcessOrderJob) is resolved from this new scope. 3. Scoped Services (EF Core / DbContext): Any Scoped dependency injected into the job's constructor (like AppDbContext, repositories, or IJobContext) belongs strictly to that execution's scope. 4. Automatic Clean Disposal: When execution finishes (whether successful or failed), the IServiceScope is disposed, safely committing or closing database connections and releasing memory.

public sealed class ProcessOrderJob : IJob<ProcessOrderInput>
{
    private readonly AppDbContext _db; // Scoped EF Core DbContext
    private readonly ILogger<ProcessOrderJob> _logger;

    public ProcessOrderJob(AppDbContext db, ILogger<ProcessOrderJob> logger)
    {
        _db = db;
        _logger = logger;
    }

    public async Task ExecuteAsync(ProcessOrderInput input, CancellationToken ct)
    {
        // Safe: _db is completely isolated to this single job execution.
        // Multiple concurrent workers will never share or conflict on this DbContext instance.
        var order = await _db.Orders.FindAsync([input.OrderId], ct);
        if (order is not null)
        {
            order.Status = "Processed";
            await _db.SaveChangesAsync(ct);
        }
    }
}

[!TIP] You do not need to manually call using var scope = serviceProvider.CreateScope(). NexJob guarantees scope isolation out of the box.


Dead-Letter Handlers

When a job exhausts all retries, NexJob invokes its dead-letter handler. This is optional — jobs without handlers are simply marked as Failed.

public sealed class PaymentDeadLetterHandler : IDeadLetterHandler<ProcessPaymentJob>
{
    private readonly IAlertService _alerts;

    public PaymentDeadLetterHandler(IAlertService alerts) => _alerts = alerts;

    public async Task HandleAsync(
        JobRecord failedJob,
        Exception lastException,
        CancellationToken cancellationToken)
    {
        await _alerts.SendAsync(
            $"Payment job {failedJob.Id} failed after {failedJob.Attempts} attempts: {lastException.Message}",
            cancellationToken);
    }
}

// Register
builder.Services.AddTransient<IDeadLetterHandler<ProcessPaymentJob>, PaymentDeadLetterHandler>();

Key behaviors:

  • Handlers run in an isolated DI scope — exceptions are logged and swallowed, never crashing the dispatcher
  • Works for both IJob and IJob<T> — use the job type as the generic parameter
  • The JobRecord contains all execution context: attempts, error message, input, queue, tags

See Retry & Dead Letter for retry configuration.


Job Execution Filters

Filters wrap job execution with cross-cutting behaviour — logging, tenant injection, audit trails, metrics, circuit breakers. Unlike dead-letter handlers which run after failure, filters run around every execution.

public sealed class ExecutionLoggingFilter : IJobExecutionFilter
{
    private readonly ILogger<ExecutionLoggingFilter> _logger;

    public ExecutionLoggingFilter(ILogger<ExecutionLoggingFilter> logger)
        => _logger = logger;

    public async Task OnExecutingAsync(
        JobExecutingContext context,
        JobExecutionDelegate next,
        CancellationToken ct)
    {
        _logger.LogInformation(
            "Starting job {JobType} attempt {Attempt}",
            context.Job.JobType,
            context.Job.Attempts);

        try
        {
            await next(ct);
            _logger.LogInformation("Job {JobType} succeeded", context.Job.JobType);
        }
        catch (Exception ex)
        {
            _logger.LogWarning(ex, "Job {JobType} failed", context.Job.JobType);
            throw; // rethrow so retry and dead-letter apply normally
        }
    }
}

// Register as a singleton — multiple filters execute in registration order
builder.Services.AddSingleton<IJobExecutionFilter, ExecutionLoggingFilter>();

Key behaviours:

  • Call await next(ct) to pass control to the next filter or the job itself
  • If the job (or a later filter) throws, the exception propagates out of await next(ct): wrap the call in try/catch to react to a failure, and rethrow so retry and dead-letter still apply
  • context.Succeeded and context.Exception are filled in by NexJob only after the whole pipeline has finished, so they are not reliable inside a filter; use try/catch instead
  • Filters are created once, when NexJob starts, so register them as singletons. Per-execution scoped services (a DbContext, IJobContext) are available through context.Services, which is the job's own scope
  • A filter that throws is treated as a job failure — retry and dead-letter apply normally
  • Filters execute in DI registration order. AddNexJob() registers NexJob's own circuit-breaker filter, so a filter registered before AddNexJob() runs before it and one registered after runs after it

When to use filters vs dead-letter handlers:

Use a filter when you need to run code before and after every execution regardless of outcome. Use a dead-letter handler when you need to react specifically to permanent failure after all retries.


Auto-Registration

AddNexJobJobs(assembly) scans the assembly and registers all IJob and IJob<T> implementations as transient services. No manual registration needed. Registration uses TryAddTransient, so a job you already registered yourself (for example with a factory) is left as it is.

// Scans the assembly and registers:
// - CleanupOldLogsJob (IJob)
// - ProcessOrderJob (IJob<ProcessOrderInput>)
// - SendEmailJob (IJob<SendEmailInput>)
builder.Services.AddNexJobJobs(typeof(Program).Assembly);

Rule: Every non-abstract class implementing IJob or IJob<T> in the assembly is registered, including internal ones.


Next Steps