Click here to Skip to main content
65,938 articles
CodeProject is changing. Read more.
Articles
(untagged)

Public API Events and Breaking Changes

0.00/5 (No votes)
14 Nov 2020 1  
Understanding breaking changes caused by event changes and how to avoid them.
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:

// 1.
public event Action ConnectionLost;

// 2.
public event Action<object> ConnectionLost;
// object is the Connection that fires this event.

// 3.
public event EventHandler ConnectionLost;

// 4.
public class ConnectionLostEventArgs: EventHandler {}
public event EventHandler<ConnectionLostEventArgs> ConnectionLost;

// 5.
public struct ConnectionLostEventArgs {}
public event Action<ConnectionLostEventArgs> ConnectionLost;

// 6.
public delegate void ConnectionLostEventHandler(object sender, EventArgs e);
public event ConnectionLostEventHandler ConnectionLost;

// 7.
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:

// 1.
public event Action<int> ConnectionLost;
// int is the error code.

// 2.
public event Action<object, int> ConnectionLost;
// object is the Connection that fires this event.
// int is the error code.

// 3 and 4 become the same
public class ConnectionLostEventArgs:
  EventHandler
{
  public int ErrorCode { get; init; }
}

public event EventHandler<ConnectionLostEventArgs> ConnectionLost;

// 5.
public struct ConnectionLostEventArgs
{
  public int ErrorCode { get; init; }
}

public event Action<ConnectionLostEventArgs> ConnectionLost;

// 6.
public class ConnectionLostEventArgs:
  EventHandler
{
  public int ErrorCode { get; init; }
}

public delegate void
  ConnectionLostEventHandler(object sender, ConnectionLostEventArgs e);

public event ConnectionLostEventHandler ConnectionLost;

// 7.
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

License

This article has no explicit license attached to it but may contain usage terms in the article text or the download files themselves. If in doubt please contact the author via the discussion board below.

A list of licenses authors might use can be found here