Skip to content
← All posts
· Delta1 Labs ObfuscationNebula

Obfuscating a Dapper data-access layer without breaking column-to-property mapping

Dapper matches result-set column names to your POCO's public members by name, case-insensitively, at materialization time. Rename those members with an obfuscator and Dapper silently stops matching — no exception, just a property left at null or 0. This is the full picture: exactly how Dapper's default type handler binds a column to a member, why your SQL text is usually safe but your POCO members are fragile, the custom-map escape hatches, and the precise nebula.config.json keep-list that pins the query-result POCOs while renaming the rest of the data layer.

A micro-ORM like Dapper earns its speed by doing very little magic, but the one piece of magic it does keep is the piece that collides with obfuscation. When you write connection.Query<Customer>("SELECT Id, FullName, Email FROM Customers"), Dapper executes the command, walks the result set, and for each row builds a Customer by matching each column name in the reader to a public member on Customer whose name matches, ignoring case. That match is done by string comparison against the member’s name at runtime. Rename Customer.FullName to a with an obfuscator and the FullName column has nothing to bind to — so Dapper leaves the property at its default and moves on. No exception. No log line. Just a Customer whose FullName is null.

That silence is what makes this worth a careful post. A broken LINQ provider throws; a broken Dapper mapping returns a plausible-looking object with holes in it, and you find out in production. The good news is that the fragile surface is small and precisely identifiable, so you do not need the usual blunt reaction of excluding your whole data layer. This is the inventory of what Dapper reads by name, why the rest is safe, and the exact Nebula.NET keep-list that pins the fragile members while renaming everything else.

How Dapper binds a column to a member

Dapper’s default type handler, SqlMapper, builds a materializer for each (type, column-set) pair and caches it. The construction logic is the part that matters here. For a type T with a parameterless constructor, Dapper enumerates the reader’s columns and, for each one, looks for a settable target on T whose name equals the column name under an ordinal-ignore-case comparison. It prefers a public settable property; failing that it will bind a public field. If it finds no member for a column, that column is simply skipped. If a member has no matching column, that member is left at whatever the constructor produced — null, 0, false, default.

Three consequences follow directly, and all three drive the keep-list:

  1. The match is by name, case-insensitively. FullName binds to a column FullName, fullname, or FULLNAME. It does not bind to a member renamed a. Case folding does not help you across a rename; only the exact (case-insensitive) name survives.
  2. Only public, settable members participate. Dapper writes through a public property setter or a public field. A private backing field is invisible to the default mapper, which is why obfuscators that rename private fields are usually harmless to Dapper and why it is specifically the public members you must pin.
  3. A missing match is silent. There is no strict mode in the default path. The absence of a binding is indistinguishable, at runtime, from a column that legitimately returned NULL.
Pinned: column name matches public property namecolumn "FullName"column "Email"name matchpublic string FullNamepublic string Emailbound ✓bound ✓Renamed properties: no member matches the columncolumn "FullName"column "Email"no matchpublic string apublic string bleft null ✗left 0 / null ✗Repository internals: no runtime name dependencyCustomerRepository, SqlBuilder, ConnectionFactory → renamed freely, no effect on mapping

Why the SQL is safe but the POCO is fragile

It is worth being precise about what the obfuscator does and does not touch, because the asymmetry is the whole reason this is manageable.

The SQL in connection.Query<Customer>("SELECT Id, FullName, Email FROM Customers") is a string literal. Nebula may encrypt that literal so it is not sitting in plaintext in your assembly, but encryption is transparent at runtime: the decrypted string handed to the database is byte-for-byte what you wrote. The database sees SELECT Id, FullName, Email and returns columns named Id, FullName, Email. String-literal column names in SQL therefore survive obfuscation intact — they were never identifiers in your IL, just data.

The POCO is the opposite. Customer.FullName is a real CLR member, an identifier in metadata, and renaming identifiers is exactly what an obfuscator does. So the two ends of Dapper’s match age differently under obfuscation: the column-name end (from SQL) is stable, and the member-name end (from the POCO) moves. The match that used to line up now does not. Pinning the POCO members re-aligns the two ends.

Here is the POCO and the call, written the way they should look in shipped code:

public sealed class Customer
{
    public int Id { get; set; }
    public string FullName { get; set; } = "";
    public string Email { get; set; } = "";
}

public sealed class CustomerRepository
{
    private readonly IDbConnection _db;
    public CustomerRepository(IDbConnection db) => _db = db;

    public Customer? GetById(int id) =>
        _db.QuerySingleOrDefault<Customer>(
            "SELECT Id, FullName, Email FROM Customers WHERE Id = @Id",
            new { Id = id });
}

Two names here are load-bearing at runtime, and nothing else is. The public members of Customer (Id, FullName, Email) are matched against result columns. The members of the anonymous parameter object new { Id = id } are matched against the @Id placeholder — Dapper reads the object’s Id property name and binds it to @Id, again by name. The class CustomerRepository, the field _db, the method GetById, the parameter id — none of those are resolved by name at runtime, so all of them can be renamed.

Parameter binding has the same name dependency

The parameter side is easy to forget because it is less visible. When you pass new { Id = id }, Dapper generates a parameter named Id from the member name and matches it to the @Id token in the SQL by name. Rename that member and the @Id placeholder has no value to bind, which does throw (a missing parameter is not silent the way a missing column is), but it is still a rename-induced break. For anonymous types this is usually a non-issue in practice, because the C# compiler’s anonymous-type property names are not something most obfuscators rename by default — but if you use a named parameter class, its public members are exactly as fragile as a result POCO and belong on the keep-list too.

The keep-list: pin the mapped members, rename the rest

Nebula’s rename pass is driven by nebula.config.json. The strategy is to turn renaming on globally and then carve out the small set of members Dapper resolves by name. The cleanest unit to pin is the POCO type (and its members); the repositories and helpers are left to the default rename.

{
  "input": "bin/Release/net8.0/Acme.Data.dll",
  "rename": {
    "enabled": true,
    "keep": [
      {
        "type": "Acme.Data.Models.Customer",
        "members": "public",
        "reason": "Dapper binds result columns to these public members by name"
      },
      {
        "namespace": "Acme.Data.Models",
        "kind": "type",
        "members": "public",
        "reason": "all query-result POCOs live here; pin their public surface"
      }
    ]
  },
  "strings": { "encrypt": true },
  "controlFlow": { "enabled": true }
}

The "members": "public" scope is the important detail: it preserves the public property and field names Dapper reads, while still allowing Nebula to rename private backing fields, which Dapper’s default mapper never touches. If you keep your POCOs in a dedicated namespace (Acme.Data.Models above), the namespace rule pins the whole family in one entry, so a new query result added later is covered without a config change.

If you use a named parameter class rather than anonymous types, pin it the same way:

{
  "type": "Acme.Data.Parameters.CustomerQuery",
  "members": "public",
  "reason": "Dapper binds @-parameters to these members by name"
}

Everything not named in keep is renamed. CustomerRepository, your connection factory, any internal SQL-building helper — their identifiers carry no runtime meaning to Dapper, so they become a, b, c and your protected assembly keeps its logic opaque while its data mapping keeps working.

The escape hatch: decouple names with a custom type map

If you would rather not keep any names — maximizing the rename surface even across your POCOs — Dapper gives you a way to make the column-to-member binding explicit, so it no longer depends on the member name at all. SqlMapper.SetTypeMap installs a per-type mapper, and CustomPropertyTypeMap lets you resolve each column to a member through a delegate. Drive that delegate from a [Column]-style attribute instead of the member name:

[AttributeUsage(AttributeTargets.Property | AttributeTargets.Field)]
public sealed class ColumnAttribute : Attribute
{
    public ColumnAttribute(string name) => Name = name;
    public string Name { get; }
}

public sealed class Customer
{
    [Column("Id")]       public int Id { get; set; }
    [Column("FullName")] public string FullName { get; set; } = "";
    [Column("Email")]    public string Email { get; set; } = "";
}

// Register once at startup, before the first query.
SqlMapper.SetTypeMap(typeof(Customer), new CustomPropertyTypeMap(
    typeof(Customer),
    (type, columnName) => type
        .GetProperties()
        .FirstOrDefault(p => p
            .GetCustomAttributes(typeof(ColumnAttribute), false)
            .Cast<ColumnAttribute>()
            .Any(a => string.Equals(a.Name, columnName,
                      StringComparison.OrdinalIgnoreCase)))));

Now the binding runs through the "FullName" string in the attribute, which is a literal Nebula encrypts but never renames — exactly like the SQL column name. The member it decorates is free: Nebula can rename FullName to a and the custom map still resolves the FullName column to it, because the resolution key is the attribute value, not the member identifier. Keep the attribute members used by reflection (ColumnAttribute.Name and the type) on the keep-list, since the delegate reads them, and you can drop the POCO property names from keep entirely.

This is the robust-versus-convenient trade. Keeping the member names (the first approach) is simpler, needs no code changes, and leaves a few public identifiers readable. The custom map hides even those identifiers but adds a registration you must not forget and a small reflective cost on first materialization. For most shipped code the keep-list is the right default; reach for the custom map when a specific POCO’s property names are themselves sensitive.

Running the protection and wiring it into the build

With the config in place, protecting the assembly is one command:

nebula protect --config nebula.config.json --verbose

Nebula reads the input assembly, applies renaming with your keep-list honored, encrypts strings, flattens control flow, and writes the protected assembly in place of the input (or to the output path if you set one). The --verbose flag prints the rename map summary, which is worth scanning once to confirm your POCO members show up as kept and your repository types show up as renamed.

To make it automatic, hook it after publish with an MSBuild target so every published build is protected without a manual step:

<Target Name="NebulaProtect" AfterTargets="Publish">
  <Exec Command="nebula protect --config $(MSBuildProjectDirectory)/nebula.config.json --input $(PublishDir)Acme.Data.dll"
        WorkingDirectory="$(MSBuildProjectDirectory)" />
</Target>

Because the target runs AfterTargets="Publish", it operates on the published output rather than the intermediate build, so your inner-loop debug builds stay fast and unobfuscated while release publishes ship protected binaries. (If you have a Nebula Pro license, control-flow and string protection run on the published output as configured; the free tier applies the rename pass, which is the part this post depends on.)

Verify with Glass before you ship

The last step is to confirm the rename actually did what the keep-list intended — that the fragile members survived and the rest did not. Open the protected assembly in Glass, the free .NET decompiler, and look at two things. First, your POCO: Customer should still show Id, FullName, Email as public properties with their original names — that is what proves Dapper will still bind the columns. Second, a repository: CustomerRepository and its members should appear renamed to short opaque identifiers, which proves the rest of the data layer was actually protected rather than accidentally swept into the keep-list.

If you see a POCO property renamed, Dapper will silently return nulls for it at runtime, so this decompiler pass is not optional — it is the cheapest possible substitute for the exception Dapper never throws. A quick round-trip — protect, decompile, eyeball the two shapes, then run an integration test that reads a row back and asserts every column populated — turns a class of silent production bugs into something you catch on the build that produced it.

The shape of the rule

Dapper’s contract with your code is a small, name-based one: result columns bind to public POCO members by name, and anonymous or named parameter members bind to @placeholders by name. Those are the only names it resolves at runtime, and they are the only names you must pin. Everything else in a data-access layer — the repositories, the connection management, the query construction — has no such dependency and is free to be renamed, string-encrypted, and control-flow-flattened like any other code you are protecting. Pin the mapped members in nebula.config.json, or decouple them with a custom type map, verify the result in Glass, and you ship a data layer that is opaque to a reverse engineer and transparent to Dapper.

Try Nebula.NET

Harden your .NET code in minutes — start with the free edition.