Skip to content

Read models from other providers

Injection is not Chronicle-only. Any provider that owns a read model’s storage can make its [ReadModel] types injectable into a command, resolved by the same key, so a validator, Provide(), or Handle() takes the read model exactly as it would a Chronicle-backed one.

This compatibility reference covers where the read model comes from and what key loads it. Provider-neutral injection does not require Chronicle. The EF and MongoDB examples below assume standalone Arc and use its data annotations command key; if you register Chronicle, use its identity rules instead. The examples are dependency/key fragments: returning CustomerRenamed alone does not persist a rename without a handler or integration that performs that effect; a complete standalone rename must write through its provider. For where to put the parameter and what a nullable one means, see Read models in commands.

These standalone examples use ConceptAs<T>, not Chronicle event-source identities. Keep each declaration in its own application file. The named types prevent passing a cart id where a customer id is expected:

using Cratis.Concepts;
public record CustomerId(Guid Value) : ConceptAs<Guid>(Value)
{
public static readonly CustomerId NotSet = new(Guid.Empty);
public static CustomerId New() => new(Guid.NewGuid());
public static implicit operator CustomerId(Guid value) => new(value);
}
public record CustomerName(string Value) : ConceptAs<string>(Value)
{
public static readonly CustomerName NotSet = new(string.Empty);
public static implicit operator CustomerName(string value) => new(value);
}
public record CartId(Guid Value) : ConceptAs<Guid>(Value)
{
public static readonly CartId NotSet = new(Guid.Empty);
public static CartId New() => new(Guid.NewGuid());
public static implicit operator CartId(Guid value) => new(value);
}
public record ItemId(Guid Value) : ConceptAs<Guid>(Value)
{
public static readonly ItemId NotSet = new(Guid.Empty);
public static ItemId New() => new(Guid.NewGuid());
public static implicit operator ItemId(Guid value) => new(value);
}
public record CustomerRenamed(CustomerId CustomerId, CustomerName Name);

A [ReadModel] entity carried by a ReadOnlyDbContext becomes injectable once the context is registered — there is nothing extra to wire up:

[ReadModel]
public class Customer
{
public required CustomerId Id { get; set; }
public required CustomerName Name { get; set; }
}
public class CustomerDbContext(DbContextOptions<CustomerDbContext> options) : ReadOnlyDbContext(options)
{
public DbSet<Customer> Customers => Set<Customer>();
}
using System.ComponentModel.DataAnnotations;
using Cratis.Arc.Commands.ModelBound;
[Command]
public record RenameCustomer([property: Key] CustomerId CustomerId, CustomerName NewName)
{
public CustomerRenamed Handle(Customer customer) => new(customer.Id, NewName);
}

WithEntityFrameworkCore() discovers the ReadOnlyDbContext, and the command’s resolved key (here the [Key] on CustomerId) loads the entity by its primary key. The primary key may be a Guid, int, long, string, or a ConceptAs<T> wrapping one of those.

The nullable rules are identical: a nullable Customer? receives null when no row exists, and a non-nullable Customer fails the command with ReadModelDoesNotExistForCommand.

WithMongoDB() does the same for the read models MongoDB holds. There is nothing to declare — a [ReadModel] becomes injectable, resolved by the document _id:

[ReadModel]
public record Customer(CustomerId Id, CustomerName Name)
{
public static IEnumerable<Customer> AllCustomers(IMongoCollection<Customer> collection) =>
collection.Find(_ => true).ToList();
}
using System.ComponentModel.DataAnnotations;
using Cratis.Arc.Commands.ModelBound;
[Command]
public record RenameCustomer([property: Key] CustomerId CustomerId, CustomerName NewName)
{
public CustomerRenamed Handle(Customer customer) => new(customer.Id, NewName);
}

The id member is whichever one MongoDB maps to _id — a member named Id by convention, or the one marked [BsonId]. Like the EF primary key it may be a Guid, int, long, string, or a ConceptAs<T> wrapping one of those. A read model with no member mapped to _id cannot be resolved by key, and injecting it fails with MissingIdMapping.

When a fallback and a declaring provider can both load a read model, declared ownership wins. Avoid having two providers declare the same CLR type: that conflict is registration-order-dependent.

ProviderOwns a read model whenClaims it as
Chroniclea projection, model-bound projection, or reducer targets itdeclared
Entity Framework Corea DbSet on a ReadOnlyDbContext carries itdeclared
MongoDB— a collection is served for any read modelfallback

A declaring provider wins over a fallback in either registration order. Two declaring providers (for example Chronicle and EF Core) replace one another; the later registration wins. MongoDB claims only what nothing else resolves, and it also leaves your own registration of a read model type alone. This matters beyond tidiness: Chronicle is the provider that releases a read model’s compliance-protected values, so a read model Chronicle projects has to be resolved by Chronicle.

The provider that claims a read model also decides which serialization boundary the injected instance crosses, and the three cross entirely different ones:

ProviderMaterializes a command-side read model through
Chronicleordinarily a service JSON payload deserialized with System.Text.Json; passive reducers can fold state in-process
Entity Framework Coreits own entity model
MongoDBthe driver’s BsonClassMap and convention machinery

So whatever customization belongs to one of those boundaries — a convention pack, a class-map customization, an element rename, a custom serializer, a JSON converter — reaches a command-side read model only when its own provider is the one that claimed it.

Chronicle and Entity Framework Core both declare. For each type claimed by either, MongoDB’s fallback does not supply the command-side instance. Its BSON customization therefore does not govern that instance. Other types in the same application can still be MongoDB-owned. See materialized and passive paths.

To contribute a provider of your own, implement ICanResolveReadModelForCommand — reporting the types it resolves, the ReadModelForCommandOwnership it claims them with, and how to load one by key — and register it with services.AddReadModelsForCommand(...).

Every provider loads by the resolved command key. When the Chronicle integration is registered, its resolver supplies that key through ICanProvideEventSourceId, an EventSourceId/EventSourceId<T>-derived property, or Chronicle [Key]. Otherwise Arc uses its provider-neutral key rules.

An application without Chronicle has none of those, so Arc reads the key from the command itself. Mark the property holding it with the data annotations [Key]:

using System.ComponentModel.DataAnnotations;
[Command]
public record RenameCustomer([property: Key] CustomerId CustomerId, CustomerName NewName)
{
public CustomerRenamed Handle(Customer customer) => new(customer.Id, NewName);
}

The key may be a Guid, int, long, string, or a ConceptAs<T> wrapping one of those — a concept resolves to the value it wraps rather than to its own ToString().

When the key is not one property — a composite of two, or a value derived from them — the command declares it:

[Command]
public record FindItem(CartId CartId, ItemId ItemId) : ICanProvideKeyForCommand
{
public string GetKey() => $"{CartId.Value}/{ItemId.Value}";
public bool Handle(CartItem? item) => item is not null;
}

The composite-key fragment assumes an application CartItem registered with a matching string key and imports from Cratis.Arc.Commands and Cratis.Arc.Commands.ModelBound.

Without Chronicle, nothing is inferred from the shape of a command. One carrying two identifiers and marking neither resolves no key, and injection fails as a validation error rather than silently picking one of them.

To key commands your own way across an application, implement ICanResolveKeyForCommand. It is discovered automatically and asked before the rule Arc ships, whichever order the two happen to be discovered in.