Target platform: C# Corner Audience: .NET developers, solution architects, and release engineers who ship commercial or licensed desktop/server software Prerequisites: Working knowledge of C#, reflection, and at least one serializer (System.Text.Json, Newtonsoft.Json, or XmlSerializer)
Why this topic keeps coming back?
Every few months someone posts the same question on a .NET forum: "I obfuscated my app and now my API calls return empty objects." The symptom is always the same — no crash, no exception, just silently null properties or 0 values in objects that were previously populated correctly. The developer assumes the obfuscator is broken. It isn't. The obfuscator did exactly what it was told to do: it renamed everything.
Serialization is one of the few places in .NET where the name of a symbol is part of your application's public contract. If you rename a property called CustomerName to a, the JSON serializer will happily go looking for a key called "a" in the incoming payload. The payload doesn't contain "a". The property stays null. No exception is thrown because, from the serializer's perspective, the field is simply optional.
This article walks through the underlying mechanics, the failure modes, and a production-ready strategy for protecting serialization-heavy assemblies without breaking them.
The core mechanism: reflection is the bridge
All three mainstream .NET serializers use reflection to discover what to read and write.
When System.Text.Json deserializes an object, it walks the type's properties at runtime. For each property it needs a name. That name comes from one of two places, in priority order:
- An explicit contract attribute — [JsonPropertyName("customer_id")], [JsonProperty("customer_id")], [XmlElement("CustomerID")], [DataMember(Name = "...")].
- The CLR metadata name — the actual symbol name stored in the assembly's metadata tables.
The first source is a string literal baked into the attribute blob. The second is the symbol itself, and it's exactly what an obfuscator rewrites.
This distinction is the entire foundation of the safe obfuscation strategy:
- Properties with explicit name attributes are safe to rename. The attribute string survives renaming, and the serializer reads the string, not the symbol.
- Properties without explicit name attributes are not safe to rename. The serializer reads the symbol, and the symbol no longer matches the payload key.
It really is that simple. Everything else in this article is a consequence of that rule.
A concrete example
Consider this DTO, which a hypothetical order service deserializes from an external partner API:
public class PartnerOrderDto
{
[JsonPropertyName("order_ref")]
public string OrderReference { get; set; }
[JsonPropertyName("customer_name")]
public string CustomerName { get; set; }
public decimal TotalAmount { get; set; }
public string CurrencyCode { get; set; }
}
Now compare the incoming JSON:
{
"order_ref": "PO-99182",
"customer_name": "Northwind Traders",
"TotalAmount": 1450.00,
"CurrencyCode": "USD"
}
Two of the four properties have explicit attributes. Two rely on their symbol names. If you obfuscate this assembly with default settings:
- OrderReference and CustomerName will be renamed, but the serializer still reads "order_ref" and "customer_name" from the attributes. ✅ These bind correctly.
- TotalAmount and CurrencyCode will be renamed to something like a and b. The serializer now looks for "a" and "b" in the payload. ❌ These return 0 and null.
The bug is invisible in unit tests that only exercise the two attributed properties. It surfaces only in production, against real partner traffic.
The polymorphic problem
Polymorphic deserialization is where even experienced developers get caught out, because the failure mode is different: instead of silent data loss, you get a hard exception.
System.Text.Json
System.Text.Json handles polymorphism through [JsonPolymorphic] and [JsonDerivedType]:
[JsonPolymorphic(TypeDiscriminatorPropertyName = "$type")]
[JsonDerivedType(typeof(StandardAccount), "standard")]
[JsonDerivedType(typeof(PremiumAccount), "premium")]
public abstract class AccountBase
{
public string AccountId { get; set; }
}
Here the discriminators "standard" and "premium" are explicit string literals. They survive obfuscation. But the class names StandardAccount and PremiumAccount are not used as discriminators in this configuration — they're only used as typeof() arguments, which are resolved at compile time and encoded as metadata tokens. If the obfuscator renames the classes, the typeof() references are updated consistently, so this works.
The failure case is when you don't specify explicit discriminators and rely on the default, which uses the type's full name. Then renaming the class breaks the resolution and you get a JsonException at runtime.
Newtonsoft.Json $type
Newtonsoft's TypeNameHandling is more dangerous. With TypeNameHandling.All or TypeNameHandling.Objects, the serializer writes the assembly-qualified type name into a $type property in the JSON itself:
{
"$type": "MyApp.Contracts.PremiumAccount, MyApp.Contracts",
"AccountId": "ACC-001"
}
This means the class name and the assembly name are part of the wire format. Renaming either one breaks deserialization for any payload produced by an older, un-obfuscated build — including payloads from other services that haven't been updated. This is the scenario where people typically abandon obfuscation entirely.
The correct fix is to implement a custom SerializationBinder that maps stable string IDs to types, and to configure the serializer to use that binder on both ends. Once the binder is in place, the $type value is no longer a raw type name — it's a key the binder understands — and the type itself can be freely renamed.
XmlSerializer
XmlSerializer deserves its own warning. When you construct an XmlSerializer at runtime, it generates a temporary assembly that contains the serialization logic. That generated assembly is built from reflection data, including type names. If you rename a type that appears inside an [XmlInclude] attribute, the generated serializer will look for a type that no longer exists under that name and throw InvalidOperationException during construction — often before any data is read.
[XmlInclude] is the specific thing to watch for:
[XmlInclude(typeof(StandardAccount))]
[XmlInclude(typeof(PremiumAccount))]
public abstract class AccountBase { ... }
The typeof() references are rewritten consistently by a good obfuscator, so this usually works. What breaks is when the XML itself carries type names — for example, when you serialize an abstract-typed collection and the runtime emits xsi:type attributes. Those are written using the CLR type name, and if that name changes, round-trip serialization between old and new builds fails.
A strategy that works
The strategy that holds up across all three serializers has three parts.
1. Make contracts explicit
Go through every DTO and add explicit name attributes to every property that participates in serialization. This is tedious, but it converts the problem from "the obfuscator must know which properties are serialized" into "the compiler knows, and the obfuscator doesn't need to."
public class PartnerOrderDto
{
[JsonPropertyName("order_ref")]
[JsonProperty("order_ref")]
[XmlElement("OrderRef")]
public string OrderReference { get; set; }
[JsonPropertyName("customer_name")]
[JsonProperty("customer_name")]
[XmlElement("CustomerName")]
public string CustomerName { get; set; }
[JsonPropertyName("total_amount")]
[JsonProperty("total_amount")]
[XmlElement("TotalAmount")]
public decimal TotalAmount { get; set; }
[JsonPropertyName("currency_code")]
[JsonProperty("currency_code")]
[XmlElement("CurrencyCode")]
public string CurrencyCode { get; set; }
}
Once this is done, all four properties can be renamed safely. The obfuscator scrambles the C# symbols; the serializers read the attribute strings.
The cost is verbosity. The benefit is that obfuscation becomes a no-op with respect to serialization correctness.
2. Isolate contracts into their own namespace
If you can't (or won't) attribute every property, the next best thing is to put all DTOs into a dedicated namespace — MyCompany.Contracts, for example — and configure the obfuscator to skip that namespace entirely.
This is a coarser instrument than attribute-based exclusion, but it's easy to reason about and easy to audit: anything in MyCompany.Contracts is a serialization contract and is never renamed; anything outside it is fair game.
The downside is that the contract namespace itself isn't protected. A competitor can still read your DTO shapes. But DTOs are rarely the valuable IP — the business logic that consumes them is. And that logic lives in a different namespace.
3. Protect the engine, not the contract
This is the key insight that makes the whole approach viable: excluding DTOs from renaming does not meaningfully reduce your protection.
The DTOs are the interface. They're designed to be seen — they're documented in your API reference, they're visible in every network trace, and they're usually derived from a published schema. There's nothing to protect.
The valuable parts of the assembly are:
- The business logic that processes deserialized data.
- The algorithms that compute results.
- The string literals that contain API keys, connection strings, and internal endpoints.
- The control flow that implements proprietary decision-making.
All of these can receive full obfuscation — identifier scrambling, control-flow flattening, string encryption — without touching the contract surface. A competitor who decompiles your assembly will see a clean, readable PartnerOrderDto class sitting next to an incomprehensible engine class full of state-machine dispatch tables and encrypted strings. That's the desired outcome.
Testing: what to actually verify
Obfuscation bugs are almost always runtime bugs, so unit tests that run against unobfuscated code will not catch them. You need to test the obfuscated artifact.
A minimal but effective test suite has three components:
Round-trip tests. Populate every DTO with distinctive values, serialize to JSON and XML, deserialize back, and assert deep equality. Run this against the obfuscated build, not the debug build.
Contract fixtures. Save real JSON and XML payloads from your external partners (or from your own API spec) as test fixtures. Deserialize them against the obfuscated build and assert that every expected property is populated. This catches the silent-null failure mode that round-trip tests miss, because round-trip tests use your own serialized output as input, which will always match whatever the obfuscator produced.
Polymorphic dispatch tests. Explicitly construct payloads with each discriminator value and verify that the correct concrete type is instantiated and that its derived properties are populated. This catches the exception-based failures.
Run all three in CI against the obfuscated artifact before it's promoted to release.
A quick reference: symptom → cause → fix
|
Symptom
|
Likely cause
|
Fix
|
|
Property is null or default after deserialization, no exception
|
Un-attributed property was renamed
|
Add explicit name attribute, or exclude DTO from renaming
|
|
JsonException: Could not resolve polymorphic type
|
Class name was renamed and no explicit discriminator was set
|
Add explicit discriminator string via [JsonDerivedType]
|
|
InvalidOperationException during XmlSerializer construction
|
Renamed type referenced in [XmlInclude] or xsi:type
|
Exclude the type from renaming
|
|
Newtonsoft $type deserialization fails across builds
|
Assembly-qualified type name embedded in payload was mutated
|
Implement a custom SerializationBinder with stable IDs
|
|
Works in debug, fails in release
|
Obfuscation applied only in release configuration
|
Add obfuscated artifact to CI test matrix
|
Closing thoughts
The tension between obfuscation and serialization is real, but it's narrower than it first appears. It only affects the names of serialized symbols — not their behavior, not their types, and not the logic that uses them. Once you internalize the rule that "explicit attribute strings survive renaming, implicit symbol names do not," the correct configuration becomes obvious. Attribute your contracts, isolate them if you can't attribute them, and let the obfuscator do its real work on the parts of the assembly that actually contain intellectual property.
The result is an assembly that decompiles to a readable contract surface surrounded by unreadable implementation — which is exactly what a well-protected .NET application should look like.
*The author works on the Rustemsoft team and contributes to the Opaquer .NET Obfuscator documentation. Feedback and corrections are welcome in the comm
HostForLIFE.eu ASP.NET Core 11.0 Hosting
European best, cheap and reliable ASP.NET hosting with instant activation. HostForLIFE.eu is #1 Recommended Windows and ASP.NET hosting in European Continent. With 99.99% Uptime Guaranteed of Relibility, Stability and Performace. HostForLIFE.eu security team is constantly monitoring the entire network for unusual behaviour. We deliver hosting solution including Shared hosting, Cloud hosting, Reseller hosting, Dedicated Servers, and IT as Service for companies of all size.
