| name | design-module-and-service-communication |
| description | Decide how two ABP modules or microservices communicate — synchronous integration services vs asynchronous distributed events — and make events reliable and idempotent. USE FOR: choosing sync (integration services — in-process DI in a monolith, HTTP client proxy across microservices) vs async (distributed event bus), publishing/subscribing distributed events (ETOs, [EventName], IDistributedEventHandler), enabling the transactional outbox/inbox, keeping handlers idempotent, cross-module/service query and reporting boundaries, sharing contracts without leaking a module's or service's internals. DO NOT USE FOR: the mechanics of exposing an integration service as a controller (use expose-http-apis); the mechanics of calling a remote service via client proxies (use consume-remote-services); running that communication over a Dapr sidecar / pub-sub (use integrate-dapr-services); in-process background jobs and local events (use the background-jobs-and-events skill).
|
| license | MIT |
Designing ABP Module & Service Communication
Two building blocks cover almost all communication across a boundary in ABP — whether the boundary is between modules in a modular monolith (one process) or between microservices (separate processes). Pick per interaction, not per system — most solutions use both. Designing for module boundaries first is what lets a modular monolith later split into microservices with little communication rework: the distributed event bus and integration services run in-process today and swap to a broker/remote transport with little application-code change — though the split still adds remote auth, network-failure/timeout handling, and cross-service consistency to design for.
When to Use
- Deciding whether an interaction should be synchronous (needs an immediate answer) or asynchronous (fire-and-forget notification), between modules or between microservices.
- Publishing/subscribing to distributed events across a module or service boundary.
- Making cross-boundary events reliable and transactional with the outbox/inbox.
- Keeping event handlers idempotent against duplicate delivery.
- Sharing contracts (ETOs, integration DTOs) across a boundary without leaking domain entities.
- Designing a cross-module/service query or report without coupling to another module's internals.
When Not to Use
- The mechanics of exposing an
[IntegrationService] as an HTTP controller — use the expose-http-apis skill.
- The mechanics of calling a remote service through client proxies — use the consume-remote-services skill.
- Running this communication over a Dapr sidecar / pub-sub — use the integrate-dapr-services skill.
- In-process background jobs and local (single-service) events — use the background-jobs-and-events skill.
How it works
Synchronous vs. Asynchronous — which to use
Synchronous (Integration Services) — use when the caller needs an immediate answer (a query, or a command whose result it must have before continuing). The caller invokes the callee's integration service and waits. How that call travels depends on topology: an in-process call between modules of a monolith, or a remote HTTP call between microservices (see below) — the same interface, different transport.
Asynchronous (Distributed Event Bus) — use when a service just needs to announce that something happened and doesn't care who reacts or when. Publisher and subscribers are decoupled; this is the recommended default for propagating state changes between services.
Rule of thumb: read/command-with-result → integration service; "X happened" notification → distributed event. Prefer async to keep services loosely coupled.
Synchronous: Integration Services
An integration service is an application service (or MVC controller) meant for service-to-service calls, not for UI/3rd-party clients. Mark it with [IntegrationService]:
[IntegrationService]
public interface IProductIntegrationService : IApplicationService
{
Task<ProductDto> GetAsync(Guid id);
}
[IntegrationService]
public class ProductIntegrationService : ApplicationService, IProductIntegrationService
{
public Task<ProductDto> GetAsync(Guid id)
{
return Task.FromResult(new ProductDto());
}
}
By convention ABP does not expose an integration service over HTTP by default (and disables its audit logging). Whether you expose it depends on whether the caller runs in the same process:
Modular monolith — modules in one process. The caller module references the owner module's Application.Contracts package (which holds IProductIntegrationService + its DTOs), then injects and calls the interface directly through DI — an ordinary in-process method call. Do not set ExposeIntegrationServices; there is no HTTP, no /integration-api, no client proxy. This is the default for a monolith. Consume it from the Application layer (an application/integration service), as the ABP tutorials do:
public class OrderAppService : ApplicationService
{
private readonly IProductIntegrationService _products;
public OrderAppService(IProductIntegrationService products) => _products = products;
public Task<ProductDto> GetProductAsync(Guid id) => _products.GetAsync(id);
}
A module's Domain layer must not inject another module's integration service: that interface lives in Application.Contracts, and ABP's layering forbids a Domain project from depending on any Application.Contracts (Domain depends only on Domain.Shared). If a domain rule needs data from another module, fetch it in the Application layer and pass the values into the domain object/service.
Microservice — separate processes. The owner service must opt in to expose the integration service over HTTP (served under /integration-api instead of /api, which the gateway should block from outside your private network):
Configure<AbpAspNetCoreMvcOptions>(options =>
{
options.ExposeIntegrationServices = true;
});
The consuming service then calls it through ABP's dynamic or static C# client proxies — same interface, remote call. Keep integration-service contracts small and stable; they are an API surface between services.
Designing for the monolith case first (Contracts + direct DI) is what lets a module later split into a microservice without rewriting the caller's business logic: the owner flips ExposeIntegrationServices on, and the consumer swaps direct DI for a client proxy of the same interface. The split still adds remote authentication, network-failure/timeout handling, and cross-service data consistency to design for — the stable contract just keeps it from being a business-logic rewrite.
Asynchronous: Distributed Event Bus
IDistributedEventBus publishes events across service boundaries. The default LocalDistributedEventBus runs in-process, so you can write distributed-ready code in a monolith and later plug in RabbitMQ / Kafka / Azure Service Bus / Rebus with no code change.
Publish an Event Transfer Object (ETO — a plain, serializable class; Eto suffix by convention):
[EventName("MyApp.Product.StockChanged")]
public class StockCountChangedEto
{
public Guid ProductId { get; set; }
public int NewCount { get; set; }
}
await _distributedEventBus.PublishAsync(
new StockCountChangedEto { ProductId = id, NewCount = newCount });
Subscribe by implementing IDistributedEventHandler<TEvent> (auto-discovered when registered in DI):
public class StockChangedHandler
: IDistributedEventHandler<StockCountChangedEto>, ITransientDependency
{
public async Task HandleEventAsync(StockCountChangedEto eventData)
{
}
}
Give the event a stable [EventName] — it's the wire contract that matches publisher and subscriber across services.
Reliable events: the Outbox / Inbox pattern
With a real broker, publisher and handler run in different processes, so you can't rely on a shared transaction. The transactional outbox saves outgoing events into your DB in the same transaction as your data change, then a background worker ships them to the broker with retries — no lost events if the broker is briefly down. The inbox persists incoming events first, then processes each one inside a transaction and marks it Processed (it does not delete the record in that transaction — a background cleanup removes processed records later, after a retention window). Persisting + marking events is what gives you de-duplication.
Prerequisites: configure the distributed lock system (used to serialize box processing across instances) and use EF Core or MongoDB.
Enable outbox on an EF Core DbContext:
public class MyDbContext : AbpDbContext<MyDbContext>, IHasEventOutbox, IHasEventInbox
{
public DbSet<OutgoingEventRecord> OutgoingEvents { get; set; }
public DbSet<IncomingEventRecord> IncomingEvents { get; set; }
protected override void OnModelCreating(ModelBuilder builder)
{
base.OnModelCreating(builder);
builder.ConfigureEventOutbox();
builder.ConfigureEventInbox();
}
}
Add a migration for the new tables, then register the boxes:
Configure<AbpDistributedEventBusOptions>(options =>
{
options.Outboxes.Configure(config => config.UseDbContext<MyDbContext>());
options.Inboxes.Configure(config => config.UseDbContext<MyDbContext>());
});
(MongoDB is analogous: IMongoCollection<OutgoingEventRecord> / IncomingEventRecord + modelBuilder.ConfigureEventOutbox()/ConfigureEventInbox().)
Outbox/inbox can be enabled independently. To bypass the outbox for a specific publish, pass useOutbox: false to PublishAsync. Fine-tune polling, cleanup and failure handling via AbpEventBusBoxesOptions (e.g. PeriodTimeSpan, InboxProcessorFailurePolicy = Retry / RetryLater / Discard).
Idempotency
The inbox de-duplicates the transport, but design handlers to be idempotent anyway — a broker may still deliver twice, and processing may retry after a partial failure. Practical guidance:
- Make the effect safe to apply more than once (upsert / "set to state X" rather than "increment"), keyed by the entity id in the event.
- For syncing a remote entity's copy locally, use ABP's
EntitySynchronizer<TEntity, TKey, TSourceEntityEto> base class. It subscribes to create/update/delete events; combined with entity versioning (IHasEntityVersion), the create/update path skips an incoming event whose version is older than the local copy, giving a mostly idempotent sync. Two caveats: the version check accepts the same version too (it uses >=, so a re-delivered same-version update isn't rejected), and the delete path has no version check at all — a delete event is applied whenever a matching local entity exists, regardless of ordering. So don't rely on it to fully order or drop stale deletes; keep your own handlers idempotent as well.
Sharing contracts safely
Only share what is genuinely a contract — ETOs and integration-service interfaces/DTOs — never a service's domain entities or internal implementation.
- Put ETOs / integration DTOs in a small Contracts package (e.g. the service's
.Application.Contracts) that other services reference. Keep it minimal and versioned.
- To avoid a shared-package dependency entirely, copy the ETO into the consuming service. This works precisely because
[EventName("...")] is the real contract — the same event name maps the two copies across services. Match the event name exactly.
- Consumers should model only the subset of fields they need (e.g.
OrderProduct copies a few Product fields), not mirror the source entity. This keeps service boundaries strong.
Cross-module / cross-service queries & reporting
A read that spans two boundaries is the same design problem as a write. Default to the same rule: a module/service references only another's Contracts (its integration service or ETOs), never its entities or DbContext. Query the owner through its integration service, or keep a local read model synced from its events and join in memory.
When a report genuinely needs to join large data sets across boundaries and per-call integration queries are too costly, put that join in an explicit reporting/aggregation module that is allowed to read from multiple sources — and accept that this module is deliberately coupled to those databases. Keep that coupling in one clearly named place instead of letting arbitrary modules reach into each other. In a modular monolith the sources may be separate schemas in one physical database; when splitting into microservices, the reporting module becomes a service fed by events or backed by a dedicated reporting store.
Validation
- For sync calls in a monolith: confirm the caller injects the integration-service interface from the owner module's Contracts and the call resolves in-process —
ExposeIntegrationServices stays off and nothing hits /integration-api. For sync calls across microservices: with ExposeIntegrationServices = true, confirm the service is reachable under /integration-api and the consuming service's client proxy gets a response.
- For async events: publish an ETO and confirm the subscriber's
IDistributedEventHandler<TEvent> fires. Matching is by event name — without [EventName] ABP falls back to eventType.FullName, so this works only if both sides resolve to the same name (same CLR type, or the same explicit [EventName] on each copy).
- For outbox/inbox (EF Core): after adding the migration, confirm the outgoing/incoming event tables exist; publish an event and observe the outgoing record persisted in the same transaction and the incoming record marked
Processed after handling.
- For outbox/inbox (MongoDB): there's no migration or table — confirm
ConfigureEventOutbox/ConfigureEventInbox are wired and check the outgoing/incoming IMongoCollection documents instead.
Common Pitfalls
- Integration services are not exposed and have audit logging disabled by default — opt in with
ExposeIntegrationServices when a remote service must reach them.
- Prefer async distributed events for state-change propagation; overusing sync integration calls couples services tightly.
- Event name is the wire contract — without
[EventName] ABP uses eventType.FullName, so publisher and subscriber match only when both resolve to the same name. Set an explicit [EventName] to keep a stable wire name and to let you copy an ETO into a different CLR type instead of sharing a package; a mismatch between the two sides silently breaks matching.
- Outbox/inbox require the distributed lock system configured. On EF Core the box lives in tables — forgetting the migration breaks processing; on MongoDB there are no tables/migration, you wire
ConfigureEventOutbox/ConfigureEventInbox and the records live in IMongoCollections.
- The inbox de-duplicates transport only — still make handlers idempotent (upsert / set-state, not increment).
EntitySynchronizer version check uses >= (a re-delivered same-version update isn't rejected), and its delete path has no version check at all — don't rely on it to order or drop stale deletes.
- Never share domain entities or internals across services — only ETOs and integration DTOs, in a minimal Contracts package.
See also
- Distributed Event Bus:
https://github.com/abpframework/abp/blob/rel-10.5/docs/en/framework/infrastructure/event-bus/distributed/index.md
- Integration Services:
https://github.com/abpframework/abp/blob/rel-10.5/docs/en/framework/api-development/integration-services.md
- Microservices overview:
https://github.com/abpframework/abp/blob/rel-10.5/docs/en/framework/architecture/microservices/index.md