Background jobs
AppointMe runs recurring work as Hangfire jobs stored in SQL Server. Each job runs as the system and invokes Wolverine commands, usually one per company, so the .NET handlers stay tenant-scoped.
- Scheduler
- Hangfire 1.8.25, cron recurring jobs
- Storage
- SQL Server,
hangfireschema - Runs as
SystemIdentityandSystemPrincipal- Dashboard
/admin/jobs, super admins only
How a recurring job runs
Section titled “How a recurring job runs”Modules declare jobs through a small interface in Shared; the API host maps it to Hangfire and runs every job as the system.
hangfireSystemPrincipal, loops over companiesInvokeForCompany runs it for one tenant| Piece | Where | What it does |
|---|---|---|
IRecurringJobRegistrar |
AppointMe.Shared/Jobs |
Register(IRecurringJobScheduler). One per job, next to the job in its slice. |
IRecurringJobScheduler |
AppointMe.Shared/Jobs |
AddOrUpdate<TJob>(jobId, methodCall, cronExpression). Modules never reference Hangfire. |
AddRecurringJobs(assembly) |
AppointMe.Shared/Jobs/JobsServiceCollectionExtensions.cs |
Registers every registrar in the assembly as a singleton (Scrutor). CRM and Booking call it. |
HangfireRecurringJobScheduler |
AppointMe.Api/Hangfire |
Forwards to Hangfire’s IRecurringJobManager.AddOrUpdate. |
RecurringJobsHostedService |
AppointMe.Api/Hangfire |
On startup, passes the scheduler to every registrar. |
SystemContextJobFilter |
AppointMe.Api/Hangfire |
A server filter that sets the system identity and principal for the job’s duration. |
AddAppointMeHangfire |
AppointMe.Api/Hangfire/HangfireExtensions.cs |
Storage, server, filter and scheduler. Program.cs skips it during code generation. |
In the code: registrar, job, handler
Section titled “In the code: registrar, job, handler”A job is a plain class with a Run method. Its registrar gives it an id and a cron expression.
internal sealed class AttendeeReconciliationJobRegistrar : IRecurringJobRegistrar{ private const string JobId = "booking:attendee-reconciliation"; private const string CronExpression = "0 3 * * *"; // daily at 03:00
public void Register(IRecurringJobScheduler scheduler) { scheduler.AddOrUpdate<AttendeeReconciliationJob>( JobId, job => job.Run(CancellationToken.None), CronExpression); }}Hangfire stores the expression, and when the job runs it passes its own token in place of CancellationToken.None (Hangfire docs). It builds the job from DI, and BookingModule registers each job with AddScoped. The job does no business work itself: it finds the companies and invokes a command for each one.
public sealed class AttendeeReconciliationJob(BookingDbContext dbContext, IMessageBus bus){ public async Task Run(CancellationToken cancellationToken) { var companies = await dbContext.BookingCompanies.ToListAsync(cancellationToken); foreach (var company in companies) { await bus.InvokeForCompany(company.Id, new ReconcileAttendeesCommand(), cancellationToken); } }}Who the job runs as
Section titled “Who the job runs as”A job has no user and no company. SystemContextJobFilter pushes the system context before the job body runs, and disposes both scopes in OnPerformed:
public void OnPerforming(PerformingContext context){ var identityScope = currentIdentity.Change(new SystemIdentity()); var principalScope = currentPrincipal.Change(new SystemPrincipal());
context.Items[StateKey] = new State(identityScope, principalScope);}SystemPrincipal has every permission, so principal.Require(...) passes in anything the job invokes.
The company comes from bus.InvokeForCompany(companyId, command). It calls Wolverine’s InvokeForTenantAsync, and TenantContextPolicy sets ICurrentCompany before the handler runs, so a CompanyId parameter and the EF Core query filters work as in a request. See Multi-tenancy and Identity & principal.
Jobs that ship
Section titled “Jobs that ship”Three reconciliation jobs repair Booking’s copies of other modules’ data. Two demo jobs keep the demo tenant’s dashboard populated.
| Job id | Class | Cron | What it does |
|---|---|---|---|
booking:company-reconciliation |
BookingCompanyReconciliationJob |
0 * * * *, hourly |
Invokes ReconcileBookingCompaniesCommand, which upserts a BookingCompany for every company in ICompanyRehydrationSource. |
booking:service-provider-reconciliation |
ServiceProviderReconciliationJob |
0 * * * *, hourly |
Per company, compares service providers with IEmployeeRehydrationSource and creates, updates or deletes to match. |
booking:attendee-reconciliation |
AttendeeReconciliationJob |
0 3 * * *, 03:00 daily |
Per company, pages through CRM customers 100 at a time through ICustomerRehydrationSource and upserts attendees. |
booking:top-up-demo-appointments |
TopUpDemoAppointmentsJob |
0 3 * * *, 03:00 daily |
Adds 15 appointments per company. Registered only when Demo:Enabled is true. |
crm:top-up-demo-customers |
TopUpDemoCustomersJob |
0 3 * * *, 03:00 daily |
Adds 2 customers per company. Registered only when Demo:Enabled is true. |
Cron times are UTC, Hangfire’s default, and IRecurringJobScheduler has no time-zone parameter. The reconciliation handlers catch and log a failure per row, then carry on with the next one. The rehydration sources are interfaces in the publisher’s *.Contracts project, so Booking reads Organizations and CRM data without referencing those modules.
Dashboard and storage
Section titled “Dashboard and storage”The Hangfire dashboard is at /admin/jobs, behind an ASP.NET Core policy that admits only super admins.
public IEndpointConventionBuilder MapAppointMeHangfireDashboard(){ return endpoints .MapHangfireDashboard("/admin/jobs", new DashboardOptions { Authorization = [] }) .RequireAuthorization(HangfireDashboardPolicy.Name);}| Setting | Value |
|---|---|
| Who gets in | HangfireDashboardPolicy needs an authenticated user and a SuperAdminRequirement: the user’s email must be in Authentication:SuperAdmins. |
| Hangfire’s own filters | Authorization = [], so the ASP.NET Core policy is the only check. |
| Local super admin | demo@appointme.dev, set in appsettings.Development.json |
| Link in the SPA | “Background Jobs” under Administration, shown when GET /api/v1/me returns isSuperAdmin: true |
| Storage | UseSqlServerStorage on the AppointMeSql database, schema hangfire, created at startup (PrepareSchemaIfNecessary = true) |
| Serialization | UseSimpleAssemblyNameTypeSerializer() and UseRecommendedSerializerSettings() |
Super admin is a platform role, not a company role: SuperAdminRegistry is an email list, the same for every tenant. infra/README.md lists replacing it with a role or permission once roles reach the token.
Hangfire job or Wolverine message?
Section titled “Hangfire job or Wolverine message?”Use Hangfire for work on a clock, and a Wolverine message for work that follows from a change.
| You need | Use | Why |
|---|---|---|
| A recurring schedule (hourly, nightly) | A Hangfire recurring job | Cron, a dashboard with history and Trigger now, and automatic retries. Every recurring job in AppointMe is built this way. |
| Work for every company | A Hangfire job that calls InvokeForCompany per company |
It’s how the shipped jobs keep each handler tenant-scoped. |
| A one-off delay after a command (“remind in 24 hours”) | A Wolverine scheduled message: bus.ScheduleAsync(message, delay), or return message.DelayedFor(...) |
It’s stored in the durable inbox and leaves through the outbox with your transaction. A handler that takes CompanyId stamps the company on it. Nothing in AppointMe uses one. |
| A reaction to a change in another module | A domain event | See Domain events & messaging. |
Extend it
Section titled “Extend it”| Step | Where | What |
|---|---|---|
| 1. Write the command | <Feature>/<Name>Command.cs and its handler |
The handler takes CompanyId companyId and does the work for one company. |
| 2. Write the job | <Feature>/<Name>Job.cs |
public async Task Run(CancellationToken cancellationToken): list the companies, call bus.InvokeForCompany(...) for each. |
| 3. Register it | The module’s Add<Module>Module() |
.AddScoped<MyJob>() |
| 4. Schedule it | <Feature>/<Name>JobRegistrar.cs |
Implement IRecurringJobRegistrar and call scheduler.AddOrUpdate<MyJob>("module:my-job", job => job.Run(CancellationToken.None), "0 * * * *"). |
| 5. Discover it | The module’s Add<Module>Module() |
.AddRecurringJobs(ModuleAssembly.Instance), if the module doesn’t call it yet |
| 6. Check | /admin/jobs |
Find it under Recurring jobs and click Trigger now. |
Gotchas
Section titled “Gotchas”| Symptom | Cause | Fix |
|---|---|---|
A job you deleted, or switched off with Demo:Enabled, still runs |
Recurring jobs persist in the hangfire schema. A registrar that skips AddOrUpdate doesn’t remove one, and IRecurringJobScheduler has no remove method. |
Delete it in the dashboard, or call IRecurringJobManager.RemoveIfExists(jobId). |
A job fails with AccessDeniedException: Active company was not specified. |
The job used InvokeAsync for a handler that takes CompanyId |
Use bus.InvokeForCompany(companyId, command). |
| One company’s failure stops the rest | InvokeForCompany runs inline and throws, and the shipped job loops don’t catch |
Catch per company in the job, or keep handlers idempotent so Hangfire’s retry of the whole job is safe. |
You’re signed in but /admin/jobs won’t open |
Your email isn’t in Authentication:SuperAdmins |
Add it for that environment. |
| A job runs at the wrong local hour | Cron is evaluated in UTC | Write the expression in UTC. |
Why Hangfire next to Wolverine?2 min · optional
Hangfire brings what a recurring job needs: cron schedules, a dashboard with run history, manual triggers and retries. The jobs stay thin, because each one only dispatches Wolverine commands, so the logic lives in handlers you can call and test like any other.
Modules depend on IRecurringJobScheduler, not on Hangfire, so swapping the scheduler touches the API host only. The cost is a second store of background state (hangfire beside wolverine) and an LGPL dependency.
Why reconciliation jobs, if events are durable?1 min · optional
Durable delivery doesn’t cover every failure. A handler bug can dead-letter events, and a module you add later starts with no copy of the past. The reconciliation jobs rebuild Booking’s projections from the source modules on a schedule, so they heal themselves without anyone replaying messages.