C#  

Adding Parameters to C# Methods Without Breaking Existing Code

Changing a method signature can look like a small refactoring, but in a library or shared component, adding a parameter can become a breaking change.

This is especially important when you expose an interface or a client library that is used by several applications. You may control the code today, but you don't necessarily control all of its consumers.

A useful pattern in C# is to introduce the new method signature and keep the old API available through extension methods.

Let's see why this can be useful.

The problem: adding a parameter

Imagine we originally had an interface like this:

public interface IProcessRunner
{
    Task StartAsync(Process input, CancellationToken cancellationToken = default);

    Task StartAndWaitAsync(Process input, CancellationToken cancellationToken = default);

    Task<ModelResult?> GetResultAsync(Guid processId, CancellationToken cancellationToken = default);
}

Existing consumers can simply write:

await process.StartAsync(input, cancellationToken);

Everything works.

Now imagine we need to introduce a correlationId as input.

The new requirement is:

await process.StartAsync(input, correlationId, cancellationToken);

We might be tempted to simply change the interface:

public interface IProcessRunner
{
    Task StartAsync(
        Process input,
        Guid? correlationId,
        CancellationToken cancellationToken = default);
}

But this changes the method signature.

Any existing implementation of IProcessRunner now has to implement the new method.

And existing consumers that were compiled against the previous contract may also need to be updated.

This is where compatibility becomes important.

A better approach

Instead of removing the old API, we can make the new signature the actual implementation and keep the old signature as an extension method.

The interface can expose the new functionality:

public interface IProcessRunner
{
    Task StartAsync(
        Process input,
        Guid? correlationId,
        CancellationToken cancellationToken = default);

    Task StartAndWaitAsync(
        Process input,
        Guid? correlationId,
        CancellationToken cancellationToken = default);

    Task<ModelResult?> GetResultAsync(
        Guid processId,
        Guid? correlationId,
        CancellationToken cancellationToken = default);
}

Then we provide compatibility methods:

public static class ProcessRunnerExtensions
{
    public static Task StartAsync(
        this IProcessRunner processRunner,
        Process input,
        CancellationToken cancellationToken = default)
        => client.StartAsync(
            input,
            null,
            cancellationToken);

    public static Task StartAndWaitAsync(
        this IProcessRunner client,
        Process input,
        CancellationToken cancellationToken = default)
        => client.StartAndWaitAsync(
            input,
            null,
            cancellationToken);

    public static Task<ModelResult?> GetResultAsync(
        this IProcessRunner client,
        Guid processId,
        CancellationToken cancellationToken = default)
        => client.GetResultAsync(
            processId,
            null,
            cancellationToken);
}

Now both versions can be used.

Existing code can continue doing:

await processRunner.StartAsync(input, cancellationToken);

While new code can use:

await processRunner.StartAsync(
    input,
    correlationId,
    cancellationToken);

The old method simply delegates to the new one and supplies null for the newly introduced parameter.

Don't confuse this with optional parameters

You might wonder:

"Why not simply add an optional parameter?"

For example:

Task StartAsync(
    Process input,
    Guid? correlationId = null,
    CancellationToken cancellationToken = default);

This can be perfectly reasonable in some situations.

But optional parameters have their own API-versioning considerations, particularly because default argument values are generally resolved at the call site during compilation.

Extension methods can make the compatibility relationship explicit.

When is this pattern useful?

This approach is particularly useful when you're evolving a shared library, SDK, client, or interface used by multiple applications.

For example, imagine a library used by ten different services. You discover that every operation now needs an optional correlationId.

Instead of duplicating the implementation or forcing every caller to understand the new concept immediately, you can expose the richer API and provide compatibility methods for existing usage.

The bigger lesson

Adding a parameter isn't only a syntax change. When you're working on shared APIs, you're changing a contract between your code and its consumers.

A good API evolution strategy tries to introduce new capabilities without unnecessarily forcing existing consumers to change.

In this example, the new method becomes the single implementation:

StartAsync(input, correlationId, cancellationToken)

while the old API becomes a thin compatibility layer:

StartAsync(input, cancellationToken) => StartAsync(input, null, cancellationToken)