The Idea
A domain event is a record that something meaningful happened inside a domain.
Not a request for something to happen. Not a command. A fact. "This happened. It happened at this time. Here is what you need to know about it."
OrderConfirmed. UserRegistered. PaymentFailed. InventoryDepleted.
The aggregate root that owns the event — the Order, the AuthUser, the Payment — creates it and emits it. It doesn't know, and shouldn't know, who's listening. It just announces what happened.
The rest of the system reacts.
The Before and After
Without domain events, the Order.confirm() method does its job and then immediately starts doing other aggregates' jobs:
// ❌ Without domain events — confirmation method owns everything
class Order {
// ...
Order confirm({
required InventoryRepository inventoryRepo,
required NotificationService notificationService,
required LoyaltyService loyaltyService,
}) {
if (_items.isEmpty) {
throw DomainException('Cannot confirm an order with no items.');
}
// Confirm the order
final confirmed = Order(
id: id,
items: _items,
status: OrderStatus.confirmed,
shippingAddress: shippingAddress,
total: total,
);
// Now do everything else
inventoryRepo.decrementStock(_items); // Crosses boundary
notificationService.sendConfirmation(id); // Crosses boundary
loyaltyService.awardPoints(customerId, total); // Crosses boundary
return confirmed;
}
}The Order aggregate now depends on three external services. It's impossible to test in isolation. Adding the loyalty points feature required modifying domain logic. And if loyaltyService is temporarily unavailable, Order.confirm() fails — even though the order was confirmed successfully.
With domain events:
// ✅ With domain events — Order stays in its lane
class Order {
// ...
(Order, OrderConfirmedEvent) confirm() {
if (_items.isEmpty) {
throw DomainException('Cannot confirm an order with no items.');
}
final confirmed = Order(
id: id,
items: _items,
status: OrderStatus.confirmed,
shippingAddress: shippingAddress,
total: total,
);
final event = OrderConfirmedEvent(
orderId: id,
customerId: customerId,
items: List.unmodifiable(_items),
total: total,
confirmedAt: DateTime.now(),
);
return (confirmed, event);
}
}Order.confirm() is now a pure function. It takes no external dependencies. It returns the new state and an announcement of what happened. It has no idea what happens next.
The Event
A domain event is a simple, immutable data class. It contains everything downstream handlers might need, and nothing more.
// Domain events live in the domain layer — no external dependencies
class OrderConfirmedEvent {
final String orderId;
final String customerId;
final List<OrderItem> items;
final Money total;
final DateTime confirmedAt;
const OrderConfirmedEvent({
required this.orderId,
required this.customerId,
required this.items,
required this.total,
required this.confirmedAt,
});
}Named in the past tense. Something happened. It's recorded. The fact won't change.
Notice it's in the domain layer — no infrastructure, no Flutter, no HTTP. Just data that represents a business fact.
The Handlers
The use case — not the aggregate — is responsible for emitting the event and triggering its handlers. The aggregate creates the event; the use case publishes it.
class ConfirmOrderUseCase {
final OrderRepository _orderRepository;
final EventBus _eventBus;
ConfirmOrderUseCase(this._orderRepository, this._eventBus);
Future<Either<Failure, Order>> execute(String orderId) async {
try {
final orderResult = await _orderRepository.getById(orderId);
return orderResult.fold(
(failure) => Left(failure),
(order) async {
// Aggregate confirms itself and produces the event
final (confirmedOrder, event) = order.confirm();
// Persist the new state
await _orderRepository.save(confirmedOrder);
// Publish — handlers react independently
await _eventBus.emit(event);
return Right(confirmedOrder);
},
);
} catch (e) {
return Left(UnexpectedFailure(e.toString()));
}
}
}The handlers are registered elsewhere, each in its own bounded context:
// Inventory context — reacts to order confirmed
class InventoryDecrementHandler {
final InventoryRepository _repository;
InventoryDecrementHandler(this._repository);
Future<void> handle(OrderConfirmedEvent event) async {
for (final item in event.items) {
final inventoryResult = await _repository.getByProductId(item.productId);
inventoryResult.fold(
(failure) => _log.error('Inventory not found for ${item.productId}'),
(inventory) async {
final updated = inventory.decrement(item.quantity);
await _repository.save(updated);
},
);
}
}
}
// Notifications context — reacts to order confirmed
class OrderConfirmationEmailHandler {
final EmailService _emailService;
OrderConfirmationEmailHandler(this._emailService);
Future<void> handle(OrderConfirmedEvent event) async {
await _emailService.sendOrderConfirmation(
customerId: event.customerId,
orderId: event.orderId,
total: event.total,
);
}
}
// Loyalty context — added later, zero changes to Order
class LoyaltyPointsHandler {
final LoyaltyRepository _repository;
LoyaltyPointsHandler(this._repository);
Future<void> handle(OrderConfirmedEvent event) async {
final points = LoyaltyCalculator.calculate(event.total);
final record = LoyaltyTransaction(
customerId: event.customerId,
points: points,
reason: 'OrderConfirmed:${event.orderId}',
createdAt: event.confirmedAt,
);
await _repository.save(record);
}
}When loyalty points were added, the Order aggregate was not touched. The event already existed. A new handler was registered. That's the extension point — the open/closed principle in practice.
Wiring It Together with GetIt
In a Flutter app with Clean Architecture, GetIt handles the registration. The event bus is a shared infrastructure component that handlers subscribe to on startup:
// lib/core/di/event_bus_setup.dart
void registerEventHandlers(GetIt sl) {
// Register handlers
sl.registerLazySingleton(() => InventoryDecrementHandler(sl()));
sl.registerLazySingleton(() => OrderConfirmationEmailHandler(sl()));
sl.registerLazySingleton(() => LoyaltyPointsHandler(sl()));
// Wire subscriptions — handlers listen for events
final bus = sl<EventBus>();
bus.on<OrderConfirmedEvent>().listen((event) {
sl<InventoryDecrementHandler>().handle(event);
sl<OrderConfirmationEmailHandler>().handle(event);
sl<LoyaltyPointsHandler>().handle(event);
});
}This registration happens once at app startup, alongside the rest of your dependency injection setup. After that, emitting an event automatically triggers every registered handler — and adding a new handler means adding one line here, not touching any existing code.
Domain Events in Flutter: Why They Fit
The connection between domain events and BLoC is worth naming explicitly, because it's one of the reasons BLoC feels natural in a DDD codebase.
BLoC is built around events and states. When you dispatch OrderConfirmedEvent from a use case, the OrderBloc can receive it and transition to an OrderConfirmedState. The vocabulary is consistent: the domain emits OrderConfirmedEvent, the BLoC receives OrderConfirmedEvent, the UI reacts to OrderConfirmedState. The same fact moves through all three layers under the same name.
This is the alignment the riverpod vs bloc article describes as DDD alignment — one of the arguments for BLoC in complex, domain-heavy apps.
The Bounded Context Connection
Domain events are also how bounded contexts communicate without coupling to each other.
The auth context registers a new user. It knows nothing about chat participants or customer records. It just emits UserRegisteredEvent. The chat context listens and creates a ChatParticipant. The orders context listens and creates a Customer. Neither the chat nor the orders context imports anything from auth. The event is the contract — a shared fact, not a shared dependency.
// Auth context — emits and forgets
class RegisterUserUseCase {
Future<Either<Failure, AuthUser>> execute(RegisterDto dto) async {
final user = AuthUser.create(dto.email, dto.password);
await _repository.save(user);
_eventBus.emit(UserRegisteredEvent(
userId: user.id,
email: user.email,
registeredAt: DateTime.now(),
));
return Right(user);
}
}
// Chat context — listens and acts, no auth imports
class ChatParticipantCreationHandler {
Future<void> handle(UserRegisteredEvent event) async {
final participant = ChatParticipant(
userId: event.userId,
username: event.email.split('@').first, // Temporary — user can update later
displayName: '',
avatarUrl: null,
isOnline: false,
);
await _repository.save(participant);
}
}The identifier crosses the boundary. The model doesn't. The event is the bridge.
What Events Are Not
Events are not the same as commands.
A command is a request: "Please confirm this order." It might be rejected. It might fail. It targets a specific handler and expects a response.
An event is a fact: "Order 7291 was confirmed at 14:32." It cannot be rejected — it already happened. It doesn't target anything specific. Multiple handlers can react, or none.
This distinction matters when you're naming things. Events are past tense because they record facts. OrderConfirmed, not ConfirmOrder. UserRegistered, not RegisterUser. If you find yourself naming an event in the present tense or imperative, you're probably looking at a command.
Commands travel forward in time. Events travel outward from what has happened.
One Thing This Changes
Before domain events, the question when designing a new feature was: "Which existing thing do I modify to add this behaviour?"
After domain events, the question becomes: "What does the system announce? Is there already an event I can react to?"
Usually, there is. A new requirement to notify someone when a prescription is dispensed doesn't require touching the dispensing aggregate. The PrescriptionDispensedEvent already exists. A new handler is all you need.
This is what a well-designed event model feels like from the inside — new requirements find their attachment point without disturbing what's already working.