This article presents many different ways in which an event can be declared, and shows which ones are most prone to cause breaking changes if the arguments ever change, as well as which ones will not break when those changes happen.
Introduction
When writing public APIs, one of the things we must take into consideration are possible breaking-changes in the future and, by default, we must avoid them.
This is easier said that done, but the thing is: There are some techniques that can help us at least reduce the amount of breaking changes we will cause if things change in the future.
For this article, I am focusing on breaking changes that happens because events changed.
So, let's see a possible situation:
ConnectionLost Event
For this fictional situation, we have Connection
objects that, among all the things they do, can also notify when a connection is lost. This is achieved by an event, obviously named ConnectionLost
. As this is the V1 of the API, there is no information on why the connection is lost and, even if there is a recommended practice for events, I see that people declare events in very different manners, and following are some of those:
public event Action ConnectionLost;
public event Action<object> ConnectionLost;
public event EventHandler ConnectionLost;
public class ConnectionLostEventArgs: EventHandler {}
public event EventHandler<ConnectionLostEventArgs> ConnectionLost;
public struct ConnectionLostEventArgs {}
public event Action<ConnectionLostEventArgs> ConnectionLost;
public delegate void ConnectionLostEventHandler(object sender, EventArgs e);
public event ConnectionLostEventHandler ConnectionLost;
public delegate void ConnectionLostEventHandler(Connection connection, EventArgs e);
public event ConnectionLostEventHandler ConnectionLost;
I could go on with the alternatives. I could also try to focus on the pros and cons of each one of the declarations assuming just a single version of the API would exist.
Yet, this article is about possible breaking changes, so let's say the common problem is that ConnectionLost
is not giving any information on why the connection was lost.
So, for the V2 of the API, we want to add that information.
For simplicity, let's say ErrorCode
is an int
(and that's all the V2 is going to provide).
The natural evolution of the previously presented approaches would be:
public event Action<int> ConnectionLost;
public event Action<object, int> ConnectionLost;
public class ConnectionLostEventArgs:
EventHandler
{
public int ErrorCode { get; init; }
}
public event EventHandler<ConnectionLostEventArgs> ConnectionLost;
public struct ConnectionLostEventArgs
{
public int ErrorCode { get; init; }
}
public event Action<ConnectionLostEventArgs> ConnectionLost;
public class ConnectionLostEventArgs:
EventHandler
{
public int ErrorCode { get; init; }
}
public delegate void
ConnectionLostEventHandler(object sender, ConnectionLostEventArgs e);
public event ConnectionLostEventHandler ConnectionLost;
public class ConnectionLostEventArgs:
EventHandler
{
public int ErrorCode { get; init; }
}
public delegate void
ConnectionLostEventHandler(Connection connection, ConnectionLostEventArgs e);
public event ConnectionLostEventHandler ConnectionLost;
I know this became a little too verbose as I repeated ConnectionLostEventArgs
declaration on every item that used it.
Yet, what is really important here is that, if the developers that use our API are going to recompile their code using our new API, then:
- Options 1 and 2 are definitely breaking changes. Any method that has the wrong number of arguments is not going to work.
- Option 3 might be a breaking change. As long as registrations don't use the delegate type, being
connection.ConnectionLost += Method;
or similar, they are going to work. But if we had a variable of type EventHandler
, well, EventHandler
and EventHandler<ConnectionLostEventArgs>
are not compatible types, even though the actual method they point is. - Options 4 to 7 are safe from breaking changes. The event type did not change at all and, even though the
ConnectionLostEventHandler
is now receiving a ConnectionLostEventArgs
instead of just an EventArgs
, this only breaks the code firing the event, forcing all the callers to pass the proper object, but the signature of the event is still compatible to the existing handlers with no issue.
Also, if instead of recompiling the code, users are just replacing an old library by a new one and rerunning the app, then option 3 becomes always a breaking change, because it doesn't matter how we register our events in code (with explicit types or implicit ones), the actual event type is always part of the binary signature.
The Recommended Practice and the Problem
I said before that there is a recommended practice, yet I decided to show many different options. I am sure the first options might look off because they don't follow the standard and are clearly broken, while the last options seem to require too many types to be declared ahead of time. So, why not just stick to the recommended practice?
Well... because the recommended practice is probably going to be option 3, which is going to cause breaking changes. There might be situations that it will not (users just used += Method and -= Method, never wrote the EventHandler
type and recompiled the code) but, as a rule of "is this a breaking change", using option 3 is bad. Yet, it is recommended because it was meant to allow events to evolve without causing breaking changes. That's why we have that "useless" EventArgs
to start with. So, what's really happening?
I can only guess, and my guess is that the entire idea was to always have specific delegate types if we ever expected to have arguments, and still pass that "dummy" args for events we never planned to have args. That dummy args is a "safety feature" if we actually end-up requiring arguments later (even though we never planned them). But, instead of changing the signature of the event handler, we would keep them as EventHandler
, but would pass our sub-class instance as argument, and any user that actually wanted the new args would need to cast it to the right type.
That works, but then we have a "newer API" that uses less specific types "just because it started wrong". I understand that breaking changes are bad, but having to keep an API "always wrong" just because we didn't anticipate an argument in an event is also bad. In fact, the use of casts is many times considered a bad practice, and that is the only way to pass arguments to an EventHandler
event without causing breaking changes (and without having to create a new event like ConnectionLostWithErrorCode
).
What's the Right Solution?
I would love to say that there is a perfect solution but, right now, there isn't.
Considering that writing ConnectionLostEventHandler
is much simpler than EventHandler<ConnectionLostEventArgs>
, that option seems to be the most elegant (and available since .NET 1). Yet, I don't like that when I need the args object, I will end-up with 2 new types for a single event.
So, always creating an event args and then using EventHandler<ArgsType>
seems the safest bet to support future changes. It still has some excessive type creation, but is less than declaring a new delegate type every time.
But I wouldn't say that's the best option in all cases. That object sender
in EventHandler
(and its generic version) is terrible if we are dealing with object decoration (we might get the non-decorated sender
instead of the decorated one) and it forces the use of casts which, again, is usually seen as a bad practice. Also, the inheritance of EventArgs
means that if we can't store all the possible args that will be instantiated ahead of time, we will be allocating new objects that need to be collected all the time. That's why one of my examples was an Action<ConnectionLostEventArgs>
. For that example, the args type is actually a struct
, not a class
, and the memory allocation can be avoided. Probably not a big problem for a ConnectionLost
event, as it shouldn't be happening all the time, but can make a huge difference in events fired when animating objects or similar.
All in all, I will just say: Avoid EventHandler
as your "basic" event-handler type in public APIs if you believe there is a minimum chance real arguments are ever going to be needed.
History
- 14th November, 2020: Initial version