Introduction When we write code, it is often intended to interact with other code. This happens for various reasons, perhaps we are part of a team and responsible for a specific subsystem, or we are hired to implement a particular feature within a larger codebase. Sometimes, we write utility code such as libraries or frameworks, either for others to use or for reuse across our own future projects.
This type of code generally falls into two categories:
Code that will be used by other code in the future.
Code that will use other code to be written in the future.
In the second case, we must design our code to be easily extensible, without needing to modify it. The mechanisms that enable this kind of extensibility are the subject of this post, and they can be grouped into four main categories: events, interfaces, overloaded methods, and delegates as parameters.
The Four Extensibility Mechanisms
This post provides an overview of four mechanisms that can make your code easier to extend. It is not meant to be an in-depth tutorial on each one. That level of detail would be better suited to a dedicated article, or even an entire book. Moreover, these concepts (callbacks, abstraction, inheritance, polymorphism) are fundamental and appear in many contexts beyond just extensibility.
Here, I’ll introduce each mechanism, explain how and when to use it, and outline its strengths and weaknesses in the context of extensibility. As always, the right choice depends on the specific needs of your project, there’s no universal “best” solution. The best approach is the one that fits your current problem.
For instance, while events tend to have lower performance than overloaded methods in general, this is not always true. In a project where we don’t expect an event to be used very much (having methods subscribed to the event often), then an overloaded method, will perform worse as the cost of calling it will be bigger than having a null delegate. Always remember to profile your code.
This principle applies not just to performance, but to all aspects of your code—who will use it, in what context, under what constraints, and so on.
Now let’s examine each of the four mechanisms with examples, along with their pros and cons in terms of extensibility.
Events
The most common way to extend code in C# is through events. Events are a way to encapsulate delegates, allowing only two operations from outside the class: subscribing and unsubscribing methods.
Delegates, also known as callbacks, represent references to methods. While you can expose a delegate directly by making it public, this is discouraged. Instead, you should use events, which are essentially wrappers around delegates. The benefit of using events is that they prevent external code from accidentally modifying or clearing the delegate’s invocation list. Think of events as similar to properties: just as properties encapsulate and control access to fields, events encapsulate and control access to delegates.
Events are widely used because they provide an easy way to plug additional behavior into code, without requiring a deep understanding of object-oriented principles.
However, they do come with trade-offs. Events tend to be less performant than other extensibility mechanisms because delegates are reference types and add pressure to the garbage collector. Furthermore, since events allow any subscribed code to run, they can introduce unexpected behavior and make it harder to reason about correctness.
Here’s an example using a delegate, taken from my Stat system library.
public event Action ValueChanged;
.
.
.
public float Value
{
get
{
if (IsDirty)
{
currentValue = CalculateModifiedValue(_digitAccuracy);
OnValueChanged();
}
return _currentValue;
}
}
.
.
.
private void OnValueChanged() => ValueChanged?.Invoke();
Here, the user of this code can execute any piece of their own logic whenever a Stat
changes value.
Notice that instead of calling ValueChanged?.Invoke()
directly inside the property’s get
method, it is invoked within a separate OnValueChanged
method. There are two important reasons for this approach—even though only one applies in this specific case:
- By placing the call inside
OnValueChanged
, you can later modify or extend what happens when a value changes without breaking compatibility or needing to find and update every instance where the event is triggered. You simply adjust theOnValueChanged
method in one place. - If the class is not sealed (unlike the
Stat
class in this example), thenOnValueChanged
should be marked asprotected
andvirtual
. This enables another form of extensibility —method overriding— which we’ll discuss below.
Interfaces and Abstract Classes
Interfaces and abstract classes offer a second, and in many ways more powerful, mechanism for extensibility than callbacks. They allow us to define desired behaviors for a type without constraining how those behaviors must be implemented. This enables us to work with types through abstractions, without needing to know their concrete implementations in advance.
Abstractions can be used throughout your codebase and are especially useful for implementing specific behaviors in a consistent way.
However, using abstractions effectively comes with its own set of challenges:
Too many or too few members defined in the contract: An abstraction that has too many members, won’t be used often as the implementor needs to have implementations for every single member of the abstraction. On the opposite end, abstractions with too few members, are hard to be useful on our end, as we may need different behaviors to exist for different scenarios. The The Interface Segregation Principle helps guide proper interface design by encouraging small, focused abstractions. Similarly, the The Liskov Substitution Principle is essential when working with abstract classes that provide partial implementations.
Clarity for implementers: It should be clear how and where an abstraction will be used. This can come from documentation, but also from good naming and clear intent in the code. As always we are responsible to create code that cannot be used in a wrong way, and without a default implementation, we must be especially careful to design contracts that prevent misuse.
Too many abstractions: There’s a saying: “There is no problem in programming that cannot be solved by adding another layer of abstraction—except the problem of too many abstractions”. Excessive use of abstraction can make the codebase complex and difficult to maintain. If your system requires many extension points, it’s often better to mix abstraction with other mechanisms rather than rely on abstraction alone.
These challenges reflect the trade-offs involved with using abstractions. They are powerful tools but require careful design to avoid hurting productivity.
A good example of using abstractions for extensibility can be found in my post on How to create an early and a super late update in Unity. In that example, the IEarlyUpdate
interface replaces an event for performance reasons and defines only a single method: void EarlyUpdate()
. By registering an object that implements this interface with the UpdateManager
, the system takes over responsibility for executing the logic defined in the EarlyUpdate
method:
private static readonly HashSet<IEarlyUpdate> _earlyUpdates = new();
public static void RegisterEarlyUpdate(IEarlyUpdate earlyUpdate) => _earlyUpdates.Add(earlyUpdate);
.
.
.
using var e = _earlyUpdates.GetEnumerator();
while (e.MoveNext())
{
e.Current?.EarlyUpdate();
}
Virtual Members
From a usability standpoint, virtual members sit somewhere between events and abstractions. They require less setup from the user compared to abstractions, users only need to override the virtual member, similar to how events are used. However, from the developer’s perspective, designing virtual members requires more care, especially in ensuring proper parameter validation within the base method.
When a user overrides a non-abstract but virtual method, they typically expect the base method to handle all necessary parameter checks. In general, virtual methods also offer better performance than events.
One significant drawback of virtual methods, which events avoid entirely, is that their behavior is determined at compile time and cannot be changed at runtime. Additionally, just like with events, virtual methods allow arbitrary code execution, which can lead to unpredictable behavior if not carefully managed.
Designing virtual members falls in the middle ground: more complex than events due to considerations like method signatures and validation, but not as involved as designing comprehensive abstraction contracts.
A good practice is to declare virtual members as protected and have public methods call them. This approach separates core logic (which may vary in derived classes) from public interface logic, leading to better encapsulation and extensibility.
A common example of using virtual members is the Template Method Pattern, which I discuss in my post The Template Method Pattern and Usage in Unity.
Important Note: You should never call a virtual method from a constructor. Doing so can lead to unexpected behavior because of the order C# initializes object instances. For more details, see my post: The order of Instantiation And Initialization of classes in C# and Unity.
Delegates as Parameters
Lastly, we have delegates as parameters. Delegates are essentially pointers to methods (more precisely, they are multicast, meaning they are a collection of pointers to methods), so when passed as parameters, they allow your method to accept externally defined behavior.
This enables the execution of arbitrary code while keeping it contained within the scope of the method. It’s a more secure approach than events but less flexible. Compared to abstractions, it’s less secure but more flexible. Delegates as parameters are easier and quicker to implement than abstractions, but they also introduce some performance overhead since delegates are objects and subject to garbage collection.
Unlike virtual methods, delegates passed as parameters offer greater flexibility because they can be dynamically defined and changed at runtime.
Here’s an example from my Stat system once again:
internal ModifierType AddModifierOperation(int order, Func<IModifiersOperations> modifierOperationsDelegate)
In this example, the AddModifierOperation
method of the ModifierOperationsGroups
class accepts a delegate that provides the logic for how custom modifiers affect a stat. This delegate is stored in a dictionary and executed whenever a custom modifier is applied.
In my experience, delegates as parameters are used less frequently than the other three extensibility mechanisms. This is primarily because their scope is limited to the method in which they are used, and they often require very specific behavior to be useful. While events represent actions triggered by events in the system, delegates as parameters represent a custom behavior injected in the our code’s logic the same way virtual members through the template method pattern do, but in contrast with virtual members they are more flexible as they don’t have to be defined at compile time.
Conclusion
This has been an overview of the four primary techniques I use for writing extensible code, code that can interact with or integrate unknown logic written by other developers in the future. This was a high-level look at each approach, as much more could be said about these core mechanics—not just in terms of extensibility, but also in how they influence object-oriented programming (OOP) design as a whole.
When writing code that needs to interact with unknown logic, consider the pros and cons of these four methods and choose the one most appropriate for your use case:
Mechanism | Flexibility | Performance | Design Complexity | Runtime Modifiability | Encapsulation | Common Use Case |
---|---|---|---|---|---|---|
Events | High | Medium to Low | Low | High | High (via event keyword) | Notify subscribers when something happens without knowing who they are |
Interfaces / Abstract Classes | High | High (if well-designed) | High | Low | Medium | Define contracts for functionality without prescribing implementation |
Virtual Members | Medium | High | Medium | Low | Medium (use protected) | Allow derived classes to override default behavior in a controlled way |
Delegates as Parameters | Medium-High | Medium | Low to Medium | High | High (local to method) | Inject behavior at runtime without inheritance or interface implementation |
Thank you for reading and as always, 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.