Skip to content

NodaTime

Use NodaTime values for scheduling, recurring time zones, job payloads and propagated context.

3 min read

Immediate.Jobs.NodaTime adds NodaTime scheduling overloads and configures the job serializer for NodaTime values in payloads and propagated context.

Install and register

dotnet add package Immediate.Jobs.NodaTime --prerelease

Register the integration with Jobs:

Program.cs
using Immediate.Jobs.NodaTime;

builder.Services.AddMyAppHandlers();
builder.Services.AddMyAppJobs(options => options.UseInMemory());
builder.Services.AddImmediateJobsNodaTime();

AddImmediateJobsNodaTime() replaces the default IJobSerializer with NodaTimeJobSerializer. By default, that serializer uses JsonSerializerDefaults.Web and DateTimeZoneProviders.Tzdb. Pass an IDateTimeZoneProvider when the application uses a different provider:

using NodaTime;

IDateTimeZoneProvider timeZoneProvider = DateTimeZoneProviders.Tzdb;
builder.Services.AddSingleton(timeZoneProvider);
builder.Services.AddImmediateJobsNodaTime(timeZoneProvider);

Every worker that reads the stored jobs must use a provider that recognizes the same time-zone IDs.

Schedule with Duration and Instant

The generated scheduler remains the service you inject. Import Immediate.Jobs.NodaTime to make the extension overloads available:

using Immediate.Jobs.NodaTime;
using NodaTime;

[Handler, Job]
public sealed partial class ReconcilePayment(IPaymentProvider payments)
{
	public sealed record Payload(Guid OrderId, Instant ExpectedSettlementAt);

	private ValueTask HandleAsync(Payload payload, CancellationToken cancellationToken) =>
		payments.ReconcileAsync(payload.OrderId, payload.ExpectedSettlementAt, cancellationToken);
}

public sealed class PaymentScheduler(ReconcilePayment.Scheduler reconciliation, IClock clock)
{
	public ValueTask<JobHandle> RetryAsync(Guid orderId, CancellationToken cancellationToken)
	{
		var expectedSettlement = clock.GetCurrentInstant() + Duration.FromHours(2);

		return reconciliation.ScheduleAsync(
			new(orderId, expectedSettlement),
			Duration.FromMinutes(10),
			cancellationToken
		);
	}

	public ValueTask<JobHandle> AtSettlementAsync(Guid orderId, CancellationToken cancellationToken)
	{
		var expectedSettlement = clock.GetCurrentInstant() + Duration.FromHours(2);

		return reconciliation.ScheduleAtAsync(
			new(orderId, expectedSettlement),
			expectedSettlement,
			cancellationToken
		);
	}
}

The package converts Duration to TimeSpan and Instant to DateTimeOffset before calling the core scheduler. It does not replace Immediate.Jobs’ TimeProvider; inject IClock only when your application code benefits from a NodaTime clock.

The complete scheduling surface is:

OperationNodaTime valueNotes
ScheduleAsyncDurationRelative delay, with an optional fair-queue group ID.
ScheduleAtAsyncInstantAbsolute time, with an optional fair-queue group ID.
AddToBatchDuration?Delayed atomic-batch member.
AddToBatchAtInstantAtomic-batch member at an absolute time.
ScheduleAfterAsync(JobHandle, ...)Duration?Delayed continuation after one job.
ScheduleAfterAsync(ReadOnlySpan<JobHandle>, ...)Duration?Delayed fan-in continuation after every supplied parent.
ScheduleAfterAsync(BatchHandle, ...)Duration?Delayed continuation after a whole batch.

Batch and continuation overloads otherwise retain the behavior described in Batches and continuations, including trigger and storage-capability requirements.

Use DateTimeZone for dynamic schedules

The recurring overload accepts a DateTimeZone and persists its Id:

using Immediate.Jobs.NodaTime;
using NodaTime;

[Handler, Job(Name = "payment-reconciliation")]
public sealed partial class PaymentReconciliation(IPaymentProvider payments)
{
	private ValueTask HandleAsync(
		EmptyJobRequest request,
		CancellationToken cancellationToken
	) => payments.ReconcileOutstandingAsync(cancellationToken);
}

public sealed class PaymentScheduleSetup(PaymentReconciliation.Scheduler reconciliation)
{
	public ValueTask ConfigureAsync(CancellationToken cancellationToken)
	{
		var vienna = DateTimeZoneProviders.Tzdb["Europe/Vienna"];

		return reconciliation.AddOrUpdateRecurringAsync(
			"daily-payment-reconciliation",
			"0 3 * * *",
			vienna,
			cancellationToken
		);
	}
}

This overload is available on IRecurringJobScheduler, including the generated scheduler for a payloadless job without a code-defined cron expression. Cron parsing, reconciliation, overlap and manual-trigger behavior are covered in Recurring jobs.

Serialize NodaTime values

The registration above applies the standard NodaTime.Serialization.SystemTextJson converters to the serializer used for job payloads and context snapshots. Generated jobs continue to supply their source-generated JsonTypeInfo<T> metadata, including for payloads such as ReconcilePayment.Payload above.

For application-owned serializer settings, pass your options to NodaTimeJobSerializer; its constructor adds the NodaTime converters:

using System.Text.Json;
using Immediate.Jobs.NodaTime;
using NodaTime;

var jsonOptions = new JsonSerializerOptions(JsonSerializerDefaults.Web)
{
	WriteIndented = false,
};

IJobSerializer serializer = new NodaTimeJobSerializer(
	jsonOptions,
	DateTimeZoneProviders.Tzdb
);

Call JsonSerializerOptions.UseNodaTime(...) when you need the same converters on options used outside IJobSerializer. NodaTimeJobSerializer also has parameterless and IDateTimeZoneProvider constructors. If you replace IJobSerializer yourself, register the configured serializer after Jobs registration.