docs/en/Community-Articles/2026-06-25-implementing-domain-events-in-abp-microservices/Post.md
Domain events look simple on paper: something happened, react to it. In a real ABP microservices solution, the hard part is not raising the event. The hard part is deciding which event belongs inside the service, which one should cross service boundaries, and how to publish it without losing data or coupling your modules into a distributed monolith.
ABP gives you the primitives to do this well: local events, distributed events, aggregate-root support, and built-in outbox/inbox infrastructure. Used correctly, they let you keep your domain model clean while still coordinating work across microservices.
This article walks through a practical way to implement domain events in ABP microservices, including the boundary between domain and integration events, the transactional flow, outbox/inbox configuration, and the pitfalls that usually show up after the first production incident.
The most important design choice is this:
These are not interchangeable, even if the payload looks similar.
A domain event represents something meaningful that happened inside your domain model.
Examples:
OrderPlacedDomainEventPaymentCapturedDomainEventProductStockDecreasedDomainEventThese events are typically handled in-process. In ABP, that usually means the local event bus or ABP's domain event dispatching from aggregates tracked by the ORM.
Use domain events when you want to:
An integration event is a contract for communication between microservices.
Examples:
OrderPlacedEtoStockCountChangedEtoCustomerDeletedEtoIn ABP, these go through the distributed event bus. With a real provider like RabbitMQ, Kafka, or Azure Service Bus, they leave the current process and get consumed elsewhere.
Use integration events when you want to:
A good practical rule is:
That separation prevents leaking internal domain details into your external contracts.
ABP already supports the eventing model most microservices need.
The local event bus is in-process. It is appropriate for:
The distributed event bus is for cross-process communication.
A few practical notes matter here:
ABP aggregate roots can generate events directly. In practice, if your entity inherits from AggregateRoot, you can use methods like:
AddDomainEvent(...)AddDistributedEvent(...)ABP collects these events and dispatches them during persistence, typically around SaveChanges in EF Core-based applications.
That means your aggregate can say, "this happened," without knowing who will react.
Let's use a simple example: an Ordering microservice places an order, and an Inventory microservice needs to update its local stock view.
The aggregate should express business meaning, not infrastructure concerns.
public class Order : AggregateRoot<Guid>
{
public OrderStatus Status { get; private set; }
public Guid CustomerId { get; private set; }
public void Place()
{
if (Status != OrderStatus.Draft)
{
throw new BusinessException("Order is not in draft state.");
}
Status = OrderStatus.Placed;
AddDomainEvent(new OrderPlacedDomainEvent(Id, CustomerId));
}
}
public record OrderPlacedDomainEvent(Guid OrderId, Guid CustomerId);
This is internal and business-oriented. It says nothing about RabbitMQ, contracts, queues, or other services.
Now handle that event in-process.
Typical responsibilities here:
public class OrderPlacedDomainEventHandler :
ILocalEventHandler<OrderPlacedDomainEvent>,
ITransientDependency
{
private readonly IDistributedEventBus _distributedEventBus;
public OrderPlacedDomainEventHandler(IDistributedEventBus distributedEventBus)
{
_distributedEventBus = distributedEventBus;
}
public async Task HandleEventAsync(OrderPlacedDomainEvent eventData)
{
await _distributedEventBus.PublishAsync(
new OrderPlacedEto
{
OrderId = eventData.OrderId,
CustomerId = eventData.CustomerId
}
);
}
}
This is where the boundary is enforced:
Your Event Transfer Object should be serializable and intentionally small.
public class OrderPlacedEto
{
public Guid OrderId { get; set; }
public Guid CustomerId { get; set; }
}
A few ABP-friendly rules for ETOs:
Do not publish your aggregate itself. That creates versioning and serialization problems fast.
In the Inventory microservice, handle the integration event through the distributed event bus.
public class OrderPlacedHandler :
IDistributedEventHandler<OrderPlacedEto>,
ITransientDependency
{
private readonly IInventorySyncService _inventorySyncService;
public OrderPlacedHandler(IInventorySyncService inventorySyncService)
{
_inventorySyncService = inventorySyncService;
}
[UnitOfWork]
public virtual async Task HandleEventAsync(OrderPlacedEto eventData)
{
await _inventorySyncService.HandleOrderPlacedAsync(
eventData.OrderId,
eventData.CustomerId
);
}
}
The UnitOfWork attribute is important when the handler writes to the local database.
A lot of design mistakes come from treating these as the same thing. They are not.
Characteristics:
Typical example:
Order was placed, so calculate loyalty points internallyCharacteristics:
Typical example:
If a local domain event handler fails, that failure is usually part of the current application's execution path.
If a distributed event consumer fails in another microservice, the original transaction is already committed. You are now in the world of retries, poison messages, compensation, and idempotency.
That is why integration events need a different level of discipline.
ABP also allows aggregates and domain services to add distributed events directly.
public class Product : AggregateRoot<Guid>
{
public int StockCount { get; private set; }
public void ChangeStock(int newCount)
{
StockCount = newCount;
AddDistributedEvent(new StockCountChangedEto
{
ProductId = Id,
NewCount = newCount
});
}
}
public class StockCountChangedEto
{
public Guid ProductId { get; set; }
public int NewCount { get; set; }
}
This is convenient, and ABP supports it well.
Still, I would use it selectively.
In larger systems, the domain-event-then-integration-event pattern usually ages better.
Without outbox, the classic failure is simple:
Now one microservice thinks the operation happened, and the others never hear about it.
Outbox exists to remove that gap.
With outbox enabled:
That gives you transactional safety between your local state change and the fact that an event must be published.
Your DbContext needs to participate in event outbox support.
public class OrderingDbContext : AbpDbContext<OrderingDbContext>, IHasEventOutbox
{
public DbSet<OutgoingEventRecord> OutgoingEvents { get; set; }
protected override void OnModelCreating(ModelBuilder builder)
{
base.OnModelCreating(builder);
builder.ConfigureEventOutbox();
}
}
Then configure the outbox:
Configure<AbpDistributedEventBusOptions>(options =>
{
options.Outboxes.Configure(config =>
{
config.UseDbContext<OrderingDbContext>();
config.Selector = type => true;
});
});
The selector lets you choose which events go through that outbox. This becomes useful when a solution has multiple modules or database contexts.
In modular ABP solutions, not every event should use every outbox.
Selectors help you:
That flexibility matters more as the solution grows.
Outbox protects publishing. Inbox protects consumption.
Without inbox, a consumer can receive an event and fail mid-processing, leaving you unsure whether the local change happened, whether to retry, or whether the event was already partially applied.
With inbox enabled:
This gives you practical idempotency support and much better operational behavior.
Your consumer DbContext participates similarly.
public class InventoryDbContext : AbpDbContext<InventoryDbContext>, IHasEventInbox
{
public DbSet<IncomingEventRecord> IncomingEvents { get; set; }
protected override void OnModelCreating(ModelBuilder builder)
{
base.OnModelCreating(builder);
builder.ConfigureEventInbox();
}
}
Then wire it up:
Configure<AbpDistributedEventBusOptions>(options =>
{
options.Inboxes.Configure(config =>
{
config.UseDbContext<InventoryDbContext>();
config.EventSelector = type => true;
config.HandlerSelector = type => true;
});
});
Inbox and outbox improve reliability, but they add:
That trade-off is usually worth it for microservices. It is often unnecessary for a simple monolith.
ABP can automatically publish distributed entity lifecycle events.
Common built-in types include:
EntityCreatedEto<T>EntityUpdatedEto<T>EntityDeletedEto<T>These are useful when another service needs basic CRUD-oriented synchronization rather than a rich business workflow event.
Configure<AbpDistributedEntityEventOptions>(options =>
{
options.AutoEventSelectors.Add<Product>();
options.EtoMappings.Add<Product, ProductEto>();
});
And the mapped ETO:
public class ProductEto
{
public Guid Id { get; set; }
public string Name { get; set; }
public int StockCount { get; set; }
}
A ProductUpdated technical event is not the same as a meaningful StockCountChanged business event.
One common microservice pattern is keeping a local copy of remote entities for querying or validation.
For example:
ProductABP's entity synchronizer support helps consume create/update/delete events and persist local copies. This is useful for eventual consistency scenarios where each service needs its own storage and query model.
This pattern works well when:
It works poorly when:
ABP uses the event type's full class name by default unless you specify an event name explicitly.
That default is convenient, but contracts deserve some care.
Many examples online publish distributed events directly from app services after repository calls. That works, but it tends to make orchestration logic pile up in the application layer.
A cleaner ABP approach is often:
Why this usually scales better:
It also makes testing easier because the business event becomes the seam.
If you are using distributed events, assume these will happen eventually:
A distributed event is not a database transaction stretched across services. Treat it as asynchronous coordination.
In a typical ABP microservice, the structure often looks like this:
AddDomainEvent(...)AddDistributedEvent(...) for very stable external contractsILocalEventHandler<TDomainEvent>IDistributedEventBusIDistributedEventHandler<TEto>That division keeps the model understandable and avoids most of the coupling problems teams introduce accidentally.
This leaks internals and breaks consumers when your domain evolves.
Internal events and external contracts change at different speeds. Keep them separate.
It works until the day you hit the save-then-crash gap.
Brokers and retries do not guarantee single delivery in the way many teams assume.
A business process usually deserves a business event, not just EntityUpdated.
Large event contracts create versioning pain, serialization issues, and unnecessary coupling.
If you are implementing domain events in ABP microservices, optimize for clear boundaries first and infrastructure reliability second.
The pattern that works well in most real systems is:
ABP already gives you the building blocks. The main challenge is not framework support. It is resisting the temptation to blur domain events, application events, and integration contracts into one catch-all mechanism.
If you keep those boundaries sharp, your services remain easier to evolve, test, and operate.