C# value type boxing by interfaces

I recently had to explain the difference between value types and reference types in .NET to people that are new to .NET and object oriented programming. This explanation then gradually roles into the concept of boxing and unboxing, where boxing is the process of converting a value type to the type object or to any interface type implemented by this value type. When boxing a value type, the CLR wraps the value inside a System.Object and stores it on the managed heap. Unboxing extracts the value type from the object and puts it on the stack.

So, when explaining boxing versus unboxing, what do you do? You put a value type inside an object and cast it back...

int i = 42;
object o = i; // boxing
i = (int)o; //unboxing

However, interfaces are reference types as well and value types can implement an interface. So boxing will also happen when you treat a value type as an interface it implements. That got me thinking ... If you consider the following code:

interface IBankAccount
{
    void Add(int amount);
}

struct BankAccount : IBankAccount
{
    int value;

    public BankAccount(int value) => this.value = value;
    void IBankAccount.Add(int amount) => value += amount;
    public void Add(int amount) => value += amount;
    public void Print() => Console.WriteLine($"Value: {value}");
}

Nothing really strange here, an interface IBankAccount that supports a method Add(int). A struct BankAccount that implements IBankAccount. The IBankAccount.Add(int) is implemented privately, and there is also a method Add(int) defined on it.

Now consider the following code

BankAccount ba = new BankAccount(100);
ba.Add(50);
ba.Print();

IBankAccount iba = ba;
iba.Add(50);
ba.Print();

This piece of code will probably not print out what you might expect... The output is as follows:

Value: 150
Value: 150

What is happening here? When creating the variable ba, the CLR will put this on the stack with an initial value of 100. Next, when executing the ba.Add(50), we add 50 to the value that is stored inside ba (on the stack), resulting in 150. Now we treat ba as its interface IBankAccount and put it inside a variable iba. This is where the boxing is happening, so the CLR takes a copy of how ba now looks like and stores this on the heap (value 150). We now call the IBankAccount.Add(int), this will increment the value on the boxed version on the heap. So this did not update the original value on the stack.