# Obfuscating an Entity Framework Core app without breaking your data layer


Entity Framework Core is a mapping layer, and the thing it maps *from*, by default, is your names. Call a class `Invoice` with a property `Status` and — with no configuration at all — EF decides there is a table `Invoices` with a column `Status`, because convention derives the schema from the CLR identifiers. That convention is wonderful for productivity and a landmine for obfuscation. The moment [Nebula.NET](https://delta1labs.com/products/nebula) renames `Invoice` to `a` and `Status` to `b`, the convention faithfully concludes your database has a table `a` with a column `b`, and nothing matches reality anymore.

The usual reaction is to exclude the whole data layer from obfuscation. That works and it is a bad trade: your entities and your `DbContext` are often the clearest description of your domain you ship, and you have just left them in plaintext to protect a database schema that was never the sensitive part. There is a precise fix instead. Find every place EF reads a CLR name at runtime, make the schema *explicit* so it stops deriving from those names, and then let Nebula rename the members. This post is that inventory and those patterns.

## Where EF actually reads a name

Renaming is safe exactly where EF does not depend on the original identifier at runtime, and unsafe where it does. There are four places it does:

1. **Convention-based table and column names.** With no explicit mapping, the table name is the `DbSet` property name (or the type name) and each column is the property name. Rename the member and the derived schema name moves with it.
2. **String-based member access.** `EF.Property<T>("Status")`, `entry.Property("Status")`, and shadow-property lookups resolve a member *by string*. A string does not participate in renaming, so after obfuscation it names a member that no longer exists and throws.
3. **Migrations.** A generated migration and the model snapshot contain the schema names as string literals. If those were produced from convention, they encode the *original* names; after a rename the running model derives *different* names and the two disagree.
4. **Anything reflecting over your model by name** — a few serializers, auditing shims, or a provider feature that keys off navigation-property names.

LINQ, notably, is *not* on this list. `Where(i => i.Status == open)` compiles to an expression tree holding a `MemberInfo`, not the string `"Status"`. When Nebula renames the property, that `MemberInfo` points at the renamed member, and EF translates it through your mapping. Consistent renaming — which Nebula guarantees across the whole assembly — keeps every compiled query intact. The breakage is always about *names resolved from strings*, never about typed member access.

<figure class="my-8"><svg viewBox="0 0 680 220" role="img" aria-label="Two mapping styles. With convention, the column name is derived from the CLR property name, so renaming the property renames the column EF expects and the schema no longer matches. With explicit mapping via HasColumnName, the column name is a fixed string literal, so the CLR property can be renamed freely while the column stays Status." style="width:100%;height:auto"><defs><style>.clr{fill:#eef4ff;stroke:#8eb4ff}.col{fill:#e6fffb;stroke:#2dd4bf}.bad{fill:#fee2e2;stroke:#f87171}.t{font:600 12px system-ui;fill:#1547e0}.x{font:11px system-ui;fill:#475569}.m{font:600 11px ui-monospace,monospace;fill:#0f172a}.ar{stroke:#94a3b8;stroke-width:2;fill:none}.arb{stroke:#f87171;stroke-width:2;fill:none}.arg{stroke:#2dd4bf;stroke-width:2;fill:none}</style><marker id="g1" markerWidth="8" markerHeight="8" refX="6" refY="3" orient="auto"><path d="M0 0l6 3-6 3z" fill="#f87171"/></marker><marker id="g2" markerWidth="8" markerHeight="8" refX="6" refY="3" orient="auto"><path d="M0 0l6 3-6 3z" fill="#2dd4bf"/></marker></defs><text class="t" x="16" y="24">Convention: schema derived from CLR name</text><rect class="clr" x="16" y="36" width="150" height="40" rx="5"/><text class="m" x="28" y="61">Status → (renamed) b</text><path class="arb" d="M166 56 h60" marker-end="url(#g1)"/><text class="x" x="172" y="48">derives</text><rect class="bad" x="236" y="36" width="160" height="40" rx="5"/><text class="m" x="248" y="61">column "b"  ✗ no match</text><text class="x" x="420" y="60">DB still has column "Status"</text><text class="t" x="16" y="128">Explicit: HasColumnName pins the schema</text><rect class="clr" x="16" y="140" width="150" height="40" rx="5"/><text class="m" x="28" y="165">Status → (renamed) b</text><path class="ar" d="M166 160 h60" marker-end="url(#g2)"/><text class="x" x="170" y="152">free rename</text><rect class="col" x="236" y="140" width="160" height="40" rx="5"/><text class="m" x="248" y="165">HasColumnName("Status")</text><path class="arg" d="M396 160 h40" marker-end="url(#g2)"/><rect class="col" x="446" y="140" width="150" height="40" rx="5"/><text class="m" x="458" y="165">column "Status" ✓</text><text class="x" x="16" y="206">The fixed string literal is encrypted by Nebula but never renamed — so the member name and the column name are decoupled.</text></svg></figure>

## Pin the schema, then obfuscate freely

The entire strategy is one idea: *decouple the schema name from the CLR name*. Once every table, column, key and index name is a string literal in your configuration, the CLR identifiers carry no schema meaning, and Nebula can rename them to `a.b` with no effect on the database.

```csharp
protected override void OnModelCreating(ModelBuilder model)
{
    model.Entity<Invoice>(e =>
    {
        e.ToTable("Invoices");                 // table name is now a literal
        e.HasKey(x => x.Id);
        e.Property(x => x.Id).HasColumnName("Id");
        e.Property(x => x.Status).HasColumnName("Status");
        e.Property(x => x.Total).HasColumnName("Total").HasPrecision(18, 2);
        e.HasIndex(x => x.Status).HasDatabaseName("IX_Invoices_Status");

        // Navigation + FK column, pinned the same way.
        e.HasMany(x => x.Lines)
         .WithOne(l => l.Invoice)
         .HasForeignKey(l => l.InvoiceId)
         .HasConstraintName("FK_InvoiceLines_Invoices");
    });
}
```

Those string literals — `"Invoices"`, `"Status"`, `"IX_Invoices_Status"` — are exactly the kind of thing Nebula's string encryption protects in the output, but they are never *renamed*, because they are data, not identifiers. The `x => x.Status` lambdas are typed member access, so they ride the rename automatically. The net result: `Invoice.Status` can become `a.b` in the shipped binary while the SQL EF emits still reads and writes the `Status` column.

Data annotations achieve the same decoupling if you prefer them on the type:

```csharp
[Table("Invoices")]
public class Invoice
{
    [Column("Id")]     public int Id { get; set; }
    [Column("Status")] public InvoiceStatus Status { get; set; }
    [Column("Total")]  public decimal Total { get; set; }
}
```

Either way, the obfuscation rule in `nebula.json` is simply that your data layer is *not* special — it obfuscates with everything else:

```json
{
  "preservePublicApi": true,
  "encryptStrings": true,
  "controlFlowObfuscation": true,
  "renaming": {
    "rules": [
      { "pattern": "MyApp.Data.*", "action": "rename" }
    ]
  }
}
```

No blanket exclusion for `MyApp.Data.*`. The entities get renamed; the schema is pinned in configuration; the two no longer touch.

## Kill the string-based access

The patterns that genuinely break are the ones that resolve a member from a string, because a string never follows a rename. Find them and move to the typed form.

```csharp
// ✗ Breaks: "Status" names a CLR member that obfuscation renamed to "b".
var q1 = db.Invoices.Where(i => EF.Property<InvoiceStatus>(i, "Status") == open);

// ✓ Typed member access — compiles to a MemberInfo that follows the rename.
var q2 = db.Invoices.Where(i => i.Status == open);
```

Shadow properties are the one case where a string is legitimate — the member does not exist on the CLR type at all — so there is nothing to rename and nothing to break. Declare them explicitly and access them by their *schema* name, which you control:

```csharp
model.Entity<Invoice>().Property<DateTime>("LastModifiedUtc");   // shadow, no CLR member
// ...
var ts = db.Entry(invoice).Property("LastModifiedUtc").CurrentValue;  // safe: not a CLR name
```

Raw SQL is the other place to audit. A raw query must name the *column* (`Status`), never the CLR property — and since you pinned the column name to a literal, those line up permanently regardless of obfuscation:

```csharp
// Names the column, which is fixed — safe under any rename.
var rows = db.Invoices.FromSqlInterpolated(
    $"SELECT * FROM Invoices WHERE Status = {(int)open}");
```

## Migrations: generate against the mapped model

Migrations encode schema names as literals, so they are safe *if those literals are your pinned names*. They become a hazard only when the snapshot was generated from convention and therefore captured CLR-derived names that later move. Two rules keep migrations clean:

- **Generate migrations from the non-obfuscated build.** The design-time tooling reflects over your model to produce the migration and the snapshot; run it against the normal `Debug` assembly, never the obfuscated artifact. The migration you commit then contains your explicit schema literals.
- **Never put `nameof(SomeProperty)` in a hand-edited migration.** `nameof` captures the CLR name at compile time, so after a rename it emits `"b"` where you meant the column `"Status"`. Write the column name as a literal.

```csharp
// In a migration — literal column names, matching your explicit mapping.
migrationBuilder.CreateIndex(
    name: "IX_Invoices_Status",
    table: "Invoices",
    column: "Status");          // literal, not nameof(Invoice.Status)
```

Because the obfuscated binary and the migration both speak in the same fixed schema vocabulary, `context.Database.Migrate()` at startup behaves identically whether or not the assembly was obfuscated.

## The rare surgical exclusion

Occasionally a specific feature still reflects over a CLR name at runtime — an auditing component that records the navigation-property name, or a provider extension that keys off it. When that happens, preserve *that member*, not the assembly. Nebula lets you opt a single member out with an attribute, which keeps the exclusion visible in the source where the dependency lives:

```csharp
public class Invoice
{
    // A downstream component reflects over this navigation's name at runtime.
    [Obfuscation(Feature = "renaming", Exclude = true)]
    public List<InvoiceLine> Lines { get; set; } = new();
}
```

That is the whole escape hatch — one property, documented by its own attribute — instead of a wildcard that spares your entire domain model. The discipline is worth it: the point of obfuscating a data-driven app is that the valuable logic is *in* the entities and the query layer, and a blanket exclusion protects none of it.

## Putting it together

EF Core breaks under obfuscation only where it resolves a name from a string at runtime, and every one of those places has a decoupled alternative. Pin tables, columns, keys and indexes with explicit mapping so the schema stops deriving from CLR identifiers; replace `EF.Property` and `nameof`-in-migrations with typed or literal forms; generate migrations from the clean build. Do that and your `MyApp.Data.*` types obfuscate right alongside the rest of the app — renamed, string-encrypted, control-flow-hardened — while the database sees exactly the schema it always has. You protect the domain model that was worth protecting, and you give up nothing to the mapping layer.

