How to Observe and Synchronize Collections with Decorators in C#

Collections often need behaviour that has nothing to do with storage. One list must report every change to a cache or a view, another must be safe to share between threads, and a third must present its items as a different type. Building each of those into a custom collection class couples the behaviour to one storage choice and repeats it for every other one.

Sphere10 Framework supplies these behaviours as decorators that wrap any list, dictionary or set. This article covers the observable decorators and their event model, the synchronized decorators and their lock scopes, projections that convert item types, and how the decorators stack over one another.

A plain list wrapped by layers that add change events and thread-safe access.
A plain list wrapped by layers that add change events and thread-safe access.
💡 In short. Add change events, thread-safe access and type projection to any list with the observable, synchronized and projected collection decorators, and stack them over one another.

1. A first look

AsObservable() returns an ObservableExtendedList<T>. It raises one event before each operation and one after it, with the arguments of the call.


var list = new ExtendedList<int>().AsObservable();
var log = new List<string>();
list.Mutating += (_, trait) => log.Add($"Mutating({trait})");
list.Adding += (_, args) => log.Add($"Adding({string.Join(",", args.CallArgs.Items)})");
list.Added += (_, args) => log.Add($"Added({string.Join(",", args.CallArgs.Items)})");
list.Mutated += (_, trait) => log.Add($"Mutated({trait})");
list.RemovedRange += (_, args) => log.Add($"RemovedRange({args.CallArgs.Index},{args.CallArgs.Count})");

list.AddRange(new[] { 1, 2, 3 });

The single AddRange call leaves four entries in the log, which are Mutating(Add), Adding(1,2,3), Mutated(Add) and Added(1,2,3) in that order.

2. How the decorators fit together

A decorator implements the same contract as the collection it wraps and forwards each call to it, adding its own behaviour on the way. The list contract used here is IExtendedList<T>, which has 64-bit counts and batch members such as AddRange and ReadRange. Because a decorated list is still an IExtendedList<T>, it can be wrapped again.

Extension methods create the wrappers. AsObservable() and AsSynchronized() apply to any IExtendedList<T>, and WithProjection returns a converted view. The innermost collection can be any implementation, including the Framework's paged and stream-mapped lists.

Diagram source
block-beta
    columns 1
    A["Calling code"]
    B["SynchronizedExtendedList<br/>read and write locks"]
    C["ObservableExtendedList<br/>before and after events"]
    D["ExtendedList<br/>storage"]

3. Observe every operation

The handlers in the first look subscribe to both kinds of event that an observable list raises. The events come in two layers. The general events Accessing, Reading and Mutating, with their completed counterparts, fire for any operation of that kind and carry an EventTraits value that names it. The specific events, such as Adding and Added, Inserting and Inserted, Updating and Updated, RemovingRange and RemovedRange, carry the items, index and count of one call. A single AddRange therefore reports as follows.

One operation is one pair of specific events, however many items it carries. A handler that maintains derived state can subscribe to Mutated alone, and a handler that needs the changed items subscribes to the specific events.

Diagram source
---
config:
  sequence:
    actorMargin: 8
    width: 96
    mirrorActors: false
    messageMargin: 28
    noteMargin: 8
---
sequenceDiagram
    participant C as Caller
    participant O as Observable
    participant H as Handlers
    C->>O: AddRange(items)
    O->>H: Mutating(Add)
    O->>H: Adding(items)
    Note over O: inner list AddRange
    O->>H: Mutated(Add)
    O->>H: Added(items)
    O-->>C: returns

Filter and suppress events

Reads raise events too, which is rarely wanted on a busy list. EventFilter selects which kinds are raised, and SuppressNotifications switches all of them off for a bulk change.


list.EventFilter = EventTraits.Write;
list.Add(1);
var first = list.Read(0);
Require(first == 1 && writes == 1 && reads == 0, "Filter did not limit events to writes.");

list.SuppressNotifications = true;
list.Add(2);
list.SuppressNotifications = false;

The EventTraits flags are Access, Read, Write, Count, Search, Fetch, Add, Update, Insert and Remove. The default filter is EventTraits.All.

The same model applies to the other observable types. ObservableList<T> wraps an IList<T>, ObservableDictionary<TKey, TValue> wraps an IDictionary<TKey, TValue>, and ObservableCollection<T> wraps an ICollection<T>.

4. Synchronize access

AsSynchronized() returns a SynchronizedExtendedList<T>, which takes a read lock around each reading member and a write lock around each writing member. Individual calls from several threads are then safe.


var list = new ExtendedList<int>().AsSynchronized();
Parallel.For(0, 1000, index => list.Add(index));
Require(list.Count == 1000, "Concurrent adds were lost.");

Locking each member does not make a sequence of members atomic. Reading the last item and then adding its successor is two calls, and another thread can run between them. For that, take the lock yourself with EnterWriteScope() or EnterReadScope() and hold it across the sequence.


var counter = new ExtendedList<int>().AsSynchronized();
counter.Add(0);
Parallel.For(0, 1000, _ => {
	using (counter.EnterWriteScope()) {
		var last = counter.Read(counter.Count - 1);
		counter.Add(last + 1);
	}
});

The members called inside the scope take the same lock again on the same thread, which the lock permits. SynchronizedList<T>, SynchronizedDictionary<TKey, TValue>, SynchronizedSet<T> and SynchronizedQueue<T> follow the same pattern for the other contracts.

5. Stack and project

Because each decorator is itself a list, the two behaviours combine by wrapping one in the other.


var observable = new ExtendedList<int>().AsObservable();
var changes = 0;
observable.Mutated += (_, _) => changes++;
var shared = observable.AsSynchronized();
Parallel.For(0, 500, index => shared.Add(index));

The order matters. With the synchronized list outermost, the events are raised while the write lock is held, so handlers run one at a time and the plain changes++ is safe. With the observable list outermost, handlers would run outside the lock.

A projection presents a list as another item type through a pair of conversion functions, and writes through the view reach the underlying list.


var numbers = new ExtendedList<int>();
var text = numbers.WithProjection(value => value.ToString(), int.Parse);
text.Add("42");

6. Limits

⚠️ Keep these limits in mind.
  • Handlers run on the calling thread, inside the operation. A slow handler slows every caller, and an exception thrown by a "before" handler stops the operation.
  • Events do not cross the wrapper. Code that keeps a reference to the inner list and changes it directly raises no events and takes no lock. Hand out only the decorated reference.
  • A held scope blocks other threads. Keep the work inside EnterWriteScope() short, and do not hold a scope across an await.
  • Enumeration can hold a lock. A SynchronizedCollection<T> keeps its read lock while an enumerator is alive, so finish or dispose enumerations promptly.

7. Get the package and run the sample

The decorators ship in the core Sphere10.Framework package. The sample targets .NET 10.


dotnet new console --framework net10.0 --name ArticleSample
cd ArticleSample
dotnet add package Sphere10.Framework --version 3.1.3

The complete MIT-licensed sample contains the program shown above. Extract the download over the generated project and run it.


dotnet run -c Release


Add events: Mutating(Add) -> Adding(1,2,3) -> Mutated(Add) -> Added(1,2,3)
Filtered to writes: 1 write event, 0 read events, 2 items
Synchronized list: 1000 concurrent adds kept, 1000 read-then-add pairs in order
Stacked decorators: 500 synchronized adds raised 500 events
Projected view wrote 42 into the underlying list

The Collections guide lists every observable, synchronized and projected type with its event arguments.


sample.zip
Download the complete worked example and build instructions (ZIP).

The Sphere10 Framework overview describes the rest of the toolkit.

Author: Herman Schoenfeld