A couple of years ago I published a small NuGet package. Inside it, I had this:
public class ApiConfig
{
public const string Version = "1.0.0";
public const int MaxRetries = 3;
}
Seemed harmless. Constants, right? I bumped MaxRetries to 5 in version 1.1.0 of the library, and pushed the NuGet update.
A week later, one of the services was still silently retrying only 3 times, even though it had definitely pulled the new package version. I checked the DLL. It genuinely had 5 init. Then a teammate said, "did you rebuild the consumer, or just the library?"
That question was the whole answer. And it’s the reason I now think really carefully before typing the word const.
Three Keywords, Three Different Promises
const, readonly, and static readonly all create values that can't be reassigned after some point. But when they're locked in, and how they get baked into your compiled code, are completely different, and that difference is exactly what broke my retry logic.
public class Settings
{
public const int MaxRetries = 3; // const
public readonly int Timeout; // readonly (instance)
public static readonly DateTime Deployed = DateTime.Now; // static readonly
}
const: Baked In at Compile Time, Forever
A const value must be known at compile time. That's a hard rule, you can only use literals or expressions the compiler can fully evaluate right now, like 3, "hello", or 3.14 * 2. You cannot do this:
public const DateTime Deployed = DateTime.Now; // ERROR: not a compile-time constant
Because const is resolved at compile time, the compiler doesn't just remember the value, it copies the literal value directly into every place that uses it, at every call site, in every assembly that references it. There's no variable lookup at runtime at all, the values for MaxRetries gets replaced with the literal 3 wherever it's used, as if you'd typed 3 yourself.
That’s exactly what bit me. When I updated MaxRetries = 5 in the new DLL, the consuming service's existing compiled code already had 3from when it was last built against the old package. Referencing the new DLL didn't matter, the old assembly never asked the DLL for the value at runtime, because it never needed to. It already "knew" the answer, permanently, from the moment it was compiled.
Thi sis literally what heppened
Day 1: Library v1.0 compiled => MaxRetries = 3 baked into library DLL
Day 1: Consumer compiled against library v1.0 => literal “3” baked into CONSUMER’s own DLL
Day 7: Library v1.1 compiled => MaxRetries = 5 baked into library DLL (this part works fine)
Day 7: Consumer’s DLL is NOT touched, nobody recompiled it
Day 7: You swap in the new library DLL, but the consumer’s already-compiled code
still contains its own private copy of “3” from Day 1
This is the versioning trap with const: if you ship a new value, every consumer needs to be recompiled, not just re-deployed with an updated reference.
readonly: Runtime-Assigned, Locked Per Instance
readonly relaxes both of those constraints. The value doesn't need to be known at compile time, and it's evaluated at runtime specifically, after that it's locked for the lifetime of that object instance.
public class Connection
{
public readonly int TimeoutSeconds;
public Connection(int timeoutSeconds)
{
TimeoutSeconds = timeoutSeconds; // allowed, still inside the constructor
}
public void Reset()
{
TimeoutSeconds = 30; // ERROR, constructor has already finished
}
}
Because it’s resolved at runtime and belongs to each object, different instances can legitimately have different readonly values:
var fast = new Connection(5);
var slow = new Connection(60);
Each Connection object carries its own TimeSeconds , decided when it was constructed.
static readonly: One Shared Value, Set Once, At Runtime
static readonly is the combination that usually should have been my first instinct for ApiConfig. It behaves like readonly (evaluated at runtime, can use non-constant expressions, assignable in a static constructor) but it's shared across the whole type instead of belonging to each instance.
public class ApiConfig
{
public static readonly int MaxRetries = ComputeDefaultRetries();
public static readonly DateTime LibraryLoadedAt = DateTime.Now;
static int ComputeDefaultRetries()
{
return Environment.GetEnvironmentVariable("HIGH_RELIABILITY") == "true" ? 5 : 3;
}
}
static readonly fields are initialized once, either inline or in a static constructor, the first time the type is used, and after that, every reader gets the same shared value.
If I’d written MaxRetries this way from the start, bumping the value in a new library release would have worked exactly the way I originally assumed const would.
The one-sentence version: const is a value the compiler copies into your callers forever, readonly is a value each instance locks in at construction, and static readonly is a value the whole type locks in once, and that difference in when the value is resolved is exactly what determines whether your API can safely change its mind later.
Summary
The one-sentence version: const is a value the compiler copies into your callers forever, readonly is a value each instance locks in at construction, and static readonly is a value the whole type locks in once, and that difference in when the value is resolved is exactly what determines whether your API can safely change its mind later.

Join the conversation! Your thoughts help the community grow.