The event keyword wraps a delegate with additional access restrictions. An event can only be invoked by the class that declares it — external subscribers can only add (+=) or remove (-=) handlers, never directly invoke the event or replace all handlers with a new delegate. This encapsulation is critical for safe publisher-subscriber patterns. Events are the .NET implementation of the Observer pattern and appear in UI frameworks, domain event dispatchers, and message bus implementations throughout ASP.NET Core applications.
Declaring and Raising Events
// ── EventArgs — data carried by the event ─────────────────────────────────
public class PostPublishedEventArgs : EventArgs
{
public int PostId { get; }
public string Title { get; }
public string AuthorId { get; }
public DateTime PublishedAt { get; }
public PostPublishedEventArgs(int postId, string title, string authorId)
{
PostId = postId;
Title = title;
AuthorId = authorId;
PublishedAt = DateTime.UtcNow;
}
}
// ── Publisher class — declares and raises the event ───────────────────────
public class PostService
{
// Event declaration using standard EventHandler<TEventArgs> pattern
public event EventHandler<PostPublishedEventArgs>? PostPublished;
// Protected virtual raise method — allows derived classes to override
protected virtual void OnPostPublished(PostPublishedEventArgs e)
{
PostPublished?.Invoke(this, e); // thread-safe null check + invoke
}
public async Task PublishAsync(Post post)
{
post.IsPublished = true;
post.PublishedAt = DateTime.UtcNow;
await _repo.UpdateAsync(post);
// Raise the event after successful publish
OnPostPublished(new PostPublishedEventArgs(post.Id, post.Title, post.AuthorId));
}
}
// ── Subscribers — attach and detach handlers ──────────────────────────────
var service = new PostService();
// Subscribe with a method
service.PostPublished += OnPostPublishedHandler;
// Subscribe with a lambda
service.PostPublished += (sender, e) =>
Console.WriteLine($"Post '{e.Title}' published by {e.AuthorId}");
// Unsubscribe (important to prevent memory leaks in long-lived objects)
service.PostPublished -= OnPostPublishedHandler;
void OnPostPublishedHandler(object? sender, PostPublishedEventArgs e)
{
_emailService.SendNotification(e.AuthorId, $"Your post '{e.Title}' is live!");
}
event keyword restricts what external code can do with the underlying delegate. Without event, external code could invoke the delegate directly (service.PostPublished(this, args)) or replace all handlers (service.PostPublished = newHandler, erasing all existing subscribers). With event, only the declaring class can invoke it. External code is limited to += and -=. This encapsulation is why events are preferred over raw public delegates for all publisher-facing APIs.PostService holds a reference to a short-lived subscriber via an event, the subscriber cannot be garbage collected even after it is logically “done.” Use -= in the subscriber’s Dispose() method, or use weak event patterns for truly decoupled scenarios. In ASP.NET Core, scoped services registering for events on singleton services are particularly susceptible to this leak.PostPublished?.Invoke(this, e) is thread-safe in C# because the null-conditional operator reads the delegate reference atomically, but between the null check and the invoke, another thread could theoretically remove all subscribers. In most application code this is not a real risk, but for genuinely concurrent event raising (e.g., in background services), assign the delegate to a local variable first: var handler = PostPublished; handler?.Invoke(this, e);. This is actually what ?.Invoke() does internally.Domain Event Pattern in ASP.NET Core
// ── Domain event marker interface ─────────────────────────────────────────
public interface IDomainEvent { DateTime OccurredAt { get; } }
public record PostPublishedEvent(int PostId, string AuthorId, DateTime OccurredAt)
: IDomainEvent;
// ── Domain event dispatcher ────────────────────────────────────────────────
public interface IDomainEventDispatcher
{
Task DispatchAsync<TEvent>(TEvent domainEvent, CancellationToken ct = default)
where TEvent : IDomainEvent;
}
// ── Event handler interface ────────────────────────────────────────────────
public interface IDomainEventHandler<TEvent> where TEvent : IDomainEvent
{
Task HandleAsync(TEvent domainEvent, CancellationToken ct = default);
}
// ── Concrete handler ───────────────────────────────────────────────────────
public class SendWelcomeEmailOnPublish : IDomainEventHandler<PostPublishedEvent>
{
private readonly IEmailSender _email;
public SendWelcomeEmailOnPublish(IEmailSender email) => _email = email;
public async Task HandleAsync(PostPublishedEvent e, CancellationToken ct)
=> await _email.SendAsync(e.AuthorId, "Your post is live!", "...");
}
// Register in DI:
// builder.Services.AddScoped<IDomainEventHandler<PostPublishedEvent>,
// SendWelcomeEmailOnPublish>();
Common Mistakes
Mistake 1 — Not unsubscribing from events (memory leak)
❌ Wrong — subscriber stays in memory via the event’s invocation list:
service.PostPublished += handler;
// handler goes "out of scope" but PostPublished holds a reference — not GC'd
✅ Correct — unsubscribe in Dispose or when the subscription is no longer needed.
Mistake 2 — Raising events before completing the main operation
❌ Wrong — raising PostPublished before SaveChanges completes; event fires but save may fail.
✅ Correct — raise domain events only after the primary operation succeeds.