Introduction
Creating a library or framework for other programmers has different requirements compared to a standard project. A library is code we write with the expectation that it will be used by other people’s code. A framework, on the other hand, is code that uses other people’s code. While they serve different purposes, the two often overlap, and in both cases, we must account for considerations that wouldn’t typically arise in regular projects.
One such consideration is the handling of arguments and return types in our public APIs. Not only must we validate every possible parameter and return values that meet certain criteria, but we also need to ensure these interfaces are easy to use and allow for future extensibility, without introducing breaking changes.
An example of code that should be treated differently when developing a library is described in my post: Prefer overloading than default parameters. Today, I want to focus on the topic of passing and returning collections in methods.
The motivation for this post comes from recent changes I made to my Stat system library, where I had to modify some method signatures that accepted or returned collections as Lists. The goal was to ensure future compatibility and avoid future breaking changes.
Collections As Parameters
When our methods accept collections as parameters, it’s best to make the type as generic as possible. Ideally, you should use IEnumerable<T>
as the parameter type. Of course, this isn’t always feasible, some methods may require functionality from more specific collection types. But in general, you should examine which methods and properties of the collection are actually used, and then choose the most generic type that supports those operations.
For example, in my Stat class, I originally had the method:
public void AddModifiers(List<Modifier> modifiers)
I changed it to:
public void AddModifiers(IEnumerable<Modifier> modifiers)
This change prevents the user from being locked into using a specific list implementation. As long as the collection implements IEnumerable<Modifier>
, it can be passed as a parameter.
This works because the method’s implementation only uses a foreach
loop, which requires nothing more than an enumerator. If the method required more specific behaviors -like indexing or modifying the collection- then a more specific type would be necessary. However, even in those cases, you should choose the most generic type that supports the required functionality, to reduce constraints on users of your library.
Collections As Return Types
When returning a collection from a method, the opposite principle applies compared to accepting a collection as a parameter: the return type should be as specific as possible. This doesn’t necessarily mean returning an array or a List. Often, the most specific type is one that you define yourself, typically inheriting from a standard collection type in the BCL.
There are several reasons for preferring specific return types:
To expose only the behaviors that make sense for your use case.
To support future extensibility.
To maintain compatibility across different versions of your code.
Among the types you can implement, Collection<T>, ReadOnlyCollection<T> and KeyedCollection<TKey,TItem> are particularly useful. These types expose common collection functionality and provide access to the wrapped IList<T>
through a protected Items
property (in the case of KeyedCollection<TKey,TItem>
, this is a Dictionary
property that provides access to the wrapped IDictionary
).
This pattern is especially helpful when you want to expose an immutable collection to users while retaining the ability to modify it internally. You can do this by declaring an internal property that returns the underlying list.
Here’s an example from my Stat
class. Initially, I had a method that returned all modifiers like this:
IReadOnlyList<Modifier> GetModifiers()
It ensured immutability by returning:
return modifiersList.AsReadOnly();
Where modifiersList
was a standard List<Modifier>
. While this approach is commonly recommended, it has some important drawbacks:
It locks you into using a
List
. If you ever need to switch to a different collection type, users will expect the behavior ofIReadOnlyList<Modifier>
to remain the same across all future versions.You can’t add custom behavior specific to the modifiers collection, since the return type is a generic interface.
If you want to mutate the returned collection internally, you’d need to create a separate mutable version with duplicated logic, since the returned collection is read-only.
To address these limitations, I changed the return type to a custom ModifiersCollection
, like this:
public ModifiersCollection GetModifiers()
The ModifiersCollection
, is implemented like this:
public sealed class ModifiersCollection : ReadOnlyCollection<Modifier>
{
internal ModifiersCollection(List<Modifier> list) : base(list) { }
internal IList<Modifier> GetModifiersList() => Items;
}
The previous disadvantages are gone. This type, has all the behaviors of a ReadOnlyCollection
, but the constructor and the availability of the wrapped collection are internal.
- The class name
(ModifiersCollection)
clearly communicates that it’s a custom type, which implies custom behavior. The underlying implementation can now be changed without affecting the public API. - The constructor is marked
internal
, so users of the library cannot create instances of this class. All instances come from within the library itself. - The
GetModifiersList()
method is also internal, allowing the library to access and mutate the underlying list if necessary. Also by declaring the constructor as internal it cannot be inherited by types outside the assembly, so theItems
property cannot be accessed outside the assembly with inheritance, as inheritance would be impossible. - Any future behavior specific to modifier collections can be added to the
ModifiersCollection
class without introducing breaking changes. These additions will immediately be available to users without requiring changes to their existing code.
Some More Custom Collection Tips
- When your collection contains items with unique keys, a keyed collection may be more appropriate as the base class for your custom type.
- Avoid returning
null
when returning collections. Most users won’t expect or check fornull
, so returning an empty collection is a safer and more user-friendly choice. - Decide whether the collection you return is a live collection or a snapshot. A live collection reflects the current internal state and updates as your code changes it. A snapshot represents the state at the time it was retrieved and does not change automatically. Both approaches are valid, but you should clearly define and document which one you’re using.
- Avoid using arrays unless you’re working with low-level code. Arrays are generally less flexible and less extensible than collections provided by the BCL.
- Avoid naming custom collection types in a way that reveals implementation-specific details. For instance, avoid names like
ModifiersList
unless you are absolutely certain that the underlying collection will never change. A name likeModifiersCollection
is more appropriate and leaves room for internal changes without confusing users. The only exception is when you’re intentionally implementing a known pattern (e.g., a custom stack or queue). - It should go without saying, but a custom collection should only expose methods that make sense for its intended use. If you inherit from a base collection that includes methods irrelevant to your implementation, override them to throw a
NotSupportedException
.
Conclusion
This has been a short, niche post focused on writing library or framework code, as opposed to code for an app or game project. These tips come from breaking changes I had to make in my Stat system library, to ensure compatibility across future versions.
Hopefully, this helps others in a similar position, maintaining an open-source library with plans for future expansion, while also wanting to preserve backward compatibility for users.
Thank you for reading, and if you have any questions or comments you can use the comments section, or contact me directly via the contact form or by email. Also, if you don’t want to miss any of the new blog posts, you can subscribe to my newsletter or the RSS feed.