Adding a optional parameter is a breaking change. This topic came up more frequently around the time the feature was introduced but, I haven’t seen it brought up in a while. I mentioned it on a code review recently so, now it’s fresh on my mind. The “breaking” nature of optional parameters is a fun one because it is not entirely obvious. How is it breaking when the consuming code doesn’t need to change? Well, like anything in software development, there is a logical explanation.

Lets take a look at the following code. Imagine we have a library that contains a message queue. We might have something like the IMessageQueue interface below.
public interface IMessageQueue { void Publish<T>(Message<T> message); }
Next, as part of our business requirements, we need to have the ability to send emails. We remember our super cool message queue so, we decide to use it. This leaves us with the following EmailService.
public class EmailService { private readonly IMessageQueue _queue; public EmailService(IMessageQueue queue) { _queue = queue; } public void Send(Email email) { _queue.Publish(new Message<Email>(email)); } }
Some time goes by and we want to enhance our message queue. We know that there are a couple other libraries that are using it so, we make sure to add default values to all our new parameters. With the following code, our consumers can still invoke the Publish method exactly as they have before.
public interface IMessageQueue { void Publish<T>(Message<T> message, MessagePriority priority = MessagePriority.Normal); }
So where’s the breaking change?!? The code looks exactly the same. The secret lies in the IL code that is generated. Let’s look at the the following line of code
_queue.Publish(new Message<Email>(email));
Prior to our code changes, we get IL that looks like the following
IL_0001: ldarg.0 // this
IL_0002: ldfld class EspressoCoder.Samples.Defaults.IMessageQueue EspressoCoder.Samples.Defaults.EmailService::_queue
IL_0007: ldarg.1 // email
IL_0008: newobj instance void class EspressoCoder.Samples.Defaults.Message`1<class EspressoCoder.Samples.Defaults.Email>::.ctor(!0/*class EspressoCoder.Samples.Defaults.Email*/)
IL_000d: callvirt instance void EspressoCoder.Samples.Defaults.IMessageQueue::Publish<class EspressoCoder.Samples.Defaults.Email>(class EspressoCoder.Samples.Defaults.Message`1<!!0/*class EspressoCoder.Samples.Defaults.Email*/>)
IL_0012: nop
After we add the default parameter, we notice the IL changes
IL_0001: ldarg.0 // this
IL_0002: ldfld class EspressoCoder.Samples.Defaults.IMessageQueue EspressoCoder.Samples.Defaults.EmailService::_queue
IL_0007: ldarg.1 // email
IL_0008: newobj instance void class EspressoCoder.Samples.Defaults.Message`1<class EspressoCoder.Samples.Defaults.Email>::.ctor(!0/*class EspressoCoder.Samples.Defaults.Email*/)
IL_000d: ldc.i4.1
IL_000e: callvirt instance void EspressoCoder.Samples.Defaults.IMessageQueue::Publish<class EspressoCoder.Samples.Defaults.Email>(class EspressoCoder.Samples.Defaults.Message`1<!!0/*class EspressoCoder.Samples.Defaults.Email*/>, valuetype EspressoCoder.Samples.Defaults.MessagePriority)
IL_0013: nop
Why does .NET compile these changes changes differently? It is because .NET determines the default values at compile time. When the function is invoked, it is called with all default parameter values even though you do not see this in code.
Pay special attention to the line IL_000d: ldc.i4.1. The last character in this line is ‘1’ which is the numeric representation of the MessagePriority.Normal default value. On the following line, we can then see that the full function (with all parameters) is invoked.
What does this mean? It means anytime we modify the parameter list, with required OR optional parameters, the consumers need to be updated. Even if that means merely recompiling against the updated function. For example, if these two classes are in the same project or even the same solution you will not have an issue. Unfortunately, this is not always possible. Compiled libraries are often delivered as nuget packages from either internal or external repositories. With the previous example, it would be perfectly reasonable to have our two classes in separate nuget packages. In this case, a runtime error will occur.
This doesn’t mean these sorts of changes CANT or SHOULDN’T be made. What it does mean is if they are, appropriate versioning must be made otherwise, pain will ensue.