Serialization in the .NET SDK

Pluggable serialization, modern C# type support, and the per-actor state cache

Dapr.Actors.Next uses the same pluggable serializer as Dapr State management and Workflows, works with modern C# types out of the box, and retains the per-actor state cache. This page covers those. Evolving state shapes across releases is a separate, deeper topic with its own page: see State migration.

Pluggable serialization

Actor payloads and state are serialized through IDaprSerializer, resolved from dependency injection. The default implementation is JsonDaprSerializer (System.Text.Json), and you can replace it without changing any actor code.

// Default: System.Text.Json, no configuration required.
builder.Services.AddDaprActors();

To plug in a custom serializer, register your own IDaprSerializer before AddDaprActors(). The actor infrastructure registers the default with TryAdd, so an implementation you register first is the one that is used:

builder.Services.AddSingleton<IDaprSerializer, MyProtobufSerializer>();
builder.Services.AddDaprActors();

To keep the actor path trim- and AOT-safe, register an IDaprSerializer backed by a source-generated JsonSerializerContext rather than relying on the reflection-based default. Configuring JsonSerializerOptions with Configure<JsonSerializerOptions>(...) does not reach the actor serializer, because the default JsonDaprSerializer is constructed with its own options; register the serializer yourself instead:

[JsonSerializable(typeof(CartState))]
[JsonSerializable(typeof(CartItem))]
internal sealed partial class MyActorJsonContext : JsonSerializerContext;

builder.Services.AddSingleton<IDaprSerializer>(new JsonDaprSerializer(
    new JsonSerializerOptions(JsonSerializerDefaults.Web) { TypeInfoResolver = MyActorJsonContext.Default }));
builder.Services.AddDaprActors();

For a fully ahead-of-time-compiled host, also register a matching IActorWireSerializer, which serializes at the invocation wire boundary, so no reflection-based path remains.

Modern C# types work as written

With the default System.Text.Json serializer, the C# shapes you most likely reach for as payload and state types work with no serialization-specific attributes: records, and classes with a primary constructor, are both fine as-is.

This is a real change from Dapr.Actors, whose strongly-typed client uses the Data Contract Serializer. That serializer requires every type to have a public parameterless constructor or be explicitly decorated with [DataContract] and [DataMember], and init-only setters need the contract attributes too. A record or a primary-constructor class has no parameterless constructor, so under Dapr.Actors you had to add a redundant one or annotate the type. Here you do not:

// Works as-is as an actor method payload or state type under the default serializer:
public record CartItem(string Sku, int Quantity);

public sealed class CartState(string ownerId)
{
    public string OwnerId { get; } = ownerId;
    public List<CartItem> Items { get; } = [];
}

You still reach for a serializer-specific attribute when you want a specific serializer feature, for example [JsonPolymorphic] and [JsonDerivedType] for a polymorphic hierarchy, or [JsonConverter] for a custom conversion. The point is that you add those to opt into a feature, not to satisfy a constructor requirement the language no longer wants you to meet.

The per-actor state cache

State reads are served from an in-memory, write-behind cache scoped to the activation. Mutations are not written through on every change; instead pending state is saved once at the end of each turn, which minimizes calls to the state store.

public async Task AddItem(CartItem item, CancellationToken ct = default)
{
    var cart = await State.GetOrCreateAsync("cart", () => new CartState(), ct);  // cache hit after first load
    cart.Value.Items.Add(item);                        // mutate through .Value within the turn
    // no Save() needed; the turn saves once when it completes
}

You rarely call save explicitly, and multiple mutations in a single method (or across a reentrant call chain within the same turn) collapse into a single write.

Evolving state shapes: see State migration

When a state type’s shape changes between releases, older persisted data must still load under the new code. That is a first-class, opt-in feature with its own page rather than a subsection here, because it carries real depth: upcaster chains, additive auto-generation, a graduation off-ramp, and build-time and run-time integrity guarantees. The short version is that you write one small upcaster per shape change (or none, for additive changes), and the runtime folds stored data up to the shape your code asks for on read, re-persisting lazily. For the full model and best practices, see State migration.

Best practices

  • Register a JsonSerializerContext for your actor payloads to stay on the AOT-safe path.
  • Keep serializer configuration for state types stable, and treat a change that alters the persisted shape as a versioning decision (see State migration).
  • Keep per-actor state reasonably sized, since the whole state saves as one value per turn.
  • Keep mutations in State rather than in external side effects, so a re-run turn stays safe under at-least-once delivery.

Next steps

Last modified July 13, 2026: Fix baseUrl for current (#5242) (d29da20)