ReadOnlyObservableCollection
CollectionChanged
C#
.NET
programming

Why is ReadOnlyObservableCollection.CollectionChanged not public?

Master System Design with Codemia

Enhance your system design skills with over 120 practice problems, detailed solutions, and hands-on exercises.

Introduction

ReadOnlyObservableCollection<T>.CollectionChanged is not public because it implements the INotifyCollectionChanged interface explicitly. This means the event is accessible only through the interface, not through the concrete type. The design enforces the read-only contract — consumers cast to INotifyCollectionChanged to subscribe, while the collection prevents direct manipulation of the event from the outside. This is an intentional .NET design pattern, not a bug.

The Problem

csharp
1var source = new ObservableCollection<string> { "Alice", "Bob" };
2var readOnly = new ReadOnlyObservableCollection<string>(source);
3
4// This does NOT compile:
5readOnly.CollectionChanged += (s, e) => Console.WriteLine("Changed");
6// Error: 'ReadOnlyObservableCollection<string>' does not contain a definition
7// for 'CollectionChanged'

The Fix: Cast to INotifyCollectionChanged

csharp
1using System.Collections.Specialized;
2
3var source = new ObservableCollection<string> { "Alice", "Bob" };
4var readOnly = new ReadOnlyObservableCollection<string>(source);
5
6// Cast to the interface to access the event
7((INotifyCollectionChanged)readOnly).CollectionChanged += (s, e) =>
8{
9    Console.WriteLine($"Action: {e.Action}");
10};
11
12// Trigger a change through the source collection
13source.Add("Charlie");
14// Output: Action: Add

Why Explicit Interface Implementation?

Explicit interface implementation means the member is only visible when accessed through the interface type, not the concrete class:

csharp
1// Simplified version of what .NET does internally
2public class ReadOnlyObservableCollection<T> : INotifyCollectionChanged
3{
4    // Explicit implementation — not visible on the class itself
5    event NotifyCollectionChangedEventHandler INotifyCollectionChanged.CollectionChanged
6    {
7        add { /* subscribe */ }
8        remove { /* unsubscribe */ }
9    }
10}
11
12// Visible only through the interface:
13INotifyCollectionChanged notifier = readOnly;
14notifier.CollectionChanged += handler;  // Works
15
16// Not visible on the concrete type:
17readOnly.CollectionChanged += handler;  // Compile error

Design Rationale

The .NET team chose explicit implementation for several reasons:

  1. Read-only contract clarity: Making CollectionChanged hidden reinforces that this collection should not be modified or subscribed to casually. It signals that change notification is an advanced scenario.
  2. Preventing accidental subscriptions: A public event encourages casual subscriptions that may not be unsubscribed, causing memory leaks. The cast requirement makes subscriptions deliberate.
  3. Interface segregation: The class exposes IList<T> and IReadOnlyList<T> functionality publicly. Change notification is a separate concern accessed through INotifyCollectionChanged.

WPF Data Binding Works Automatically

WPF's data binding engine internally casts to INotifyCollectionChanged, so binding works without any special code:

xml
<!-- XAML: binding works because WPF checks for INotifyCollectionChanged -->
<ListBox ItemsSource="{Binding ReadOnlyItems}" />
csharp
1public class ViewModel
2{
3    private readonly ObservableCollection<string> _items = new();
4
5    // Expose read-only version — WPF handles CollectionChanged internally
6    public ReadOnlyObservableCollection<string> ReadOnlyItems { get; }
7
8    public ViewModel()
9    {
10        ReadOnlyItems = new ReadOnlyObservableCollection<string>(_items);
11    }
12
13    public void AddItem(string item) => _items.Add(item);
14}

The ListBox updates automatically when items are added to _items because WPF detects INotifyCollectionChanged on the bound collection.

Creating a Wrapper with Public Event

If the explicit interface is inconvenient, create a wrapper:

csharp
1public class ObservableReadOnlyCollection<T> : ReadOnlyObservableCollection<T>
2{
3    public ObservableReadOnlyCollection(ObservableCollection<T> list) : base(list) { }
4
5    // Re-expose the event as public
6    public new event NotifyCollectionChangedEventHandler CollectionChanged
7    {
8        add => base.CollectionChanged += value;
9        remove => base.CollectionChanged -= value;
10    }
11}
12
13// Usage
14var source = new ObservableCollection<string>();
15var readOnly = new ObservableReadOnlyCollection<string>(source);
16
17// Now works directly without casting
18readOnly.CollectionChanged += (s, e) => Console.WriteLine("Changed");
19source.Add("Alice");
20// Output: Changed

Extension Method Approach

csharp
1public static class ReadOnlyObservableCollectionExtensions
2{
3    public static void SubscribeToChanges<T>(
4        this ReadOnlyObservableCollection<T> collection,
5        NotifyCollectionChangedEventHandler handler)
6    {
7        ((INotifyCollectionChanged)collection).CollectionChanged += handler;
8    }
9
10    public static void UnsubscribeFromChanges<T>(
11        this ReadOnlyObservableCollection<T> collection,
12        NotifyCollectionChangedEventHandler handler)
13    {
14        ((INotifyCollectionChanged)collection).CollectionChanged -= handler;
15    }
16}
17
18// Usage
19readOnly.SubscribeToChanges((s, e) => Console.WriteLine(e.Action));

Other Explicitly Implemented Members

ReadOnlyObservableCollection<T> also explicitly implements INotifyPropertyChanged:

csharp
1// PropertyChanged is also explicit
2((INotifyPropertyChanged)readOnly).PropertyChanged += (s, e) =>
3{
4    Console.WriteLine($"Property changed: {e.PropertyName}");
5};
6
7source.Add("Dave");
8// Output: Property changed: Count
9// Output: Property changed: Item[]

Common Pitfalls

  • Thinking CollectionChanged does not exist: The event exists — it is just hidden behind an explicit interface implementation. Cast to INotifyCollectionChanged to access it. This is a deliberate design choice, not a missing feature.
  • Memory leaks from unsubscribed handlers: Event subscriptions prevent garbage collection of the subscriber. When using the cast pattern, store the handler in a variable so you can unsubscribe later: var handler = ...; ((INotifyCollectionChanged)readOnly).CollectionChanged -= handler;.
  • Modifying the ReadOnlyObservableCollection directly: There is no way to add or remove items from the read-only collection. Changes must go through the underlying ObservableCollection<T> that was passed to the constructor.
  • Losing the source reference: The ReadOnlyObservableCollection<T> holds a reference to the source ObservableCollection<T>, but it does not expose it publicly. Keep a reference to the source if you need to modify the collection later.
  • Assuming the wrapper prevents all mutations: ReadOnlyObservableCollection<T> prevents direct add/remove, but if the items themselves are mutable objects, their properties can still be changed. For true immutability, use immutable item types.

Summary

  • CollectionChanged is explicitly implemented via INotifyCollectionChanged, not missing
  • Cast to INotifyCollectionChanged to subscribe: ((INotifyCollectionChanged)collection).CollectionChanged += handler
  • WPF data binding works automatically — it internally checks for the interface
  • Create a subclass or use extension methods to expose the event publicly if needed
  • Changes propagate from the underlying ObservableCollection<T> to the read-only wrapper automatically

Course illustration
Course illustration

All Rights Reserved.