Events as an encapsulated publish/subscribe mechanism: compiler-generated add/remove, thread-safe subscription, raising safely, custom accessors and the risks of static events.
An event is a delegate member with access restricted to subscribe and unsubscribe. Outside the class you can only use += and -=; only the owner can raise it or clear it, whereas a public delegate field can be invoked or overwritten by anyone. For a field-like event the compiler generates a private delegate field and thread-safe add and remove accessors. I raise it with Changed?.Invoke(this, e), which reads the field once. I avoid static events because they keep every subscriber alive until it unsubscribes.
An event is a delegate-backed member that exposes only subscribe and unsubscribe to outside code, while keeping raising and replacing private to the declaring type. A public delegate field gives everyone full control — including wiping out all subscribers or raising the event themselves. I'd cover what event generates, field-like vs custom accessors, raising safely, and static events.
public sealed class OrderService
{
public Action<Order>? OrderPlacedField; // public delegate field
public event EventHandler<OrderPlacedEventArgs>? OrderPlaced; // event
}
// From outside:
service.OrderPlacedField = null; // allowed - removes everyone!
service.OrderPlacedField?.Invoke(fakeOrder); // allowed - anyone can raise it
service.OrderPlaced += Handle; // allowed
service.OrderPlaced -= Handle; // allowed
// service.OrderPlaced = null; // CS0070 - not allowed
// service.OrderPlaced?.Invoke(...); // CS0070 - not allowed outside the type
| Public delegate field | Event | |
|---|---|---|
Outside code can +=/-= |
Yes (not thread-safe) | Yes (thread-safe accessors) |
Outside code can assign = |
Yes (destroys subscriptions) | No |
| Outside code can invoke | Yes | No |
| In interfaces | As a property only | Yes (event members) |
| Metadata | Field | add_X/remove_X methods + event metadata |
Events encode the observer pattern: the publisher owns notification; subscribers can only register interest.
event keyword generatesA field-like event public event EventHandler? Changed; generates:
add_Changed accessor using a lock-free Interlocked.CompareExchange loop with Delegate.Combine,remove_Changed accessor with Delegate.Remove,Inside the class, Changed refers to the field, so you can invoke it; outside, it refers to the event, so only +=/-= work.
add/remove accessorsprivate readonly EventHandlerList _handlers = new();
private static readonly object s_changedKey = new();
public event EventHandler Changed
{
add { lock (_handlers) _handlers.AddHandler(s_changedKey, value); }
remove { lock (_handlers) _handlers.RemoveHandler(s_changedKey, value); }
}
Use custom accessors to:
add => _inner.Changed += value;),EventHandlerList to avoid a field per event),With custom accessors, there's no backing field — you raise the event from your own storage.
protected virtual void OnOrderPlaced(OrderPlacedEventArgs e) => OrderPlaced?.Invoke(this, e);
?.Invoke reads the delegate once, so there's no race between the null check and the call if another thread unsubscribes the last handler.protected virtual OnXxx pattern lets derived classes override raising behaviour.GetInvocationList with try/catch).public static class AppEvents
{
public static event EventHandler<TenantChangedEventArgs>? TenantChanged;
}
Static events live for the process lifetime and hold strong references to every subscriber's target. Any instance that subscribes and never unsubscribes is kept alive forever — the classic memory leak (e.g. per-request objects or view models subscribing to SystemEvents or a static hub). In server apps, static events also mix tenants and requests. Prefer DI-scoped publishers, IObservable<T>, channels or a mediator; if static events are unavoidable, unsubscribe in Dispose or use weak events.
A desktop-to-web migration kept a static ConfigChanged event that per-request services subscribed to in their constructors. In the web app, every request created services that subscribed and never unsubscribed; memory grew steadily and dotnet-gcdump showed hundreds of thousands of service instances rooted by the static event's invocation list. We replaced it with IOptionsMonitor<T>.OnChange registrations in singletons only (disposing the returned IDisposable), and the leak disappeared.
Checks encapsulation understanding and practical event safety.
Green flags: explains what event prevents; knows generated accessors and thread safety; safe raise pattern; OnXxx convention; static event leaks; knows when to use a message bus instead.
Red flags: thinks events and delegates are identical; raises with the null-check race; heavy work in handlers.
.event EventHandler Changed
{
.addon instance void C::add_Changed(class EventHandler)
.removeon instance void C::remove_Changed(class EventHandler)
}
.field private class EventHandler Changed // for field-like events
Other .NET languages (VB.NET AddHandler/RaiseEvent, F#) use the same accessor methods. Reflection exposes events via EventInfo (GetAddMethod, AddEventHandler).
Since C# 4, field-like events use a CAS loop:
EventHandler? prev, cur = _changed;
do { prev = cur; cur = Interlocked.CompareExchange(ref _changed, (EventHandler?)Delegate.Combine(prev, value), prev); }
while (cur != prev);
No lock, no lock(this) issues. Raising still needs the single-read pattern (?.Invoke).
public interface INotifyOrderChanges { event EventHandler<OrderChangedEventArgs>? OrderChanged; }
Implementations can be field-like or custom. Events can be virtual, abstract and override — but overriding field-like events is confusing (each level has its own backing field); prefer protected virtual OnXxx methods.
The publisher's delegate holds strong references to subscribers' targets. Lifetime rule: subscriber lifetime ≤ publisher lifetime is safe; a long-lived publisher with short-lived subscribers leaks unless they unsubscribe. Weak event patterns (WeakEventManager in WPF, ConditionalWeakTable-based managers, or the CommunityToolkit WeakReferenceMessenger) break that strong reference.
| Need | Option |
|---|---|
| In-process pub/sub with DI | Mediator pattern (IPublisher + handlers), MediatR alternatives |
| Async producer/consumer | Channel<T> |
| Reactive streams with operators | IObservable<T> / System.Reactive |
| Configuration change | IOptionsMonitor<T>.OnChange (returns IDisposable) |
| Cancellation/one-time signals | CancellationToken.Register, TaskCompletionSource |
| Cross-process | Message brokers (Service Bus, Event Grid) |
Events remain great for UI components and tightly coupled object notifications (INotifyPropertyChanged).
= and invoking from another class.dotnet-gcdump and find them rooted.namespace Delegates.Events;
public enum OrderStatus { Pending, Paid, Shipped }
public sealed class OrderStatusChangedEventArgs(Guid orderId, OrderStatus oldStatus, OrderStatus newStatus) : EventArgs
{
public Guid OrderId { get; } = orderId;
public OrderStatus OldStatus { get; } = oldStatus;
public OrderStatus NewStatus { get; } = newStatus;
}
public class Order
{
public Guid Id { get; } = Guid.NewGuid();
public OrderStatus Status { get; private set; }
public event EventHandler<OrderStatusChangedEventArgs>? StatusChanged;
public void MarkPaid() => ChangeStatus(OrderStatus.Paid);
public void MarkShipped() => ChangeStatus(OrderStatus.Shipped);
private void ChangeStatus(OrderStatus newStatus)
{
var old = Status;
if (old == newStatus) return;
Status = newStatus;
OnStatusChanged(new OrderStatusChangedEventArgs(Id, old, newStatus));
}
protected virtual void OnStatusChanged(OrderStatusChangedEventArgs e) => StatusChanged?.Invoke(this, e);
}
// Custom accessors: forward subscriptions to an inner publisher
public sealed class OrderFacade(Order inner)
{
public event EventHandler<OrderStatusChangedEventArgs>? StatusChanged
{
add => inner.StatusChanged += value;
remove => inner.StatusChanged -= value;
}
public void Pay() => inner.MarkPaid();
}
public static class Program
{
public static void Main()
{
var order = new Order();
var facade = new OrderFacade(order);
EventHandler<OrderStatusChangedEventArgs> audit = (sender, e) =>
Console.WriteLine($"AUDIT {e.OrderId}: {e.OldStatus} -> {e.NewStatus}");
facade.StatusChanged += audit;
facade.Pay();
order.MarkShipped();
facade.StatusChanged -= audit;
// order.StatusChanged = null; // CS0070: only += and -= allowed outside Order
// order.StatusChanged(order, ...); // CS0070: cannot raise from outside
}
}
What happens: public Action<Order>? OrderPlaced;.
Why it is wrong: callers can overwrite or raise it, and subscription isn't thread-safe.
Do instead: declare an event.
What happens: if (Changed != null) Changed(this, e);.
Why it is wrong: another thread can unsubscribe between the check and the call.
Do instead: Changed?.Invoke(this, e);.
What happens: handlers are invoked while holding a lock. Why it is wrong: handlers may call back and deadlock, or run long while holding the lock. Do instead: copy state, release the lock, then raise.
What happens: per-request or per-view objects subscribe to static events.
Why it is wrong: memory leaks — subscribers are never collected.
Do instead: unsubscribe in Dispose, use weak events, or use DI-scoped alternatives.
What happens: handlers perform I/O synchronously. Why it is wrong: the publisher blocks until all handlers finish. Do instead: keep handlers fast or hand work to a queue.
What happens: virtual field-like events overridden in derived classes.
Why it is wrong: separate backing fields per level cause lost subscriptions.
Do instead: use protected virtual OnXxx methods.
What happens: one handler throws and later handlers don't run. Why it is wrong: unrelated features fail silently. Do instead: isolate handlers when independence matters.
What happens: in-process events used for workflows that need durability. Why it is wrong: lost on crashes and restarts. Do instead: use durable messaging (outbox + broker).
Events are an in-process observer mechanism with strong references and synchronous execution. That is perfect for UI, domain objects and library notifications, and wrong for slow, cross-cutting or cross-service work, where I use domain events dispatched after the transaction or a message bus. Seniors understand what the compiler generates (lock-free add/remove), raise events safely, and treat subscription lifetime as a resource to manage.
The convention is EventHandler<TEventArgs> with object? sender and an immutable args object, EventArgs.Empty when there is no data, and a protected virtual OnXxx method that raises it. The big production risk is leaks: the publisher's delegate holds a strong reference to every subscriber, so a singleton or static publisher keeps short-lived subscribers alive forever. I find them with a heap dump and gcroot, which shows the path through the event's delegate, and fix them by unsubscribing in Dispose or using a weak event. Async handlers are async void, so I keep them thin and catch everything inside.
Any delegate can hold several methods. += calls Delegate.Combine, which creates a new immutable delegate with an invocation list, and invoking it calls each method synchronously in subscription order. For a delegate with a return value you only get the last handler's result, and if one handler throws, the rest are skipped. -= removes the last matching entry, which only works for lambdas if you kept the same instance. When I need every result or want one failing handler not to stop the others, I loop over GetInvocationList().
A partial type is one class, struct, record or interface split across several declarations (usually files) that the compiler merges into a single type at compile time. All parts need partial, must be in the same assembly and namespace, and their modifiers, base types and interfaces are combined. Their main purpose is separating generated code from hand-written code: designers and source generators such as [GeneratedRegex], [LoggerMessage] and JsonSerializerContext write the other half. Partial members let one part declare a member and another implement it: partial methods (classic ones may be left unimplemented and removed), partial properties and indexers (C# 13), and partial constructors and events (C# 14).
How to tell it (STAR)
**S:** We discovered [period] of missing audit records for order status changes. **T:** Find the cause and make it impossible to repeat. **A:** I traced it to a public delegate field that a new module overwrote with `=`. I converted it to an event so the compiler blocks that, backfilled the audit trail from [source], and added tests. **R:** No further gaps, and the audit team signed off on the fix.
Last updated 30 Oct 2026.