Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,38 @@ public record AddItemToCart([Key] Guid CartId, Guid ProductId, int Quantity)

Read models never emit events. If the decision must hold under concurrency, drive it from the aggregate or from a Chronicle [constraint](/chronicle/constraints/) rather than from projected state — read models are eventually consistent.

## 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.

The Entity Framework Core integration does this for you. A `[ReadModel]` entity carried by a `ReadOnlyDbContext` becomes injectable once the context is registered — there is nothing extra to wire up:

```csharp
[ReadModel]
public class Customer
{
public Guid Id { get; set; }
public string Name { get; set; } = string.Empty;
}

public class CustomerDbContext(DbContextOptions<CustomerDbContext> options) : ReadOnlyDbContext(options)
{
public DbSet<Customer> Customers => Set<Customer>();
}
```

```csharp
[Command]
public record RenameCustomer([Key] Guid CustomerId, string 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`](./failures.md#readmodeldoesnotexistforcommand). Chronicle-backed and EF-backed read models coexist in the same application and never claim each other's types.

## Testing

Seed the state the command should see with the `Given` builder — either the events behind it or a pinned instance — and execute through the real pipeline. See [Testing with Chronicle](../../testing/chronicle.md#testing-commands-that-take-read-model-dependencies).
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
// Copyright (c) Cratis. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.

using Cratis.Arc.Commands;

namespace Cratis.Arc.Queries.for_ReadModelForCommandServiceCollectionExtensions;

/// <summary>
/// A test <see cref="ICanResolveReadModelForCommand"/> that resolves pinned instances for the read model types it owns.
/// </summary>
/// <param name="readModelTypes">The read model types this resolver owns.</param>
/// <param name="instances">The pinned instances to resolve, keyed by read model type; a type with no entry resolves to null.</param>
public class a_read_model_resolver(IEnumerable<Type> readModelTypes, IReadOnlyDictionary<Type, object?>? instances = null) : ICanResolveReadModelForCommand
{
readonly IReadOnlyDictionary<Type, object?> _instances = instances ?? new Dictionary<Type, object?>();

public IEnumerable<Type> ReadModelTypes { get; } = [.. readModelTypes];

public Task<object?> Resolve(Type readModelType, CommandContext commandContext) =>
Task.FromResult(_instances.TryGetValue(readModelType, out var instance) ? instance : null);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
// Copyright (c) Cratis. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.

namespace Cratis.Arc.Queries.for_ReadModelForCommandServiceCollectionExtensions;

#pragma warning disable SA1402, SA1649 // File may only contain a single type, File name should match first type name

public record FirstReadModel(string Value);

public record SecondReadModel(string Value);

#pragma warning restore SA1402, SA1649
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
// Copyright (c) Cratis. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.

using Microsoft.Extensions.DependencyInjection;

namespace Cratis.Arc.Queries.for_ReadModelForCommandServiceCollectionExtensions;

public class when_adding_read_models_for_command : Specification
{
IServiceCollection _services;
RegisteredReadModelTypes _registeredReadModelTypes;

void Establish() => _services = new ServiceCollection();

void Because()
{
_services.AddReadModelsForCommand(new a_read_model_resolver([typeof(FirstReadModel)]));
_registeredReadModelTypes = _services
.First(descriptor => descriptor.ServiceType == typeof(RegisteredReadModelTypes))
.ImplementationInstance as RegisteredReadModelTypes;
}

[Fact] void should_register_a_scoped_resolver_for_the_read_model() =>
_services.ShouldContain(descriptor => descriptor.ServiceType == typeof(FirstReadModel) && descriptor.Lifetime == ServiceLifetime.Scoped);

[Fact] void should_register_the_read_model_type_as_a_known_read_model() => _registeredReadModelTypes.Contains(typeof(FirstReadModel)).ShouldBeTrue();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
// Copyright (c) Cratis. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.

using Microsoft.Extensions.DependencyInjection;

namespace Cratis.Arc.Queries.for_ReadModelForCommandServiceCollectionExtensions;

public class when_adding_read_models_from_two_providers : Specification
{
IServiceCollection _services;
RegisteredReadModelTypes _registeredReadModelTypes;

void Establish() => _services = new ServiceCollection();

void Because()
{
_services.AddReadModelsForCommand(new a_read_model_resolver([typeof(FirstReadModel)]));
_services.AddReadModelsForCommand(new a_read_model_resolver([typeof(SecondReadModel)]));
_registeredReadModelTypes = _services
.First(descriptor => descriptor.ServiceType == typeof(RegisteredReadModelTypes))
.ImplementationInstance as RegisteredReadModelTypes;
}

[Fact] void should_register_a_scoped_resolver_for_the_first_provider_read_model() =>
_services.ShouldContain(descriptor => descriptor.ServiceType == typeof(FirstReadModel) && descriptor.Lifetime == ServiceLifetime.Scoped);

[Fact] void should_register_a_scoped_resolver_for_the_second_provider_read_model() =>
_services.ShouldContain(descriptor => descriptor.ServiceType == typeof(SecondReadModel) && descriptor.Lifetime == ServiceLifetime.Scoped);

[Fact] void should_keep_the_first_provider_read_model_in_the_known_set() => _registeredReadModelTypes.Contains(typeof(FirstReadModel)).ShouldBeTrue();
[Fact] void should_add_the_second_provider_read_model_to_the_known_set() => _registeredReadModelTypes.Contains(typeof(SecondReadModel)).ShouldBeTrue();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
// Copyright (c) Cratis. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.

using Cratis.Arc.Commands;
using Cratis.Execution;
using Microsoft.Extensions.DependencyInjection;

namespace Cratis.Arc.Queries.for_ReadModelForCommandServiceCollectionExtensions.when_resolving_through_di;

public class and_the_instance_is_absent : Specification
{
ServiceProvider _rootProvider;
IServiceScope _scope;
object? _resolved;

void Establish()
{
var commandContext = new CommandContext(CorrelationId.New(), typeof(object), new object(), [], new CommandContextValues());

_rootProvider = new ServiceCollection()
.AddScoped(_ => commandContext)
.AddReadModelsForCommand(new a_read_model_resolver([typeof(FirstReadModel)]))
.BuildServiceProvider();
_scope = _rootProvider.CreateScope();
}

void Because() => _resolved = _scope.ServiceProvider.GetService(typeof(FirstReadModel));

[Fact] void should_resolve_to_null() => _resolved.ShouldBeNull();

void Destroy()
{
_scope.Dispose();
_rootProvider.Dispose();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
// Copyright (c) Cratis. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.

using Cratis.Arc.Commands;
using Cratis.Execution;
using Microsoft.Extensions.DependencyInjection;

namespace Cratis.Arc.Queries.for_ReadModelForCommandServiceCollectionExtensions.when_resolving_through_di;

public class and_the_instance_is_present : Specification
{
ServiceProvider _rootProvider;
IServiceScope _scope;
FirstReadModel _instance;
object? _resolved;

void Establish()
{
_instance = new FirstReadModel("materialized");
var instances = new Dictionary<Type, object?> { [typeof(FirstReadModel)] = _instance };
var commandContext = new CommandContext(CorrelationId.New(), typeof(object), new object(), [], new CommandContextValues());

_rootProvider = new ServiceCollection()
.AddScoped(_ => commandContext)
.AddReadModelsForCommand(new a_read_model_resolver([typeof(FirstReadModel)], instances))
.BuildServiceProvider();
_scope = _rootProvider.CreateScope();
}

void Because() => _resolved = _scope.ServiceProvider.GetService(typeof(FirstReadModel));

[Fact] void should_resolve_the_pinned_instance() => _resolved.ShouldEqual(_instance);

void Destroy()
{
_scope.Dispose();
_rootProvider.Dispose();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,11 @@
// Licensed under the MIT license. See LICENSE file in the project root for full license information.

using System.Reflection;
using Cratis.Arc.Chronicle.Commands;
using Cratis.Arc.Commands;
using Cratis.Chronicle.Events;
using Cratis.Execution;
using Microsoft.Extensions.DependencyInjection;

namespace Cratis.Arc.Chronicle.ReadModels.for_ReadModelUnresolvableDependencyClassifier.given;
namespace Cratis.Arc.Queries.for_ReadModelUnresolvableDependencyClassifier.given;

public class a_classifier : Specification
{
Expand All @@ -23,13 +21,15 @@ void Establish()
_parameter = typeof(Consumer).GetMethod(nameof(Consumer.Method))!.GetParameters()[0];
}

protected IServiceProvider ServiceProviderWith(bool registerReadModel, EventSourceId eventSourceId)
protected IServiceProvider ServiceProviderWith(bool registerReadModel, string? resolvedKey)
{
var commandContextValues = new CommandContextValues
var commandContextValues = new CommandContextValues();
if (resolvedKey is not null)
{
{ WellKnownCommandContextKeys.EventSourceId, eventSourceId }
};
var commandContext = new CommandContext(CorrelationId.New(), typeof(TestCommand), new TestCommand(), [], commandContextValues, null);
commandContextValues[CommandContextKeys.ResolvedKey] = resolvedKey;
}

var commandContext = new CommandContext(CorrelationId.New(), typeof(TestCommand), new TestCommand(), [], commandContextValues);

var services = new ServiceCollection();
services.AddScoped(_ => commandContext);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,14 @@
// Licensed under the MIT license. See LICENSE file in the project root for full license information.

using Cratis.Arc.Validation;
using Cratis.Chronicle.Events;

namespace Cratis.Arc.Chronicle.ReadModels.for_ReadModelUnresolvableDependencyClassifier.when_classifying;
namespace Cratis.Arc.Queries.for_ReadModelUnresolvableDependencyClassifier.when_classifying;

public class and_the_dependency_is_a_registered_read_model_with_a_valid_id : given.a_classifier
public class and_the_dependency_is_a_registered_read_model_with_a_valid_key : given.a_classifier
{
IServiceProvider _serviceProvider;

void Establish() => _serviceProvider = ServiceProviderWith(registerReadModel: true, EventSourceId.New());
void Establish() => _serviceProvider = ServiceProviderWith(registerReadModel: true, resolvedKey: "some-key");

void Because() => _result = _classifier.TryClassifyAsClientInput(_parameter, _serviceProvider, out _failure);

Expand Down
Original file line number Diff line number Diff line change
@@ -1,15 +1,13 @@
// Copyright (c) Cratis. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.

using Cratis.Chronicle.Events;

namespace Cratis.Arc.Chronicle.ReadModels.for_ReadModelUnresolvableDependencyClassifier.when_classifying;
namespace Cratis.Arc.Queries.for_ReadModelUnresolvableDependencyClassifier.when_classifying;

public class and_the_dependency_is_not_a_registered_read_model : given.a_classifier
{
IServiceProvider _serviceProvider;

void Establish() => _serviceProvider = ServiceProviderWith(registerReadModel: false, EventSourceId.New());
void Establish() => _serviceProvider = ServiceProviderWith(registerReadModel: false, resolvedKey: "some-key");

void Because() => _result = _classifier.TryClassifyAsClientInput(_parameter, _serviceProvider, out _failure);

Expand Down
Original file line number Diff line number Diff line change
@@ -1,15 +1,13 @@
// Copyright (c) Cratis. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.

using Cratis.Chronicle.Events;
namespace Cratis.Arc.Queries.for_ReadModelUnresolvableDependencyClassifier.when_classifying;

namespace Cratis.Arc.Chronicle.ReadModels.for_ReadModelUnresolvableDependencyClassifier.when_classifying;

public class and_the_event_source_id_is_unspecified : given.a_classifier
public class and_the_resolved_key_is_absent : given.a_classifier
{
IServiceProvider _serviceProvider;

void Establish() => _serviceProvider = ServiceProviderWith(registerReadModel: true, EventSourceId.Unspecified);
void Establish() => _serviceProvider = ServiceProviderWith(registerReadModel: true, resolvedKey: null);

void Because() => _result = _classifier.TryClassifyAsClientInput(_parameter, _serviceProvider, out _failure);

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
// Copyright (c) Cratis. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.

namespace Cratis.Arc.Queries.for_ReadModelUnresolvableDependencyClassifier.when_classifying;

public class and_the_resolved_key_is_empty : given.a_classifier
{
IServiceProvider _serviceProvider;

void Establish() => _serviceProvider = ServiceProviderWith(registerReadModel: true, resolvedKey: string.Empty);

void Because() => _result = _classifier.TryClassifyAsClientInput(_parameter, _serviceProvider, out _failure);

[Fact] void should_not_classify_as_client_input() => _result.ShouldBeFalse();
[Fact] void should_not_produce_a_failure() => _failure.ShouldBeNull();
}
20 changes: 20 additions & 0 deletions Source/DotNET/Arc.Core/Commands/CommandContextExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
// Copyright (c) Cratis. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.

namespace Cratis.Arc.Commands;

/// <summary>
/// Provides provider-neutral extension methods for the <see cref="CommandContext"/>.
/// </summary>
public static class CommandContextExtensions
{
/// <summary>
/// Gets the provider-neutral resolved key from the command context values, if present.
/// </summary>
/// <param name="commandContext">The <see cref="CommandContext"/> to get the resolved key from.</param>
/// <returns>The resolved key, or null when the command carried no usable key.</returns>
public static string? GetResolvedKey(this CommandContext commandContext) =>
commandContext.Values.TryGetValue(CommandContextKeys.ResolvedKey, out var value) && value is string resolvedKey
? resolvedKey
: null;
}
21 changes: 21 additions & 0 deletions Source/DotNET/Arc.Core/Commands/CommandContextKeys.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
// Copyright (c) Cratis. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.

namespace Cratis.Arc.Commands;

/// <summary>
/// Provider-neutral keys for values held on a <see cref="CommandContext"/>.
/// </summary>
public static class CommandContextKeys
{
/// <summary>
/// The key for the provider-neutral resolved key in the command context values.
/// </summary>
/// <remarks>
/// The resolved key is the command's key expressed as a plain string, independent of any backing store. A provider
/// that owns the key resolution (for example the Chronicle integration, which resolves an event source id) writes it
/// so that a read model backing provider — which need not depend on the resolving provider — can load a read model
/// by the same key.
/// </remarks>
public const string ResolvedKey = "resolvedKey";
}
33 changes: 33 additions & 0 deletions Source/DotNET/Arc.Core/Queries/ICanResolveReadModelForCommand.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
// Copyright (c) Cratis. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.

using Cratis.Arc.Commands;

namespace Cratis.Arc.Queries;

/// <summary>
/// Defines a provider that can resolve read models by key for a command, so a read model backed by any store — Chronicle,
/// Entity Framework Core, or another provider — can be injected into command-scoped code.
/// </summary>
/// <remarks>
/// A read model is injectable into command-scoped code (a <c>CommandValidator&lt;&gt;</c>, <c>Provide()</c>, or
/// <c>Handle()</c>) because it is resolvable by the command's resolved key. The command pipeline resolves each read model
/// dependency from DI by type, and a provider contributes a command-scoped, by-key resolver for the read model types it
/// owns through this abstraction. Providers coexist without claiming each other's read model types — each reports only
/// the types it can resolve through <see cref="ReadModelTypes"/>.
/// </remarks>
public interface ICanResolveReadModelForCommand
{
/// <summary>
/// Gets the read model types this provider can resolve by key for a command.
/// </summary>
IEnumerable<Type> ReadModelTypes { get; }

/// <summary>
/// Resolves the read model of the given type for the current command context, keyed by the command's resolved key.
/// </summary>
/// <param name="readModelType">The type of read model to resolve.</param>
/// <param name="commandContext">The <see cref="CommandContext"/> to resolve from.</param>
/// <returns>The resolved read model instance, or null when no instance exists for the command's key.</returns>
Task<object?> Resolve(Type readModelType, CommandContext commandContext);
}
Loading
Loading