Skip to content

Operations Framework Serialization

The Operations Framework stores operation data in the database using JSON serialization. This document covers how DbOperation and Operation.Items are serialized, and how to customize the serialization.

DbOperation Storage

When an operation is committed, it's stored as a DbOperation entity with the following serialized fields:

ColumnContentSerializer
ItemsJsonOperation.Items property bagNewtonsoftJsonSerializer.Default
CommandJsonThe command that triggered the operationNewtonsoftJsonSerializer.Default

Default Serializer

DbOperation uses Newtonsoft.Json by default:

cs
// DbOperation.Serializer is a static, mutable property.
// Its default value is NewtonsoftJsonSerializer.Default.
ITextSerializer serializer = DbOperation.Serializer;

Newtonsoft.Json is chosen because:

  • It handles polymorphic types well with TypeNameHandling.Auto
  • It's more forgiving with missing/extra properties during schema evolution
  • It has mature support for complex object graphs

Operation.Items Serialization

Operation.Items is a MutablePropertyBag that stores arbitrary key-value pairs. It's serialized to the ItemsJson column:

cs
// How Items are serialized to DbOperation
var ItemsJson = operation.Items.Items.Count == 0
    ? null
    : Serializer.Write(operation.Items.Snapshot, typeof(PropertyBag));

PropertyBag Internals

Each item in the bag uses TypeDecoratingUniSerialized<object> to preserve type information:

cs
[DataContract, MemoryPackable, MessagePackObject]
public partial record struct PropertyBagItem(
    [property: DataMember] string Key,
    [property: DataMember] TypeDecoratingUniSerialized<object> Serialized);

This allows heterogeneous values with full type preservation:

cs
// Store different types in the same operation
operation.Items.Set("userId", 123L);           // long
operation.Items.Set("metadata", myDto);        // custom type
operation.Items.Set("tags", new[] { "a", "b" }); // array

// Types are preserved after serialization round-trip
var userId = operation.Items.Get<long>("userId");     // Works correctly
var metadata = operation.Items.Get<MyDto>("metadata"); // Type preserved

Customizing Serialization

Changing the Default Serializer

To use different serializer settings:

cs
// At application startup, before any operations are processed
DbOperation.Serializer = new NewtonsoftJsonSerializer(new JsonSerializerSettings {
    TypeNameHandling = TypeNameHandling.Auto,
    NullValueHandling = NullValueHandling.Ignore,
    DateParseHandling = DateParseHandling.None,
    // Add custom converters if needed
    Converters = { new MyCustomConverter() },
});

Using Type-Decorated Serializer

For explicit type information in the JSON:

cs
DbOperation.Serializer = new TypeDecoratingTextSerializer(
    new NewtonsoftJsonSerializer(customSettings));

This produces JSON like:

json
/* @type MyNamespace.MyCommand, MyAssembly */ {"property": "value"}

Command Serialization

Commands stored in CommandJson are serialized with type information to enable proper deserialization during reprocessing:

cs
// Command types must be serializable
[DataContract, MemoryPackable, MessagePackObject]
public sealed partial record CreateTodoCommand(
    [property: DataMember, MemoryPackOrder(0), Key(0)] string Title,
    [property: DataMember, MemoryPackOrder(1), Key(1)] string? Description
) : ICommand<Todo>;

Annotating Command Types

For reliable serialization across Operations Framework, RPC, and other subsystems, annotate commands with all serialization attributes:

cs
[DataContract, MemoryPackable(GenerateType.VersionTolerant), MessagePackObject(true)]
public sealed partial record MyCommand(
    [property: DataMember(Order = 0), MemoryPackOrder(0)] string Id,
    [property: DataMember(Order = 1), MemoryPackOrder(1)] string Data
) : ICommand<string>
{
    [System.Text.Json.Serialization.JsonConstructor, MemoryPackConstructor, SerializationConstructor]
    public MyCommand() : this("", "") { }
}

Schema Evolution

When evolving command schemas:

  1. Add new properties as optional with default values
  2. Don't remove properties from persisted commands (old operations may need reprocessing)
  3. Don't change property types without a migration strategy
cs
public record CreateUserCommandV1(string Name) : ICommand<User>;
cs
public record CreateUserCommandV2(
    string Name,
    string? Email = null  // New optional property
) : ICommand<User>;

Deployment Compatibility Contract

DbOperation persists the concrete command (including nested-operation and operation-item types) as polymorphic JSON, and other hosts deserialize it to replay the invalidation pass. This ties command serialization to your deployment process:

  • A command type (or any nested-operation/operation-item type it carries) must remain deserializable for at least MaxEntryAge (30 minutes by default – see Operation Log Trimmer) past its last producer. In practice: don't rename or remove a command type and deploy that change within the same window; stage such changes across releases instead (e.g. keep the old type around, deprecated, for one extra release).
  • If this contract is violated, deserialization fails on the reading host, and the operation eventually gets abandoned with a single Error-level log once its bounded retry budget is exhausted – it does not fail silently, but it also doesn't self-heal. Losing the invalidation for that operation means dependent caches on that host go stale until something else invalidates them.
  • There's no built-in type-alias/rename mapping in the serialization binder today; if a specific rename can't be staged across releases, add one on demand rather than up front.

Troubleshooting

Missing Type Information

If deserialization fails with "Could not determine type", ensure:

  • The type is in a loaded assembly
  • Type names haven't changed (namespace, class name)
  • TypeNameHandling.Auto is enabled in Newtonsoft.Json settings

PropertyBag Values Not Deserializing

Check that stored types:

  • Have parameterless constructors (or appropriate constructor attributes)
  • Are public and not internal/private
  • Have [DataContract] or are otherwise serializable