Skip to content

Enum Persistence — String by Default, Int on Opt-Out

Domain enums are persisted as varchar columns by default. The convention is applied automatically by ApplyGranitConventions — no per-property HasConversion<string>() calls. When you need raw integer storage (bitmasks, hot tables, pre-existing schemas), opt out with [PersistAsInt].

Default-int persistence makes operational data unreadable and refactors silently dangerous. A status = 3 row leaves the on-call engineer guessing which version of the enum was deployed when the row was written. Reordering enum members — a routine refactor in C# — corrupts every existing row.

| Aspect | varchar (default) | integer (opt-out) | Postgres native enum | |--------|---------------------|---------------------|------------------------| | Ops readability | 'Queued' in psql | 0 requires reverse mapping | 'Queued' | | Refactor safety | Reordering members is a no-op | Reordering corrupts data silently | Reordering requires ALTER TYPE | | Storage cost | +20–40 % vs. int | Smallest | Smallest | | Sort order | Alphabetical (rarely meaningful) | Declaration order | Declaration order | | Wire symmetry | Matches JsonStringEnumConverter 1:1 | Wire is string, DB is int — two mental models | Matches the wire | | Migration ergonomics | One ALTER COLUMN with USING CASE | N/A (default) | ALTER TYPE ... ADD VALUE is non-transactional | | Provider portability | Same SQL on Postgres and SQL Server | Same | Postgres-only — lock-in |

Granit picks string as the default because the readability + refactor-safety gains dominate the storage overhead at typical enum cardinalities (4–20 members, 6–16-byte names).

ApplyGranitConventions scans every entity type and, for each enum property:

  1. Skips the property if a ValueConverter is already configured (your explicit override wins).
  2. Skips if the property carries [PersistAsInt].
  3. Skips if the enum type is decorated with [Flags].
  4. Otherwise injects EnumToStringConverter<TEnum> and sets MaxLength = max(20, longestMemberName + 4) — leaving headroom for future members.

Nullable<TEnum> properties are detected via Nullable.GetUnderlyingType and persisted as nullable varchar(N).

public class OrderConfiguration : IEntityTypeConfiguration<Order>
{
public void Configure(EntityTypeBuilder<Order> builder)
{
builder.HasKey(o => o.Id);
// No HasConversion / HasMaxLength needed — the convention covers it.
builder.Property(o => o.Status); // → varchar(20)
builder.Property(o => o.Priority); // → varchar(20), nullable
builder.Property(o => o.AuditFlags); // [Flags] enum → integer
}
}

PersistAsIntAttribute lives in Granit.Persistence. Apply it to the entity property when string storage is the wrong choice. Document the reason in an XML comment — opt-outs should be auditable.

public class UserPresence : Entity<Guid>
{
/// <summary>
/// Manual status persisted as int — this is a write-heavy presence table
/// updated on every heartbeat, and the storage delta matters at fleet scale.
/// </summary>
[PersistAsInt]
public ManualStatus? ManualStatus { get; set; }
}

Three legitimate reasons to opt out:

  1. [Flags] bitmask semantics — composition requires integer arithmetic. The convention already auto-skips [Flags]; [PersistAsInt] makes the choice explicit when you also want to disable the convention’s other safety checks.
  2. Hot tables where 4 bytes matter — write-heavy ledgers, telemetry, presence. Measure first; this is a real but narrow win.
  3. Pre-existing DB contracts — a customer-owned database column that cannot be migrated. Document the integration in the XML comment so the constraint travels with the code.

Postgres has a first-class CREATE TYPE ... AS ENUM (...) construct. Granit deliberately does not use it:

  • Provider lock-in. Granit ships SQL Server and Postgres providers from the same *Configuration.cs. Native enums require Postgres-specific mapping and break the symmetry.
  • Migration rigidity. Adding a value is ALTER TYPE ... ADD VALUE, which cannot run inside a transaction on most versions. Renaming or removing a value requires creating a new type and rewriting every dependent column.
  • Multi-DataSource wiring. Npgsql requires every enum to be registered on the NpgsqlDataSource builder before connection — a global registration that is awkward in a multi-module framework where modules ship their own enums independently.

The varchar approach loses 20–40 % on storage and gains a single mental model across providers, transactional migrations, and zero global wiring.

Apps consuming Granit that were running before the convention shipped already have integer columns. The migration to varchar is one-shot per consuming app.

Terminal window
dotnet ef migrations add EnumPersistenceAsString \
--context HostDbContext \
--project src/MyApp.Infrastructure \
--startup-project src/MyApp.Host

Granit.Persistence.EntityFrameworkCore ships a helper that emits the correct CASE expression from the enum members at compile time:

using Granit.Persistence.EntityFrameworkCore.Migrations;
public partial class EnumPersistenceAsString : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterEnumColumnIntToString<ExportJobStatus>(
table: "data_exchange_export_jobs",
column: "status",
maxLength: 20);
migrationBuilder.AlterEnumColumnIntToString<ScheduledActionStatus>(
table: "scheduling_scheduled_actions",
column: "status",
maxLength: 24);
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterEnumColumnStringToInt<ExportJobStatus>(
table: "data_exchange_export_jobs",
column: "status");
migrationBuilder.AlterEnumColumnStringToInt<ScheduledActionStatus>(
table: "scheduling_scheduled_actions",
column: "status");
}
}

Under the hood, AlterEnumColumnIntToString<TEnum> emits:

ALTER TABLE data_exchange_export_jobs
ALTER COLUMN status TYPE varchar(20)
USING CASE status
WHEN 0 THEN 'Queued'
WHEN 1 THEN 'Exporting'
WHEN 2 THEN 'Completed'
WHEN 3 THEN 'Failed'
END;

The Down helper emits the symmetric mapping from string back to int.

Bonus — partial indexes after the migration

Section titled “Bonus — partial indexes after the migration”

Once the column is varchar, predicate-style filtering becomes readable and indexable in one step:

CREATE INDEX ix_export_jobs_active
ON data_exchange_export_jobs (tenant_id, created_at)
WHERE status IN ('Queued', 'Exporting');

A partial index on the “active” subset typically shrinks the index by 10–100x versus a full index on a status that is mostly historical.

The wire format is decided by JsonStringEnumConverter, registered globally in GranitHostBuilderExtensions. Enums travel as PascalCase strings on the wire whether the DB stores them as varchar or integer — string DB storage just makes wire and storage agree.

The recommended TypeScript shape is a union of string literals, not a numeric enum:

// Good — matches the wire exactly
export type ExportJobStatus = "Queued" | "Exporting" | "Completed" | "Failed";
// Optional companion constant for autocomplete
export const ExportJobStatus = {
Queued: "Queued",
Exporting: "Exporting",
Completed: "Completed",
Failed: "Failed",
} as const satisfies Record<string, ExportJobStatus>;

See HTTP Conventions §JSON conventions for the wire-format rules and Dashboards § enum strings for a worked example.

Granit.ArchitectureTests ships EnumPersistenceConventionTests as a defence-in-depth check. The test reflects over every loaded IEntityTypeConfiguration<>, collects enum properties, and asserts that one of the following holds:

  • The resolved ValueConverter is EnumToStringConverter<TEnum> (convention applied), or
  • The property carries [PersistAsInt] (documented opt-out), or
  • The enum type carries [Flags] (auto-skipped by the convention).

Anything else fails the build with a message naming the offending property. The convention does the work; the archi-test catches the rare cases where a module explicitly bypasses the convention and forgets to document why.