🚨 Optional Parameters in C#: A “MissingMethod” Lesson Learned
Optional parameters in C# look convenient, but they can also cause surprising runtime breakages. Our team recently learned this the hard way when a constructor change broke local builds, even though everything compiled fine.
Here’s the story, what really happened, and how to avoid it in the future.
What Happened
A teammate added a new optional parameter to a constructor, mainly to avoid creating multiple overloads:
public class User
{
public User(
string name,
string email,
int age = 0,
bool isActive = true,
bool isAdmin = false) // newly added parameter
{ ... }
}
The project built fine. But some projects referencing this assembly started throwing at runtime:
MissingMethodException: Method not found:
.ctor(string, string, int, bool)
Someone had already spent a full day debugging without finding the root cause. At first, we suspected runtime object creation through reflection has some problem.
The Real Cause
It wasn’t reflection at all , it was how C# optional parameters really work.
Optional parameters are compile-time sugar. The default values are inserted at the caller’s compile time, not at runtime.
In IL (Intermediate Language), only the exact constructor/method signatures you defined exist
So by adding isAdmin, the constructor signature changed from:
.ctor(string, string, int, bool)
to:
.ctor(string, string, int, bool, bool)
Any consumer compiled earlier was still calling the old 4-parameter version. At runtime, the CLR couldn’t find it → MissingMethodException.
What IL Does With Defaults
When a caller omits an optional parameter, the compiler inserts the default value directly into the call site IL.
Example:
public void Send(string to, string body, bool track = false) { }
Send("a@b.com", "Hello"); // 'track' omitted
The caller’s IL (simplified):
ldstr "a@b.com"
ldstr "Hello"
ldc.i4.0 // compiler inserted default 'false'
call instance void Mailer::Send(string, string, bool)
Notice how the default is baked into the IL. That means:
Recommended by LinkedIn
Where MSBuild Made It Worse
We actually had two projects:
When we built solution, it built only Project B, Project A’s own source hadn’t changed. MSBuild looked at timestamps and said “up-to-date,” so it didn’t bother recompiling A.
That meant Project A’s IL was still expecting the old 4-parameter constructor. At runtime, the CLR looked inside the new Project B DLL, didn’t find the old signature, and failed.
Only after a clean + full rebuild of both projects did Project A recompile against the new 5-parameter constructor. Then everything worked fine.
Common Misbeliefs (and the Truths)
❌ “Optional parameters are resolved at runtime.” ✅ They’re baked into call sites at compile time.
❌ “Adding an optional parameter is safe; old callers will still work.” ✅ It changes the IL signature. Old binaries break until they’re recompiled.
❌ “Reflection/DI will figure out the defaults.” ✅ Reflection only matches constructors that actually exist. No match = exception.
How to Avoid This
1️⃣ Use an Options Object Instead of Parameter Soup
public class UserOptions
{
public int Age { get; init; }
public bool IsActive { get; init; } = true;
public bool IsAdmin { get; init; }
}
public class User
{
public User(string name, string email, UserOptions options = null)
{
options ??= new UserOptions();
...
}
}
Usage:
var user = new User("Alice", "alice@example.com");
var admin = new User("Bob", "bob@example.com", new UserOptions { Age = 30, IsAdmin = true });
✅ Benefits:
2️⃣ Add Overloads
Adding overloads means more constructors, but it guarantees backward compatibility. If you control all projects and always rebuild everything together, adding an optional parameter can also be fine but in shared libraries, overloads are the safer bet. This creates more constructors, but it preserves binary compatibility.
public class User
{
public User(string name, string email, int age, bool isActive, bool isAdmin) { ... }
public User(string name, string email, int age, bool isActive)
: this(name, email, age, isActive, false) { }
}
3️⃣ Treat API Changes as Breaking Altering a constructor or method signature isn’t just a minor tweak, it breaks compatibility. For shared packages, release a new API version and inform consumers.
Takeaway
This bug looked like a reflection issue at first, but the real cause was binary compatibility.
Optional parameters aren’t runtime magic , they’re compile-time sugar, and they lock call sites to exact IL signatures.