ADR-058: JSON Persistence Policy
Date: 2026-05-21 Authors: Jean-Francois Meyers Scope:
granit-dotnet— all modules persisting JSON-shaped data via EF Core
Context
Section titled “Context”Granit persists JSON-shaped payloads in many places: notification metadata, export presets, import column mappings, scheduled job payloads, BFF tokens, mergeable idempotency caches, audit change records, etc.
Historically each module reinvented the same shape: a string ...Json column on an
entity plus a hand-rolled JsonSerializer.Serialize / Deserialize round-trip in a
store or interceptor. A repo-wide audit surfaced 13 such sites across 8 modules,
none of them queryable, all of them duplicating the same plumbing.
EF Core 8+ supports JSON natively in three flavours:
HasJsonConversion(Granit helper) — applies aJsonSerializer-backedValueConverter+ a deep-equalityValueComparerto any property; the column stays a string at the relational level (texton Npgsql,nvarchar(max)on SQL Server) unless an explicit column type is set.- Owned types via
.ToJson()— EF maps a child entity / collection of entities to a single JSON document column. Npgsql stores this asjsonbby default. - Primitive collections (
PrimitiveCollection(...), EF 8+) —List<string>,List<int>, etc. are persisted as JSON arrays natively, no helper required.
The provider matters too: PostgreSQL jsonb is queryable (@>, jsonb_path_query)
and indexable (GIN), while text is opaque. SQL Server nvarchar(max) is opaque
either way.
Decision
Section titled “Decision”The framework defines a default-first policy that modules should follow when adding a new JSON-shaped column.
Default — owned types or primitive collections
Section titled “Default — owned types or primitive collections”When the JSON payload has a known, stable shape, model it as owned types or primitive collections:
// Collection of recordsbuilder.OwnsMany(e => e.Mappings, m =>{ m.ToJson(); m.Property(p => p.SourceColumn); m.Property(p => p.TargetProperty); m.Property(p => p.Confidence);});
// Collection of primitivesbuilder.PrimitiveCollection(e => e.Fields).IsRequired();
// Single owned recordbuilder.OwnsOne(e => e.Report).ToJson();This is the default for any new entity persisting JSON. It is queryable on
PostgreSQL (jsonb by default with Npgsql), composable in LINQ, and avoids any
custom serialization layer.
Fallback — HasJsonConversion for typed-but-string columns
Section titled “Fallback — HasJsonConversion for typed-but-string columns”When the payload is JSON-serializable but cannot be modelled as owned types —
for example a JsonElement of dynamic shape, or a small Dictionary<string, string> whose contents do not warrant a sub-table — use the framework helper:
builder.Property(e => e.Data).HasJsonConversion();The helper, defined in Granit.Persistence.EntityFrameworkCore, gives all sites
the same ValueConverter + deep-equality ValueComparer policy. It stamps a
Granit:JsonSerialized=true annotation on the property so PostgreSQL apps can
opt in to jsonb storage in a single declarative call:
protected override void OnModelCreating(ModelBuilder modelBuilder){ modelBuilder.ApplyGranitConventions(...); modelBuilder.UseGranitJsonbForJsonProperties(); // app opt-in, only when on Npgsql}Modules MUST NOT ship their own HasColumnType("jsonb") — that would break
SQL Server consumers. Provider-specific column types belong in the app, or in the
provider-specific helper from Granit.Persistence.EntityFrameworkCore.Postgres
(HasJsonbConversion).
Last resort — custom ValueConverter
Section titled “Last resort — custom ValueConverter”A hand-rolled ValueConverter is acceptable only when none of the helpers fit:
- The payload is encrypted then serialized (or signed with an HMAC). Examples:
BffSessionEntity.SerializedTokens,MergeIdempotencyEntry.ResultJson. The converter wraps the string-encryption pipeline. - The serialization layer is transport, not storage. Example:
CursorEncoder— the cursor is an opaque HMAC-signed token that traverses the client; it is not a database column.
These cases must include a comment explaining why the standard helpers do not apply.
Consequences
Section titled “Consequences”Positive
Section titled “Positive”- One migration point when EF 11 ships its native JSON-mapping improvements — no scattered converter sites to chase.
- Postgres apps can flip to
jsonbstorage in one line; the gain (queryability, indexability) is automatic for every property already going throughHasJsonConversion. - New modules pick the right primitive by default; reviewers reject hand-rolled
JsonSerializer.Serializein entity setters / store methods. - Domain methods can take typed inputs (
IReadOnlyList<ImportColumnMapping>) instead ofstring, removing a class of bugs around malformed payloads at the domain boundary.
Negative
Section titled “Negative”- Owned-type
.ToJson()mappings cannot be queried with arbitrary LINQ on every provider — Postgresjsonbis rich, SQL Server JSON support is more limited. Modules that need cross-provider portability should design queries against the primitive fields, not against deeply nested JSON. - The deep-equality
ValueComparerinHasJsonConversionre-serializes on every change-tracking check. Hot-path entities with large JSON payloads may want a hand-rolled comparer.
Neutral
Section titled “Neutral”- The choice between
OwnsOne(...).ToJson()andHasJsonConversion<MyRecord>is up to the module: owned types win on queryability, the converter wins on flexibility (works withJsonElement, dynamic shapes).
Compliance audit (2026-05)
Section titled “Compliance audit (2026-05)”Initial audit triaged 13 historical sites:
| Status | Sites |
|--------|-------|
| ✅ Migrated to helpers | Notifications.UserNotification.Data, Privacy.ExportRequest.MissingProviders, DataExchange.SavedMappingEntity.Mappings, DataExchange.ExportPresetEntity.Fields |
| 📋 Tracked follow-up | DataExchange.ImportJob.MappingsJson + ReportJson, DataExchange.ExportJob.RequestJson — issue #2181 |
| 🟢 Quick win pending | Persistence.IHasMetadata.MetadataJson — issue #2184 |
| 🟡 Defer (encryption / dynamic) | Bff.SerializedTokens, EntityMerge.ResultJson, Auditing.ChangeTracking, Scheduling.PayloadJson — issue #2184 |
| 🔴 Non-migrable | QueryEngine.CursorEncoder — opaque transport token |
References
Section titled “References”- PR #2175 —
HasJsonConversionhelper - PR #2176 —
HasJsonbConversionPostgres companion - PR #2180 —
SavedMappingEntity+ExportPresetEntitymigration - PR #2183 —
UseGranitJsonbForJsonPropertiesconvention - Issue #2181 —
ImportJob/ExportJobmigration follow-up - Issue #2184 — Audit triage of 6 remaining sites