blog
Ottorino Bruni  

Getting Started with System.Collections and Immutable Types in C# and .NET – Part 2

Introduction

In Part 1 of this series, we explored the foundational types and interfaces behind collections in C# and .NET, such as IEnumerable<T>, ICollection<T>, IList<T>, as well as common types like arrays, List<T>, Queue<T>, Stack<T>, and Dictionary<TKey, TValue>.

We also discussed trade-offs and practical use cases to help you make better choices when working with collections.

But the .NET framework offers much more than just the basics.

In this second part, we’ll dive into specialized, immutable, and thread-safe collection types that are often overlooked yet extremely powerful when building applications with concurrency, shared state, or functional programming principles in mind.

By the end of this article, you’ll not only be familiar with types like ImmutableList<T> and ConcurrentDictionary<TKey, TValue>, but also understand when and why to use them.

Let’s get started.

Immutable Collections

Getting Started with System.Collections and Immutable Types in C# and .NET – System.Collections.Immutable Namespace

Immutable collections, found in the System.Collections.Immutable namespace, are designed to ensure that once a collection is created, it cannot be changed. Any modification operation like adding or removing an item will return a new instance of the collection, leaving the original one untouched.

This “copy-on-write” behavior might seem inefficient at first, but it brings important benefits when working with concurrent systems, functional-style code, or unit testing scenarios where data integrity and predictability are critical.

In this section, we’ll explore:

  • ImmutableList<T>
  • ImmutableDictionary<TKey, TValue>
  • ImmutableStack<T>

You’ll learn how they work under the hood, when to use them, and how they help you write safer, more testable code.

Let’s begin with ImmutableList<T>.

ImmutableList<T>

ImmutableList<T> is an immutable version of the classic List<T>. Instead of modifying the collection in-place, each operation returns a new list with the desired change, while the original list stays unchanged.

This behavior is especially useful when working in multi-threaded environments or when you want to ensure that data passed around your code doesn’t get accidentally modified.

How it works (under the hood)

Immutable collections use a copy-on-write strategy. Internally, they avoid full duplication by sharing structure where possible. When you “modify” an immutable list, a new internal node is created, but most of the data is reused.

Example

using System.Collections.Immutable;

var original = ImmutableList<string>.Empty;

var updated = original.Add("Apple").Add("Banana").Add("Cherry");

Console.WriteLine("Original list:");
foreach (var item in original)
{
    Console.WriteLine(item);
}

Console.WriteLine("\nUpdated list:");
foreach (var item in updated)
{
    Console.WriteLine(item);
}

var removed = updated.Remove("Banana");

Console.WriteLine("\nAfter removing 'Banana':");
foreach (var item in removed)
{
    Console.WriteLine(item);
}

Output

Original list:

Updated list:
Apple
Banana
Cherry

After removing 'Banana':
Apple
Cherry

As you can see, the original list remains untouched throughout the process.

When to use

  • When data integrity is critical
  • When passing collections across components and you want to avoid side effects
  • In functional-style code (immutability is a core concept)
  • For unit tests (you always get a fresh instance)

ImmutableDictionary<TKey, TValue>

ImmutableDictionary<TKey, TValue> is an immutable version of Dictionary<TKey, TValue>. It ensures that once a key-value pair is added, the original dictionary remains unchanged. Every modification creates a new instance, preserving the previous state.

This is especially useful when working with configuration data, caching strategies, or shared state in multi-threaded environments.

Example: Using ImmutableDictionary<TKey, TValue>

using System.Collections.Immutable;

var original = ImmutableDictionary<string, int>.Empty;

var updated = original
    .Add("Apples", 5)
    .Add("Oranges", 3)
    .Add("Bananas", 7);

Console.WriteLine("Original dictionary:");
foreach (var item in original)
{
    Console.WriteLine($"{item.Key}: {item.Value}");
}

Console.WriteLine("\nUpdated dictionary:");
foreach (var item in updated)
{
    Console.WriteLine($"{item.Key}: {item.Value}");
}

var updatedAgain = updated.SetItem("Apples", 10).Remove("Oranges");

Console.WriteLine("\nAfter updating 'Apples' and removing 'Oranges':");
foreach (var item in updatedAgain)
{
    Console.WriteLine($"{item.Key}: {item.Value}");
}

Output

Original dictionary:

Updated dictionary:
Apples: 5
Oranges: 3
Bananas: 7

After updating 'Apples' and removing 'Oranges':
Apples: 10
Bananas: 7

When to use it

  • When you need thread-safe read operations
  • To ensure data integrity in caching and lookups
  • When you want to avoid side effects from shared references
  • For predictable, testable state transitions

ImmutableStack<T>

ImmutableStack<T> is the immutable equivalent of Stack<T>, providing Last-In-First-Out (LIFO) behavior while ensuring that any push or pop operation returns a new stack, leaving the original unchanged.

This is especially useful in functional programming, undo-redo implementations, or anywhere you need predictable, side-effect-free operations.

Example: Using ImmutableStack<T>

using System.Collections.Immutable;

var emptyStack = ImmutableStack<string>.Empty;

var stack1 = emptyStack.Push("First");
var stack2 = stack1.Push("Second");
var stack3 = stack2.Push("Third");

Console.WriteLine("Top of stack3: " + stack3.Peek()); // Third

var stack4 = stack3.Pop(); // Removes "Third"

Console.WriteLine("Top after one pop: " + stack4.Peek()); // Second

Output

Top of stack3: Third
Top after one pop: Second

When to use it

  • When building undo/redo systems
  • In recursive algorithms or parsers
  • When you want predictable and safe stack behavior across operations
  • For thread-safe LIFO data access without locks

Thread-Safe Collections in .NET

Getting Started with System.Collections and Immutable Types in C# and .NET – System.Collections.Concurrent Namespace

In multi-threaded applications, standard collections like List<T> or Dictionary<TKey, TValue> can easily cause race conditions or data corruption if accessed concurrently. To address this, .NET provides a set of thread-safe collections under the System.Collections.Concurrent namespace.

These collections are designed to safely handle concurrent reads and writes without needing locks, making your code cleaner and more efficient.

We’ll explore:

  • ConcurrentDictionary<TKey, TValue>
  • ConcurrentQueue<T>
  • ConcurrentBag<T>
  • BlockingCollection<T> (producer-consumer pattern)

We’ll look at when to use them and how they compare to standard collections in terms of performance and safety.

ConcurrentDictionary<TKey, TValue>

ConcurrentDictionary<TKey, TValue> is a special version of Dictionary that is safe to use when multiple threads access it at the same time.

With a normal Dictionary, if two threads try to add or update values at once, your app could crash or return wrong data. With ConcurrentDictionary, you don’t need to worry about that it takes care of locking for you behind the scenes, so everything stays safe and consistent.

It’s especially useful in background tasks, APIs, or real-time apps where many parts of the code access shared data at once.

Example: Using ConcurrentDictionary

using System.Collections.Concurrent;

var userConnections = new ConcurrentDictionary<string, int>();

// Add or update entries
userConnections["user1"] = 3;
userConnections.AddOrUpdate("user2", 1, (key, oldValue) => oldValue + 1);

// Try to get a value
if (userConnections.TryGetValue("user1", out int count))
{
    Console.WriteLine($"user1 has {count} connections.");
}

// Try to remove an entry
userConnections.TryRemove("user2", out _);

Output

user1 has 3 connections.

When to use it

  • When you have multiple threads or tasks sharing a dictionary
  • For real-time data updates (e.g. user sessions, in-memory cache)
  • To avoid writing your own lock logic

Use ConcurrentDictionary anytime you’re working with shared data across threads it keeps your code safe and easier to maintain.

ConcurrentQueue<T>

ConcurrentQueue<T> is a thread-safe version of Queue<T>, designed for first-in, first-out (FIFO) operations in multi-threaded scenarios.

With a regular Queue<T>, you’d need to add manual locking if multiple threads might enqueue or dequeue items at the same time. ConcurrentQueue<T> solves that for you it handles synchronization internally so your app stays safe and fast without extra effort.

It’s commonly used in producer-consumer scenarios, background processing, and parallel data pipelines.

Example: Using ConcurrentQueue

using System.Collections.Concurrent;

var jobQueue = new ConcurrentQueue<string>();

// Enqueue jobs
jobQueue.Enqueue("Job A");
jobQueue.Enqueue("Job B");

// Try to dequeue
if (jobQueue.TryDequeue(out string job))
{
    Console.WriteLine($"Processing: {job}");
}

Output

Processing: Job A

When to use it

  • When tasks or threads add and remove items in a queue
  • For asynchronous background work (like message processing)
  • To build producer-consumer pipelines

Use ConcurrentQueue<T> when you want a safe and efficient queue in multi-threaded code no manual locking needed.

ConcurrentStack<T>

ConcurrentStack<T> is a thread-safe version of Stack<T>. It follows the Last-In, First-Out (LIFO) principle, meaning the last item pushed is the first one popped. It’s useful when multiple threads need to add and remove items from a shared stack without locking.

Internally, ConcurrentStack<T> uses lock-free algorithms (based on Compare-And-Swap) to ensure high performance even under contention.

Example: Using ConcurrentStack

using System.Collections.Concurrent;

var history = new ConcurrentStack<string>();

// Push items
history.Push("Step 1");
history.Push("Step 2");

// Try to pop
if (history.TryPop(out string step))
{
    Console.WriteLine($"Undo: {step}");
}

Output

Undo: Step 2

When to use it

  • For undo/redo operations shared across threads
  • In parallel algorithms that need temporary LIFO buffers
  • To avoid race conditions in stack-based logic

Other Useful Thread-Safe Collections

ConcurrentBag<T>

ConcurrentBag<T> is a thread-safe, unordered collection — ideal when the order of items doesn’t matter.

It’s especially useful in parallel loops (e.g. Parallel.For) where multiple threads produce intermediate results that need to be collected safely.

Think of it as a thread-safe “grab bag” of values.

BlockingCollection<T>

BlockingCollection<T> is a wrapper around other collections like ConcurrentQueue<T> that adds powerful features such as:

  • Capacity limits
  • Blocking behavior when the collection is full or empty
  • Built-in support for the producer/consumer pattern

It’s perfect for background workers, data pipelines, or task queues that require flow control and backpressure handling.

Specialized Collections in .NET

Getting Started with System.Collections and Immutable Types in C# and .NET – System.Collections.Specialized Namespace

While most developers rely on List<T>, Array, and Dictionary<TKey, TValue>, the .NET framework also offers several specialized collections that solve specific problems more efficiently or with added functionality.

HashSet<T> – Uniqueness Guarantee

A HashSet<T> is a collection that automatically ensures no duplicates. It’s ideal when you need a fast way to track unique values.

  • Internally uses hashing (GetHashCode() and Equals())
  • Operations like Add, Contains, and Remove are O(1) on average
var emails = new HashSet<string>();
emails.Add("otto@example.com");
emails.Add("otto@example.com"); // Will be ignored — already exists

Console.WriteLine(emails.Count); // Output: 1

Use HashSet<T> when you care about uniqueness and don’t need ordering.

SortedSet<T> – Always Ordered

SortedSet<T> extends HashSet<T> by automatically keeping elements sorted.

  • Maintains elements in order using a balanced binary search tree
  • Useful when you want uniqueness + order without sorting manually
var scores = new SortedSet<int> { 80, 95, 80, 70 };
foreach (var score in scores)
{
    Console.WriteLine(score); // Output: 70, 80, 95
}

Perfect for leaderboard-like data or when you need fast lookups + sorted output.

ObservableCollection<T> – UI Notifications

ObservableCollection<T> is mostly used in UI frameworks (like WPF, MAUI, Blazor) because it raises events whenever the collection changes.

  • Implements INotifyCollectionChanged
  • Triggers events like CollectionChanged when items are added, removed, or refreshed
var items = new ObservableCollection<string>();
items.CollectionChanged += (s, e) =>
{
    Console.WriteLine($"Change: {e.Action}");
};

items.Add("Milk"); // Triggers event

Great for data binding and reactive interfaces where the UI needs to respond to collection updates.

Example: Advanced .NET Collections in a Safe Order Processing System

Disclaimer: This example is purely for educational purposes. There are better ways to write code and applications that can optimize this example. Use this as a starting point for learning, but always strive to follow best practices and improve your implementation.

All the code is available in this GitHub repository under the collections-part2 branch OrderProcessingDemo

Scenario

You’re building a system that processes customer orders. You need to:

  • Keep a set of available product categories, sorted and unique → SortedSet<string>
  • Track customer tags for marketing → ImmutableHashSet<string>
  • Manage a thread-safe queue of pending ordersBlockingCollection<Order>

Prerequisites

Before starting, make sure you have the following installed:

  • .NET SDK: Download and install the .NET SDK if you haven’t already.
  • Visual Studio Code (VSCode): Install Visual Studio Code for a lightweight code editor.
  • C# Extension for VSCode: Install the C# extension for VSCode to enable C# support.

Getting Started

To try the example on your own machine, follow these steps:

  • Open Visual Studio Code
  • Create a new folder for your project, for example: OrderProcessingDemo
  • Open the folder in VSCode
  • Open the terminal and run:
dotnet new console -n OrderProcessingDemo
cd OrderProcessingDemo

This will create a new .NET Console App called OrderProcessingDemo.

  • Replace the contents of the generated Program.cs file with the code below
  • Run the application with:

The goal is to demonstrate how each collection type fits naturally into different aspects of a simple, practical workflow.

Program.cs

using System.Collections.Concurrent;
using System.Collections.Immutable;

// Specialized Collection: SortedSet for unique, ordered categories
var categories = new SortedSet<string> { "Electronics", "Books", "Toys", "Books" }; // "Books" added twice

Console.WriteLine("Product Categories:");
foreach (var category in categories)
{
    Console.WriteLine($"- {category}");
}

// Immutable Collection: Customer tags (e.g. loyalty, interest)
var baseTags = ImmutableHashSet<string>.Empty;
var customerTags = baseTags.Add("Loyal").Add("Newsletter").Add("VIP");

// Try to "mutate" the set
var updatedTags = customerTags.Add("Referral");

Console.WriteLine("\nCustomer Tags:");
foreach (var tag in updatedTags)
{
    Console.WriteLine($"- {tag}");
}

// Thread-Safe Collection: BlockingCollection for pending orders
var orderQueue = new BlockingCollection<Order>();

// Producer: add a few orders
orderQueue.Add(new Order(1, "John Doe", 99.99m));
orderQueue.Add(new Order(2, "Jane Smith", 49.50m));
orderQueue.CompleteAdding(); // Signal we're done

// Consumer: process orders
Console.WriteLine("\nProcessing Orders:");
foreach (var order in orderQueue.GetConsumingEnumerable())
{
    Console.WriteLine($"✔ Order #{order.Id} for {order.CustomerName}: €{order.Total}");
}

// Simple order record
record Order(int Id, string CustomerName, decimal Total);
Getting Started with System.Collections and Immutable Types in C# and .NET – Program.cs
dotnet run

This will execute the example and show the output in your terminal.

Product Categories:
- Books
- Electronics
- Toys

Customer Tags:
- Newsletter
- Referral
- VIP
- Loyal

Processing Orders:
✔ Order #1 for John Doe: €99,99
✔ Order #2 for Jane Smith: €49,50

This example demonstrates how to use different types of advanced .NET collections in a basic order processing scenario. It combines three key concepts: ordering, immutability, and thread safety.

First, it uses a SortedSet<string> to store product categories. This ensures that all categories are unique and automatically sorted. For instance, even if you try to add the same category twice, only one instance will be stored, and the categories will appear in alphabetical order without needing to sort them manually.

Next, it introduces an ImmutableHashSet<string> to manage customer tags such as “Loyal”, “VIP”, or “Newsletter”. The key idea here is immutability: each time a new tag is added, a new set is returned, leaving the original unchanged. This makes it safer to share data across different parts of your application without risking unintended changes.

Finally, a BlockingCollection<Order> is used to simulate a simple producer/consumer pattern. Orders are added to the collection, and then processed one by one. This collection handles synchronization for you, so multiple threads can add or consume orders safely without worrying about locks or race conditions.

Together, these collections show how to design small, maintainable, and safe systems using the right tool for each job.

Getting Started with System.Collections and Immutable Types in C# and .NET – Run app

Conclusion

Whether you’re building a simple CRUD app or a high-performance distributed system, collections are everywhere in C# and .NET. Knowing which collection to choose and why can have a huge impact on your code’s readability, performance, and correctness.

In Part 1, we covered the fundamentals: the core interfaces like IEnumerable<T>, ICollection<T>, and IList<T>, along with essential types like arrays, List<T>, Queue<T>, and Stack<T>.

In this Part 2, we explored the power of:

  • Immutable collections, which help avoid accidental data mutations
  • Thread-safe collections, critical for concurrent applications
  • Specialized collections, like HashSet<T> and ObservableCollection<T>, designed for specific scenarios

The .NET ecosystem offers a rich and thoughtful collection library, designed to support a wide range of needs — from simple local lists to real-time concurrent data pipelines.

As you continue learning and building with .NET, take the time to explore these tools. Understanding the trade-offs and capabilities of each collection will help you write cleaner, safer, and more maintainable code.

If you think your friends or network would find this article useful, please consider sharing it with them. Your support is greatly appreciated.

Thanks for reading!

🚀 Discover CodeSwissKnife, your all-in-one, offline toolkit for developers!

Click to explore CodeSwissKnife 👉

Leave A Comment

This site uses Akismet to reduce spam. Learn how your comment data is processed.