Domain Events in .NET: A DDD Perspective
Domain-Driven Design gave us a vocabulary that matters: bounded contexts, aggregates, ubiquitous language. But few of its concepts are as architecturally powerful — or as frequently misused — as domain events.
In most codebases I have reviewed, events are treated as a messaging detail. You pick a broker, wire up a serializer, and call it done. The architecture is shaped by the transport, not by the domain. This is exactly backwards.
What a Domain Event Actually Is
Eric Evans defined it plainly
A domain event is something that happened inside the domain — something meaningful to domain experts, worth recording, and worth broadcasting.
That last word is the key. Not sending. Not commanding. Broadcasting — because the producing context does not know, and should not know, who is listening.
Three properties follow from this definition:
- Immutability — an event is a fact about the past. It cannot be rejected.
OrderPlacedalready happened; there is nothing left to accept or refuse. - Named in the ubiquitous language —
OrderPlaced,InvoiceIssued,UserRegistered. NotMessageSentToQueue. Domain experts should recognize the name immediately. - Temporal decoupling — consumers process events at their own pace. The producing bounded context does not wait, does not care, and does not retry on their behalf.
This is different from a command. A command is a request: do this. It can be rejected. An event is a statement of fact: this happened. It cannot.
Why Events Matter Architecturally

Bounded contexts need to share information without becoming tightly coupled. This is one of the hardest problems in enterprise architecture, and it is where events earn their place.
Without events, the typical solution is direct invocation: one service calls another, either synchronously over HTTP or through a shared library. Both approaches create coupling. The caller must know the address of the callee. Changes to one can break the other. Deployments become coordinated.
With events, the producing context simply announces what happened. Any other context that cares can subscribe. The relationship is inverted: consumers depend on the producer’s contract, not on its implementation. Adding a new consumer requires no change to the producer.
This is how bounded contexts integrate cleanly. It is also what enables the kind of architecture that can scale both technically and organizationally — teams can work on their contexts independently, as long as they agree on the event contracts.
Domain Events vs. Integration Events
A distinction that often gets lost in practice: domain events and integration events are not the same thing.
Domain events are exchanged within a bounded context, between aggregates. They tend to be fine-grained and carry only the delta — the piece of information that changed. They do not need to be serialized to a broker. They can be dispatched in-process, within the same unit of work.
Integration events cross context boundaries. They need to be serialized, transported, and versioned. They typically carry more data, because the receiving context cannot reach into the producing context’s storage to fetch the rest. They are the contracts between teams.
Confusing the two leads to either over-coupling (integration events used inside a context, carrying unnecessary data dependencies) or under-specification (domain events used across boundaries, without proper versioning or schema governance).
Modeling Events as First-Class Contracts
If integration events are contracts between teams, they deserve the same governance as any other public API. That means:
- A machine-readable schema, not just a code example.
- Versioning, with explicit rules about breaking changes.
- A discovery mechanism, so consumers can find what events are available.
This is where most teams struggle. REST APIs have OpenAPI. Events get a Confluence page that is out of date within a week.
The architectural answer to this is to treat event schemas the same way you treat API specifications: version-controlled, machine-readable, and published alongside the producing service.
Deveel Events: A .NET Implementation
Deveel Events (GitHub) is a lightweight .NET framework built on top of the CloudEvents standard. It focuses on the publishing side of domain events — the part that most teams rewrite from scratch every time they start a new service.
What makes it architecturally interesting is its scope. It does not try to be a full service-bus runtime. It does not dictate how you model aggregates or build read models. It solves one problem well: broadcasting domain events from a bounded context to any number of downstream consumers, through a transport-agnostic layer.
Annotating an Event Contract
The starting point is a data class annotated with [Event]. This is your contract:
using System.ComponentModel.DataAnnotations;
using Deveel.Events;
[Event("order.placed", "1.0")]
public class OrderPlacedData
{
[Required]
public Guid OrderId { get; set; }
[Required]
[Range(0.01, 1_000_000.0)]
public decimal Amount { get; set; }
[Required]
public string Currency { get; set; } = default!;
public string? Notes { get; set; }
}
The [Event] attribute captures the event type string (order.placed) and its version (1.0). These values become part of the CloudEvents envelope, giving every published event a stable, identifiable type.
Registering the Publisher
Registration lives in the DI setup, separate from the domain model:
using Deveel.Events;
var builder = WebApplication.CreateBuilder(args);
builder.Services
.AddEventPublisher(options =>
{
options.Source = new Uri("https://myapp.example.com");
})
.AddServiceBus(options =>
{
options.ConnectionString = builder.Configuration["ServiceBus:ConnectionString"]!;
options.QueueName = builder.Configuration["ServiceBus:QueueName"]!;
});
The publisher is transport-agnostic. Swapping Azure Service Bus for RabbitMQ means changing the channel registration, not the domain code.
Publishing from a Domain Service
using Deveel.Events;
public class OrderService
{
private readonly EventPublisher _publisher;
public OrderService(EventPublisher publisher)
{
_publisher = publisher;
}
public async Task PlaceOrderAsync(Guid orderId, decimal amount, string currency)
{
// ... domain logic ...
var data = new OrderPlacedData
{
OrderId = orderId,
Amount = amount,
Currency = currency
};
await _publisher.PublishAsync(data);
}
}
The domain service does not reference the broker. It does not know whether the event will go to Azure Service Bus, RabbitMQ, or an HTTP webhook. That is a deployment decision, not a domain one.
Schema Governance
One of the most valuable parts of the framework is its schema support. The Deveel.Events.Schema package derives a schema from annotated data classes and exports it as JSON Schema, YAML, or an AsyncAPI 2.x document.
This is what gives integration events the same governance weight as REST APIs. Consumers get a machine-readable contract. Schema registries can validate payloads. Breaking changes can be detected automatically.
dotnet add package Deveel.Events.Schema
dotnet add package Deveel.Events.Schema.AsyncApi
The AsyncAPI export is particularly useful: it produces a complete, machine-readable API specification for asynchronous messaging — the same role OpenAPI plays for REST. Tooling can generate documentation sites, client SDKs, and mock servers from it.
The Architectural Principle
The framework mechanics are secondary. The principle matters more.
Domain events are contracts. They deserve the same discipline as any other public interface: versioning, schema governance, and explicit communication of breaking changes. They should be named by domain experts, not by the infrastructure team. They should be scoped to what actually happened, not to what the receiving system happens to need today.
When you treat events as first-class architectural citizens — and invest in the tooling to govern them — bounded contexts can evolve independently. Teams can work in parallel. The system can scale.
When you treat events as a messaging detail, you get a distributed monolith with extra latency.
The choice is made in design, not in deployment.
Deveel Events documentation is at events.deveel.org. Source code and issue tracker are on GitHub.
Disclosure: I am the author of the Deveel Events project. The views expressed in this post reflect my own architectural perspective. You can find my other open-source work on GitHub.