📁 Sangam Dhakal - Portfolio v2.0 - Netscape Navigator 4.0
_
□
×

📚 Interview question

What is an event in C#, and how is it different from a public delegate field? Explain what the event keyword generates, field-like events versus custom add/remove accessors, how to raise an event safely, and static events.

Events as an encapsulated publish/subscribe mechanism: compiler-generated add/remove, thread-safe subscription, raising safely, custom accessors and the risks of static events.

Mid-Level Theory Intermediate csharp delegates events observer-pattern thread-safety

🎯 Key points

  • An event is a delegate-typed member that outside code can only `+=` and `-=`; only the declaring type can invoke or reset it
  • A field-like event generates a private delegate field plus add/remove accessors using a lock-free `Interlocked.CompareExchange` loop
  • Raise with `Changed?.Invoke(this, e)` so the field is read once and a concurrent unsubscribe cannot cause a NullReferenceException
  • Custom add/remove accessors let you store handlers elsewhere (EventHandlerList, weak references, forwarding)
  • Events are synchronous and run on the raising thread; a slow handler blocks the publisher
  • The publisher holds strong references to subscribers; static events keep subscribers alive for the process lifetime
  • Events can be declared in interfaces and can be virtual/abstract; C# 14 adds partial events

⏱ 30-second answer

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.

🗣 Full answer (senior)

How I would answer this in an interview

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.

1. Event vs public delegate field

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.

2. What the event keyword generates

A field-like event public event EventHandler? Changed; generates:

  • a private delegate field,
  • a public add_Changed accessor using a lock-free Interlocked.CompareExchange loop with Delegate.Combine,
  • a public remove_Changed accessor with Delegate.Remove,
  • event metadata linking them.

Inside the class, Changed refers to the field, so you can invoke it; outside, it refers to the event, so only +=/-= work.

3. Custom add/remove accessors

private 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:

  • forward subscriptions to another object (wrappers/adapters: add => _inner.Changed += value;),
  • store handlers in a sparse structure when a type has many events but few are used (WinForms controls use EventHandlerList to avoid a field per event),
  • implement weak events (store weak references to handlers' targets),
  • add logging/validation or lazily start an underlying subscription (e.g. start listening to a socket when the first handler subscribes).

With custom accessors, there's no backing field — you raise the event from your own storage.

4. Raising an event safely

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.
  • The protected virtual OnXxx pattern lets derived classes override raising behaviour.
  • Exceptions from handlers propagate to the raiser and stop later handlers — decide whether to isolate them (GetInvocationList with try/catch).
  • Raise events outside locks to avoid deadlocks when handlers call back into your object.
  • Handlers run synchronously on the raising thread; long-running handlers block the publisher.

5. Static events

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.

6. A production example

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.

🔍 What the interviewer is checking

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.

📚 Learn in depth

1. Metadata and IL

.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).

2. Thread-safe add/remove

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).

3. Events in interfaces and abstract members

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.

4. Memory and lifetime

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.

5. Alternatives to events in modern .NET

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).

Try it yourself

  1. Declare both a delegate field and an event, and try = and invoking from another class.
  2. Decompile a field-like event and read the generated accessors.
  3. Implement custom accessors forwarding to an inner object.
  4. Create a static event, subscribe 10,000 short-lived objects, take a dotnet-gcdump and find them rooted.

💻 Code

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
    }
}

⚠ Common mistakes

1. Public delegate fields instead of events

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.

2. Unsafe raising

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);.

3. Raising events inside locks

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.

4. Static events with short-lived subscribers

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.

5. Long-running handlers

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.

6. Overriding field-like events

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.

7. Ignoring handler exceptions

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.

8. Events for cross-service communication

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).

🏛 Senior insight: trade-offs

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.

🔗 Follow-ups the interviewer may ask

  1. When: Candidate mentions strong references or static events. Why: Probes conventions and the most common production problem with events: leaks.
    How to answer

    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.

  2. When: Candidate says events run synchronously in order. Why: Checks what happens when handlers throw or return values.
    How to answer

    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().

  3. When: Candidate mentions custom accessors or partial events. Why: C# 14 partial events exist mainly for source generators.
    How to answer

    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).

🛠 Real-world problems: how you handle them

A module silently removed the audit handler
Situation
An order aggregate exposed `public Action<Order>? StatusChanged` as a field. Audit, notifications and reporting subscribed to it.
Signals
Audit records stopped for status changes after a reporting release; no errors anywhere.
Investigation
Searched usages and found the reporting module wrote `order.StatusChanged = OnStatusChanged;` instead of `+=`, replacing all earlier subscribers.
Resolution
Changed the field to `public event EventHandler<OrderStatusChangedEventArgs>? StatusChanged`, which made the assignment a compile error, and fixed the reporting code to use +=.
Prevention
Added an analyser rule against public delegate fields and an integration test asserting audit records are written for every status change.

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.

↩ Usually asked after

More in Delegates and Events

Last updated 30 Oct 2026.

🌐 Sangam's Webring

⏪ Previous 🏠 Main Next ⏩
💬 Chat with me