Skip to content

Entity Framework Core JSON property conversion

Cratis Arc’s JSON conversion stores a complex property or collection in one database column rather than mapping it to separate tables. Mark the property with [Json] and apply the conversion through BaseDbContext or ApplyJsonConversion; Arc serializes the value on save and deserializes it on load.

Arc adds a value converter and a value comparer to each marked property. The converter handles JSON storage; the comparer uses serialized values for equality and creates snapshots for EF change tracking. The default JSON options include converters for Cratis concepts, so domain value types can also appear inside the stored object.

The model and startup fragments below extend the host from getting started; they are not independent programs.

Use JSON conversion when the application loads and saves a property as a whole, such as customer preferences or a collection of addresses. You keep that data together without defining a table for each nested type.

The stored JSON is still a data contract. When you change an object’s shape or its converters, check how existing rows will deserialize and whether they need a data migration. If you need to filter or join on nested values, verify the provider’s query support before choosing JSON storage.

Your entity models can use complex properties by marking them with the [Json] attribute:

using Cratis.Arc.EntityFrameworkCore.Json;
public class Customer
{
public Guid Id { get; set; }
public string Name { get; set; }
[Json]
public IEnumerable<Address> Addresses { get; set; }
[Json]
public CustomerPreferences Preferences { get; set; }
}
public class Address
{
public string Street { get; set; }
public string City { get; set; }
public string Country { get; set; }
}

Here, Addresses and Preferences each occupy a JSON-converted column. Nested objects and collections use the same JSON options, including the default Cratis concept converters.

Arc selects PostgreSQL jsonb, SQL Server nvarchar(max), and SQLite TEXT. These are provider-specific column mappings, not equivalent server-side JSON query capabilities. A value converter alone does not translate arbitrary nested-property LINQ expressions into JSON operators.

The JSON options are separate from the plain serializer used by AsPoint(), AsLineString(), and AsPolygon(). Do not apply both conversion paths to one property; use the geometry storage-path matrix to choose a representation.

JsonConversionOptions starts with these converters:

Converter Handles
ComplexKeyDictionaryJsonConverterFactory Dictionaries with complex keys
PointJsonConverter, LineStringJsonConverter, PolygonJsonConverter Cratis geometry in GeoJSON format
ConceptAsJsonConverterFactory Cratis ConceptAs<T> value types
EnumerableConceptAsJsonConverterFactory IEnumerable<ConceptAs<T>> sequences
EnumConverterFactory Enum values (serialized as integers)
DateOnlyJsonConverter System.DateOnly
TimeOnlyJsonConverter System.TimeOnly
TypeJsonConverter System.Type
UriJsonConverter System.Uri
EnumerableModelWithIdToConceptOrPrimitiveEnumerableConverterFactory Enumerable model-with-id to concept/primitive
DerivedTypeJsonConverterFactory Added when IDerivedTypes is supplied; handles registered polymorphic types

If a property’s declared type does not tell JsonSerializer which concrete type to construct, supply a matching converter or polymorphic type configuration. Interface and abstract types can otherwise fail to deserialize with NotSupportedException. The example below uses a converter to read an interface-typed property back as a concrete record.

Register additional converters at startup through the JsonConverters list on EntityFrameworkCoreOptions. The converters are appended after the built-in Arc defaults:

builder.AddCratisArc(configureBuilder: arcBuilder =>
{
arcBuilder.WithEntityFrameworkCore(options =>
{
options.ConnectionString = "Data Source=store.db";
options.JsonConverters.Add(new MyRequestConverter());
});
});

With the converter registered, an entity with an interface-typed [Json] property works correctly:

public interface IMyRequest { int Count { get; } }
public sealed record DoThing(int Count) : IMyRequest;
public class MyEntity
{
[Key] public Guid Id { get; set; }
[Json] public required IMyRequest Request { get; set; }
}

Implement JsonConverter<T> where T is the interface or abstract type that needs to be handled:

using System.Text.Json;
using System.Text.Json.Serialization;
public class MyRequestConverter : JsonConverter<IMyRequest>
{
public override IMyRequest? Read(
ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
using var doc = JsonDocument.ParseValue(ref reader);
// Use a discriminator or well-known property to pick the concrete type
var count = doc.RootElement.GetProperty("count").GetInt32();
return new DoThing(count);
}
public override void Write(
Utf8JsonWriter writer, IMyRequest value, JsonSerializerOptions options)
{
writer.WriteStartObject();
writer.WriteNumber("count", value.Count);
writer.WriteEndObject();
}
}

If you’re not using BaseDbContext, apply JSON conversion in OnModelCreating. In addition to the JSON namespace below, import Cratis.Arc.EntityFrameworkCore and Microsoft.EntityFrameworkCore.

using Cratis.Arc.EntityFrameworkCore.Json;
public class StoreDbContext(DbContextOptions<StoreDbContext> options) : DbContext(options)
{
public DbSet<Customer> Customers => Set<Customer>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
var entityTypes = modelBuilder.Model.GetEntityTypes();
modelBuilder.ApplyJsonConversion(entityTypes, Database.GetDatabaseType());
base.OnModelCreating(modelBuilder);
}
}

To include custom converters in a manual setup, pass a JsonConversionOptions instance directly:

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
var jsonOptions = new JsonConversionOptions();
jsonOptions.JsonSerializerOptions.Converters.Add(new MyRequestConverter());
var entityTypes = modelBuilder.Model.GetEntityTypes();
modelBuilder.ApplyJsonConversion(entityTypes, Database.GetDatabaseType(), jsonOptions);
base.OnModelCreating(modelBuilder);
}

When you use BaseDbContext with WithEntityFrameworkCore, the context resolves the JsonConversionOptions singleton from dependency injection. Populate EntityFrameworkCoreOptions.JsonConverters at startup; you do not need to pass the options into model configuration yourself.

JsonConversion.ApplyJsonConversion() inspects the supplied entity types for [Json] properties and annotated constructor parameters. It configures the corresponding properties with value converters, serialized-value comparers, and column types for the selected provider.

For context-wide conventions, continue with BaseDbContext. If a property holds geometry, compare string conversion, JSON properties, and spatial column declarations before writing its migration.