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
recordtypes 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
IJobandIJob<T>— use the job type as the generic parameter - The
JobRecordcontains 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 intry/catchto react to a failure, and rethrow so retry and dead-letter still apply context.Succeededandcontext.Exceptionare filled in by NexJob only after the whole pipeline has finished, so they are not reliable inside a filter; usetry/catchinstead- Filters are created once, when NexJob starts, so register them as singletons. Per-execution scoped services (a
DbContext,IJobContext) are available throughcontext.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 beforeAddNexJob()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¶
- Scheduling — Enqueue, delay, schedule at specific time
- Retry & Dead Letter — Configure retries and dead-letter handlers
- IJobContext — Access runtime context inside jobs