
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

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

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

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()
andEquals()
) - Operations like
Add
,Contains
, andRemove
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 orders →
BlockingCollection<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);

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.

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>
andObservableCollection<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 👉