| name | background-jobs-and-events |
| description | In-process/cross-service events, entity change reactions, deferred/recurring background work. USE FOR: ILocalEventBus / IDistributedEventBus, EntityCreated/Updated/DeletedEventData, [EventName] ETOs, IBackgroundJobManager jobs, AsyncPeriodicBackgroundWorkerBase workers. DO NOT USE FOR: distributed cache/lock (distributed-caching-and-locking); SignalR push (add-signalr-realtime); microservice message contracts (design-module-and-service-communication). |
| license | MIT |
Background Jobs & Events (ABP)
Guidance for the ABP event bus (local + distributed), background jobs, and background workers. All APIs below are verified against ABP source. Inject services via constructor; the event bus only subscribes handlers that are already registered in DI, so a handler class must be registered (e.g. by implementing ITransientDependency or explicit registration) — implementing the handler interface alone is not enough.
When to Use
- Decouple code in the same process by publishing/handling local events.
- React to entity create/update/delete performed through a repository.
- Notify other microservices/apps of something that happened (distributed events).
- Queue one-shot work to run later, with automatic retry on failure (background jobs).
- Run recurring/periodic loops (cleanup, polling) as background workers.
When Not to Use
- Coordinating a critical section across app instances or typed distributed caching — use the distributed-caching-and-locking skill (
IAbpDistributedLock, IDistributedCache<T>).
- Pushing real-time updates to connected clients — use the add-signalr-realtime skill.
- Defining the message/contract shape between microservices at the architecture level — use the design-module-and-service-communication skill.
How it works
Local event bus (in-process)
ILocalEventBus (namespace Volo.Abp.EventBus.Local) publishes events within the same process. Publish via the inherited IEventBus.PublishAsync:
public class MyService : ITransientDependency
{
private readonly ILocalEventBus _localEventBus;
public MyService(ILocalEventBus localEventBus) => _localEventBus = localEventBus;
public async Task DoAsync()
{
await _localEventBus.PublishAsync(new StockCountChangedEvent { ProductId = 42, NewCount = 10 });
}
}
Handle by implementing ILocalEventHandler<TEvent> (namespace Volo.Abp.EventBus). The handler is only subscribed if its class is registered in DI, so add ITransientDependency (or register it explicitly):
public class StockCountChangedHandler
: ILocalEventHandler<StockCountChangedEvent>, ITransientDependency
{
public Task HandleEventAsync(StockCountChangedEvent eventData)
{
return Task.CompletedTask;
}
}
A single class can implement multiple ILocalEventHandler<T> interfaces.
Entity change events
ABP automatically publishes local events when entities are created/updated/deleted through a repository. Handle them with the generic event data types from Volo.Abp.Domain.Entities.Events. The changed entity is on the .Entity property:
public class ProductCacheInvalidator
: ILocalEventHandler<EntityCreatedEventData<Product>>,
ILocalEventHandler<EntityUpdatedEventData<Product>>,
ILocalEventHandler<EntityDeletedEventData<Product>>,
ITransientDependency
{
public Task HandleEventAsync(EntityCreatedEventData<Product> e) => Invalidate(e.Entity);
public Task HandleEventAsync(EntityUpdatedEventData<Product> e) => Invalidate(e.Entity);
public Task HandleEventAsync(EntityDeletedEventData<Product> e) => Invalidate(e.Entity);
private Task Invalidate(Product product) => Task.CompletedTask;
}
Hierarchy: EntityCreatedEventData<T>, EntityUpdatedEventData<T>, EntityDeletedEventData<T> all derive from EntityChangedEventData<T>, which exposes TEntity Entity { get; }. Subscribe to EntityChangedEventData<T> to catch all three.
Distributed event bus (cross-service)
IDistributedEventBus (namespace Volo.Abp.EventBus.Distributed) publishes across processes/microservices through a provider (RabbitMQ, Kafka, Azure Service Bus, Dapr, Rebus, or the default in-memory one). Note the extra useOutbox parameter:
Task PublishAsync<TEvent>(TEvent eventData, bool onUnitOfWorkComplete = true, bool useOutbox = true)
where TEvent : class;
Distributed events are transferred as serializable ETOs (Event Transfer Objects). Give the ETO a stable, transport-level name with [EventName] (namespace Volo.Abp.EventBus) so producer and consumer agree regardless of CLR type name:
[EventName("MyApp.Stock.Changed")]
public class StockChangedEto
{
public Guid ProductId { get; set; }
public int NewCount { get; set; }
}
await _distributedEventBus.PublishAsync(new StockChangedEto { ProductId = id, NewCount = count });
Handle with IDistributedEventHandler<TEto> (namespace Volo.Abp.EventBus.Distributed):
public class StockChangedHandler
: IDistributedEventHandler<StockChangedEto>, ITransientDependency
{
public Task HandleEventAsync(StockChangedEto eventData) => Task.CompletedTask;
}
Provider selection is a module + configuration concern (e.g. AbpEventBusRabbitMqModule, AbpEventBusKafkaModule) plus settings in appsettings.json. Keep useOutbox/inbox enabled for reliable, transactional delivery when the provider is configured for it.
Background jobs (deferred, one-shot work)
Use IBackgroundJobManager (namespace Volo.Abp.BackgroundJobs) to queue work that runs later, retried on failure:
Task<string> EnqueueAsync<TArgs>(
TArgs args,
BackgroundJobPriority priority = BackgroundJobPriority.Normal,
TimeSpan? delay = null);
await _backgroundJobManager.EnqueueAsync(
new EmailSendingArgs { To = "a@b.com", Subject = "Hi" },
delay: TimeSpan.FromSeconds(30));
BackgroundJobPriority values: Low, BelowNormal, Normal (default), AboveNormal, High.
Define the job by deriving from AsyncBackgroundJob<TArgs> (implements IAsyncBackgroundJob<TArgs>) and overriding ExecuteAsync. The args type is how the job is looked up.
public class EmailSendingJob : AsyncBackgroundJob<EmailSendingArgs>, ITransientDependency
{
public override async Task ExecuteAsync(EmailSendingArgs args)
{
}
}
For synchronous jobs, derive from BackgroundJob<TArgs> (IBackgroundJob<TArgs>) and override void Execute(TArgs args). Prefer the async version. The startup template ships the Background Jobs module, which persists jobs in the database by default (so they survive restarts and are retried); the framework only falls back to an in-memory store when no persistent job-store module is installed. Hangfire, Quartz, and RabbitMQ are alternative providers you can swap in — not a requirement for production.
Dynamic background jobs (enqueue by name)
When the job type isn't known at compile time (plugin systems, dynamic workflows), use IDynamicBackgroundJobManager (namespace Volo.Abp.BackgroundJobs) to enqueue by job name:
await _dynamicJobManager.EnqueueAsync("emails", new { To = "user@example.com", Subject = "Hi" });
_dynamicJobManager.RegisterHandler("ProcessOrder", async (context, ct) => { });
await _dynamicJobManager.EnqueueAsync("ProcessOrder", new { OrderId = orderId });
EnqueueAsync(name, args) looks up the job by [BackgroundJobName] (or a runtime-registered handler), deserializes the args, and runs through the same persistent pipeline as typed jobs. Use typed IBackgroundJobManager when the job type is known; reach for the dynamic manager only when it genuinely isn't (the args are loosely typed, so you lose compile-time checking).
Background workers (recurring work)
For periodic loops, derive from AsyncPeriodicBackgroundWorkerBase (namespace Volo.Abp.BackgroundWorkers). Its constructor takes AbpAsyncTimer timer and IServiceScopeFactory serviceScopeFactory; set timer.Period (milliseconds) and override DoWorkAsync. Each run gets a fresh DI scope via workerContext.ServiceProvider — resolve scoped services from there, not the constructor:
public class CleanupWorker : AsyncPeriodicBackgroundWorkerBase
{
public CleanupWorker(AbpAsyncTimer timer, IServiceScopeFactory scopeFactory)
: base(timer, scopeFactory)
{
Timer.Period = 60_000;
}
protected override async Task DoWorkAsync(PeriodicBackgroundWorkerContext workerContext)
{
var repo = workerContext.ServiceProvider
.GetRequiredService<IRepository<StaleRecord, Guid>>();
}
}
AsyncPeriodicBackgroundWorkerBase also exposes a CronExpression property, but the default in-memory worker only schedules by Period — its AbpAsyncTimer ignores CronExpression. Cron scheduling is honored only when you use a Hangfire, Quartz, or TickerQ background-worker provider.
Register the worker in your module's OnApplicationInitializationAsync with the extension AddBackgroundWorkerAsync<TWorker> on ApplicationInitializationContext:
public override async Task OnApplicationInitializationAsync(ApplicationInitializationContext context)
{
await context.AddBackgroundWorkerAsync<CleanupWorker>();
}
To add a worker at runtime, resolve IBackgroundWorkerManager and call AddAsync(IBackgroundWorker worker). Custom workers can also derive from BackgroundWorkerBase (which implements IBackgroundWorker) for full control.
Choosing the right tool
- Local event bus — decouple code in the same process/app; entity change reactions.
- Distributed event bus — notify other microservices/apps; use
[EventName] + ETO.
- Background job — run one-shot work later, with retries (emails, reports).
- Background worker — run recurring/periodic work (cleanup, polling).
Provider integrations (mainstream)
Each provider needs its [DependsOn] module. The configuration shape differs per provider — some use Configure<...Options>, some PreConfigure<...>, and some also need an initialization-phase call (see the note below the examples). appsettings.json config works too, and values set in code take precedence over it.
Background jobs
Hangfire — depend on AbpBackgroundJobsHangfireModule and wire a Hangfire storage/server in ConfigureServices (Hangfire has no ABP options class; you configure Hangfire itself):
[DependsOn(typeof(AbpBackgroundJobsHangfireModule))]
public class MyModule : AbpModule
{
public override void ConfigureServices(ServiceConfigurationContext context)
{
var configuration = context.Services.GetConfiguration();
context.Services.AddHangfire(config =>
config.UseSqlServerStorage(configuration.GetConnectionString("Default")));
}
}
RabbitMQ — depend on AbpBackgroundJobsRabbitMqModule; connections come from the RabbitMQ:Connections config (default = localhost), and AbpRabbitMqBackgroundJobOptions customizes queue names/connections per job:
Configure<AbpRabbitMqBackgroundJobOptions>(options =>
{
options.DefaultQueueNamePrefix = "my_app_jobs.";
options.JobQueues[typeof(EmailSendingArgs)] = new JobQueueConfiguration(
typeof(EmailSendingArgs),
queueName: "my_app_jobs.emails",
delayedQueueName: "my_app_jobs.emails.delayed",
connectionName: "Default");
});
Background workers
Swap the default in-memory worker timer for a provider that honors CronExpression — but the two mainstream providers are not both zero-config:
- Hangfire (
AbpBackgroundWorkersHangfireModule) needs a Hangfire storage/server configured, exactly like the Hangfire job provider above (AddHangfire(config => config.UseSqlServerStorage(...))). The module adapts your workers but does not pick or configure storage — without it Hangfire fails to start.
- Quartz (
AbpBackgroundWorkersQuartzModule) runs on Quartz's default in-memory (RAM) store with no options; for production, configure a persistent Quartz store.
[DependsOn(typeof(AbpBackgroundWorkersQuartzModule))]
public class MyModule : AbpModule { }
Your existing AsyncPeriodicBackgroundWorkerBase workers, registered the same way with context.AddBackgroundWorkerAsync<TWorker>(), are then dispatched by the chosen provider (Hangfire adapts them via HangfirePeriodicBackgroundWorkerAdapter). This is what makes CronExpression honored on Quartz/Hangfire/TickerQ, unlike the default in-memory timer.
Distributed event bus
RabbitMQ — depend on AbpEventBusRabbitMqModule; AbpRabbitMqEventBusOptions sets ConnectionName, ClientName (the queue name for this app), and ExchangeName:
[DependsOn(typeof(AbpEventBusRabbitMqModule))]
public class MyModule : AbpModule
{
public override void ConfigureServices(ServiceConfigurationContext context)
{
Configure<AbpRabbitMqEventBusOptions>(options =>
{
options.ClientName = "MyApp";
options.ExchangeName = "MyMessages";
});
}
}
Kafka — depend on AbpEventBusKafkaModule; AbpKafkaEventBusOptions sets ConnectionName, GroupId (consumer group), and TopicName:
[DependsOn(typeof(AbpEventBusKafkaModule))]
public class MyModule : AbpModule
{
public override void ConfigureServices(ServiceConfigurationContext context)
{
Configure<AbpKafkaEventBusOptions>(options =>
{
options.GroupId = "MyGroupId";
options.TopicName = "MyTopicName";
});
}
}
The other providers (Quartz/TickerQ for jobs, Azure Service Bus/Rebus for the event bus) each add their [DependsOn] module, but the configuration shape is not uniform — check each provider's doc:
- Rebus configures in
PreConfigureServices via PreConfigure<AbpRebusEventBusOptions>(...), not Configure.
- TickerQ also needs an initialization-phase call —
context.GetHost().UseAbpTickerQ() in OnApplicationInitialization (the UseAbpTickerQ extension is on IHost, not the context), not just options.
- Quartz uses two options at different phases:
PreConfigure<AbpQuartzOptions>(...) for the scheduler plus Configure<AbpBackgroundJobQuartzOptions>(...) for the job integration.
Validation
- Build the module. Workers are resolved and started at initialization; event handlers are resolved from DI each time the event fires, and jobs are resolved from DI at execution time.
- For a local/distributed handler, confirm it is registered in DI (implements
ITransientDependency or is registered explicitly) — an unregistered handler is silently never invoked.
- For a background worker, confirm registration via
context.AddBackgroundWorkerAsync<TWorker>() and observe DoWorkAsync firing on the configured Timer.Period (or the CronExpression, only if you run a Hangfire/Quartz/TickerQ provider).
- For a background job, confirm
ExecuteAsync runs after the configured delay (with a persistent provider wired in for anything beyond dev).
Common Pitfalls
- Implementing the handler interface is not enough — the event bus only subscribes handlers already registered in DI. Add
ITransientDependency or register the class explicitly, or it never fires.
- Resolving scoped services from a background worker's constructor — each
DoWorkAsync run gets a fresh scope; resolve scoped services from workerContext.ServiceProvider, not the constructor.
- Relying on CLR type names for distributed events — producer and consumer must agree on a stable
[EventName] on the ETO, or delivery breaks when type names differ across services.
- Running background jobs without a persistent store — the in-memory store is only the framework fallback used when neither the Background Jobs module nor a custom
IBackgroundJobStore is installed. The startup template already installs the module with a database store (per your ORM), which is production-usable; Hangfire/Quartz/RabbitMQ are optional alternative providers, not a requirement. In a cluster, still configure a distributed lock so jobs execute on one instance.
- Assuming distributed delivery is reliable by default — keep
useOutbox/inbox enabled (with a provider configured for it) for transactional delivery.