Xpandables.Net.AspNetCore 6.0.8

There is a newer version of this package available.
See the version list below for details.
dotnet add package Xpandables.Net.AspNetCore --version 6.0.8                
NuGet\Install-Package Xpandables.Net.AspNetCore -Version 6.0.8                
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="Xpandables.Net.AspNetCore" Version="6.0.8" />                
For projects that support PackageReference, copy this XML node into the project file to reference the package.
paket add Xpandables.Net.AspNetCore --version 6.0.8                
#r "nuget: Xpandables.Net.AspNetCore, 6.0.8"                
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
// Install Xpandables.Net.AspNetCore as a Cake Addin
#addin nuget:?package=Xpandables.Net.AspNetCore&version=6.0.8

// Install Xpandables.Net.AspNetCore as a Cake Tool
#tool nuget:?package=Xpandables.Net.AspNetCore&version=6.0.8                

Xpandables.Net

Provides with useful interfaces contracts in .Net 6.0 and some implementations mostly following the spirit of SOLID principles, CQRS... The library is strongly-typed, which means it should be hard to make invalid requests and it also makes it easy to discover available methods and properties though IntelliSense.

Feel free to fork this project, make your own changes and create a pull request.

Here are some examples of use :

Minimal Web Api using CQRS and EFCore

Create a Web Api project (Minimal version) and add the following nuget packages to your project : We are building all the code in the same project just for demo purpose.

Xpandables.Net.AspNetCore

Xpandables.Net.EntityFramework

Microsoft.EntityFrameworkCore.InMemory

Model Definition


// Entity is the domain object base implementation that provides with an Id,
// You can use Aggregate{TAggregateId} if you're targeting DDD.

// The person entity Id
public readonly record struct PersonEntityId(Guid Key)
{
    public static PersonEntityId With(Guid key) => new(key);
    public static implicit operator Guid(PersonEntityId personId) => personId.Key;
}

// Define the class
public sealed class PersonEntity : Entity
{
    public PersonEntityId EntityId { get; }
    public string FirstName { get; private set; }
    public string LastName { get; private set; }
    
    public static PersonEntity Create(Guid id, string firstName, string lastName)
    {
        // custom check
        // ...

        var personEntityId = PersonEntityId.With(id);
        return new(personEntityid, fistName, lastName);
    }
    
    public void ChangeFirstName(string firstName)
        => FirstName = firstName ?? throw new ArgumentNullException(nameof(firstName));
    
    public void ChangeLastName(string lastName)
        => LastName = lastName ?? throw new ArgumentNullException(nameof(lastName));
    
    private PersonEntity(PersonEntityId entityId, string firstName, string lastName)
        => (EntityId, FirstName, LastName) = (entityId, firstName, lastName);
}

Context definition


// We are using EFCore

public sealed class PersonEntityTypeConfiguration : IEntityTypeConfiguration<PersonEntity>
{
    public void Configure(EntityTypeBuilder<PersonEntity> builder)
    {
        builder.Ignore(p => p.Id); // Disable the Id from the base entity because we are using the PersonEntityId type.
        builder.HasKey(p => p.EntityId);

        builder.Property(p => p.EntityId).HasConversion(v => v.Value, v => new(v)); // Conversion for EFCore
        builder.Property(p => p.FirstName);
        builder.Property(p => p.LastName);

        builder.Property(p => p.EntityState).HasConversion(v => v.Name, v => new(v)); // Entity State
    }
}

// DataContext is an abstract class that inherits from DbContext (EFCore) and adds some behaviors.
// IUnitOfWorkContext is an interface used for IUnitofWork

public sealed class EntityDataContext : DataContext, IUnitofWorkContext
{
     public EntityDataContext(DbContextOptions<EntityDataContext> contextOptions)
        : base(contextOptions) { }
        
     protected override void OnModelCreating(ModelBuilder modelBuilder)
     {
         modelBuilder.ApplyConfiguration(new PersonEntityTypeConfiguration());
     }
     
     public DbSet<PersonEntity> People { get; set; } = default!;
}

Command/Query and Handler definitions


// The command must implement the ICommand interface and others to enable their behaviors.
// Such as IValidatorDecorator to apply validation before processing the command,
// IPersistenceDecorator to add persistence to the control flow
// or IInterceptorDecorator to add interception of the command process...

// You can derive from QueryExpression{TClass} to allow command/query to behave like an expression
// when querying data, and override the target GetExpression() method.

public sealed record class AddPersonCommand(
    Guid Id, string FirstName, string LastName) : ICommand, IValidatorDecorator, IPersistenceDecorator;

public sealed class AddPersonCommandHandler : ICommandHandler<AddPersonCommand>
{
    private readonly IUnitOfWork _unitOfWork;
    public AddPersonCommandHandler(IUnitOfWork unitOfWork) => _unitOfWork = unitOfWork;
   
    public async ValueTask<OperationResult> HandleAsync(AddPersonCommand command, 
        CancellationToken cancellationToken = default)
    {
        // You can check here for data validation or use a specific class for that
        // (see AddPersonCommandValidationDecorator).
        
        var newPerson = PersonEntity.Create(
            command.Id,
            command.FirstName,
            command.LastName);

        var repository = _unitOfWork.GetWriteRepository<PersonEntity>();
        await repository.InsertAsync(newPerson, cancellationToken).ConfigureAwait(false);

        // We do not persist here because we use the decorator for persistence
        // await _unitOfWork.PersitAsync(canellationToken).ConfigureAwait(false);
               
        return OperationResult.Ok(
            OperationResultHeaderCollection
                .CreateWith("newId", command.Id));

        // or you can use the OperationResult.Created(...) response.        
        // Ok, NotFound... are extension methods that return an OperationResult that acts like
        // IActionResult in AspNetCore.
        // The OperationResultFilter will process the output message format.
        // You can add a decorator class to manage the exception.
    }
}

public sealed record class Person(Guid Id, string FirstName, string LastName);
public sealed record class GetPersonQuery(Guid Id) :  IQuery<Person>;

public sealed class GetPersonQueryHandler : IQueryHandler<GetPersonQuery, Person>
{
    private readonly IUnitOfWork _unitOfWork;
    public GetPersonQueryHandler(IUnitOfWork unitOfWork) => _unitOfWork = unitOfWork;
    
    public async ValueTask<OperationResult<Person>> HandleAsync(GetPersonQuery query,
        CancellationToken cancellationToken = default)
    {
        var repository = _unitOfWork.GetReadRepository<PersonEntity>();
        var result = await respository.TryFindAsync(PersonEntityId.With(query.Id), cancellationToken).ConfigureAwait(false);
        
        return result switch
        {
            PersonEntity person => OperationResult.Ok(new Person(person.Id, person.FirstName, person.LastName)),
            _ => OperationResult.NotFound<Person>(
                OperationResultErrorCollection.CreateWith(nameof(query.Id), "Id not found.")))
        };
    }        
}

// When using validation decorator.
// IValidator{T} defines a method contract used to validate a type-specific argument using a decorator.
// The validator get called during the control flow before the handler.
// If the validator returns a failed operation result or throws exception, the execution will be interrupted
// and the result of the validator will be returned.
// We consider as best practice to handle common conditions without throwing exceptions
// and to design classes so that exceptions can be avoided.
// You can use the default built in Validator{T} that uses the dotnet Validator.TryValidateObject(...)

public sealed class AddPersonCommandValidationDecorator : IValidator<AddPersonCommand>
{
    private readonly IUnitOfWork _unitOfWork;
    public AddPersonCommandValidationDecorator(IUnitOfWork unitOfWork) => _unitOfWork = unitOfWork;

    public async ValueTask<OperationResult> Validate(AddPersonCommand argument)
    {
        // custom check
        // validate values...
        // ...

        // For ex. check that the record is unique
        var repository = _unitOfWork.GetReadRepository<PersonEntity>();

        return await respository.TryFindAsync(PersonEntityId.With(argument.Id), cancellationToken).ConfigureAwait(false) switch
        {
            true => OperationResult.Conflict(
                OperationResultErrorCollection.CreateWith(nameof(argument.Id), "Id already exist")),
            _ => OperationResult.Ok()
        };
    }    
}

Contract definition for Api clients

// Contract is decorated with HttpClientAttribute that describes the parameters for a request 
// used with IHttpClientDispatcher, where IHttpClientDispatcher provides with methods to handle HTTP
// Rest client queries and commands using a typed client HTTP Client. It also allows, in a .Net environment,
// to no longer define client actions because they are already included in the contracts, 
// by implementing interfaces such as IHttpRequestPathString, IHttpRequestFormUrlEncoded,
// IHttpRequestMultipart, IHttpRequestString, IHttpRequestStream...
// Without the use of one of those interfaces, the whole class will be serialized.
// You can also use the IHttpClientAttributeProvider interface to return the attribute at runtime.


[HttpClient(Path = "api/person", In = ParameterLocation.Body, Method = ParameterMethod.POST, IsSecured = false)]
public sealed record class AddPersonRequest([Required] Guid Id, [Required] string FirstName, [Required] string LastName)
    : IHttpClientRequest, IHttpRequestString
{
    // You can omit the use of IHttpRequestString, the whole class will be serialized.
    public object GetStringContent() => new { Id, FirstName, LastName };
}

[HttpClient(Path = "api/person/{id}", IsNullable = true, In = ParameterLocation.Path, 
    Method = ParameterMethod.Get, IsSecured = false)]
public sealed record class GetPersonRequest([Required] Guid Id) : IHttpClientRequest<Person>,
    IHttpRequestPathString
{
    public IDictionary<string, string> GetPathStringSource()
        => new Dictionary<string, string> { { nameof(Id), Id.ToString() } };
}

Api definition endpoints


// IEndpointRoute provides with methods to add routes and register services.
// The implementation get called by extension methods AddXServiceRegisters() and MapXEndpointRoutes()

public sealed class AddPerson : IEndpointRoute
{
    public void AddRoutes(IEndpointRouteBuilder app)
    {
        app.MapPost("api/person", AddPersonAsync)
            .WithTags("Person")
            .WithName("AddPerson")
            .AllowAnonymous()
            .Produces<Guid>(StatusCodes.Status200OK, ContentType.Json)
            .Produces<ValidationProblemDetails>(StatusCodes.Status409Conflict, ContentType.Json)
            .Produces<ProblemDetails>(StatusCodes.Status500InternalServerError, ContentType.Json);
    }

    //public void RegisterServices(IServiceCollection services)
    //{
    //    if you need to register services for the endpoint.
    //    You can use another signature : RegisterServices(IServiceCollection service, IConfiguration configuration)
    //}

    internal static async ValueTask<IResult> AddPersonAsync(
        ICommandHandler<AddPersonCommand> addPerson, [FromBody] AddPersonRequest request, CancellationToken cancellationToken)
    {
        var command = new AddPersonCommand(request.Id, request.FirstName, request.LastName);
        OperationResult result = await addperson.HandleAsync(command, cancellationToken).ConfigureAwait(false);
        return result.ToResultOperationResult();

        // OperationResult.ToResultOperationResult() converts the operation to IResult.
    }
}

public sealed class GetPerson : IEndpointRoute
{
    public void AddRoutes(IEndpointRouteBuilder app)
    {
        app.MapGet("api/person/{id}", GetPersonAsync)
            .WithTags("Person")
            .WithName("GetPerson")
            .AllowAnonymous()
            .Produces<Person>(StatusCodes.Status200OK, ContentType.Json)
            .Produces<ValidationProblemDetails>(StatusCodes.Status404NotFound, ContentType.Json)
            .Produces<ProblemDetails>(StatusCodes.Status500InternalServerError, ContentType.Json);
    }

    internal static async ValueTask<IResult> GetPersonAsync(
        IQueryHandler<GetPersonQuery, Person> getPerson, Guid id, CancellationToken cancellationToken)
    {
        var query = new GetPersonQuery(id);
        OperationResult<Person> result = await getPerson.HandleAsync(query, cancellationToken).ConfigureAwait(false);
        return result.ToResultOperationResult();
    }
}

// The program class
// We will register handlers, context, validators and decorators.

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();

// Register services
builder.Services
    .AddXpandablesServices()
    .AddXUnitOfWorkContext<EntityDataContext>()
    .AddXDataContext<EntityDataContext>(options => options.UseInMemoryDatabase(nameof(EntityDataContext))
    .AddXOperationResultMinimalExceptionMiddleware()
    .AddXHandlers(
        options =>
        {
            options.UsePersistenceDecorator();
            options.UseValidatorDecorator();
        },
        ServiceLifetime.Scoped,
        typeof(AddPersonCommand).Assembly)
    .AddXQueryHandlerWrapper()
    .AddXOperationResultConfigureJsonOptions()
    .AddXServiceRegisters(configuration, typeof(AddPersonCommand).Assembly) // Register services from IEndpointRoute implementations
    .Build();

// ... custom registration

var app = builder.Build();

app.UseXpandableApplications()
    .UseXOperationResultMinimalExceptionMiddleware();

// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
    app.UseSwagger();
    app.UseSwaggerUI();
}

app.UseHttpsRedirection();
app.UseXpandableApplications()
    .MapXEndpointRoutes(typeof(AddPesonCommand).Assembly);

app.Run();

// AddXpandableServices() will make available methods for registration
// AddXOperationResultMinimalExceptionMiddleware() handles the OperationResult exception
// AddXOperationResultConfigureJsonOptions() will add operation result converters
// AddXDataContext{TContext} registers the TContext
// AddXUnitOfWorkContext<EntityDataContext>() Add the unitOfWork for the context
// AddXHandlers(options, serviceLifetime, assemblies) registers all handlers and associated classes (validators, decorators...)
// according to the options set.
// UseXOperationResultMinimalExceptionMiddleware() add the minimal Api exception handler


Wep Api Test class

We are using the MSTest template project. You can use another one. Add this package to your test project : Microsoft.AspNetCore.Mvc.Testing reference to your api test project.


// Before, you need to add the following class definition at the bottom of Program.cs
// because the default Program.cs get compiled into a private class.
public partial class Program { }

[TestMethod]
[DataRow("My FirstName", "My LastName")
public async Task AddPersonTestAsync(string firstName, string lastName)
{
    // Build the api client
    
    Environment.SetEnvironmentVariable("ASPNETCORE_ENVIRONMENT", "Development");
    var factory = new WebApplicationFactory<Program>(); // from the api
    var client = factory.CreateClient();
    
    // if you get serialization error (due to System.Text.Json), you can
    // set the serialization options by using an extension method or by globally
    // setting the IHttpClientDispatcher.SerializerOptions property.
    
    using var httpClientDispatcher = new HttpClientDispatcher(
        new HttpClientRequestBuilder(),
        new HttpClientResponseBuilder(),
        client);

    var id = Guid.NewGuid();
    var addPersonRequest = new AddPersonRequest(id, firstName, lastName);
    using var response = await httpClientDispatcher.SendAsync(addPersonRequest).ConfigureAwait(false);

    if (!response.IsValid())
    {
         Trace.WriteLine($"{response.StatusCode}");
         OperationResult operationResult = response.ToOperationResult();
         
         // ToOperationResult() is an extension method for HttpRestClientResponse that returns
         // an OperationResult from the response.
         
         foreach (OperationError error in operationResult.Errors)         
         {
            Trace.WriteLine($"Key : {error.Key}");
            Trace.WriteLine(error.ErrorMessages.StringJoin(";"));
         }         
    }
    else
    {
        string createdId = response.Headers["newId"];
        Trace.WriteLine($"Added person : {createdId}");
        Assert.Equal(id.ToString(), createdId);
    }
}

Blazor WebAss with Web Api using IHttpClientDispatcher

Blazor WebAss project

Create a blazor WebAssembly project. Add the following nuget packages : Xpandables.Net.BlazorExtended

In the Program file, replace the default code with this.


public class Program
{
    public static async Task Main(string[] args)
    {
       var builder = WebAssemblyHostBuilder.CreateDefault(args);
        builder.RootComponents.Add<App>("#app");
        
        builder.Services
            .AddOptions()
            .AddXpandableServices()
                .AddXHttpClientDispatcher(httpClient =>
                {
                    httpClient.BaseAddress = new Uri("https://localhost:44396"); // your api url
                    httpClient.DefaultRequestHeaders
                        .Accept
                        .Add(new MediaTypeWithQualityHeaderValue(ContentType.Json));
                })
            .Build();
        
        // AddXHttpClientDispatcher(httpClient) will add the IHttpClientDispatcher
        // implementation using the HttpClient with your configuration.
        // if you get errors with System.Text.Json, you can use IHttpClientDispatcher.SerializerOptions
        // property to globally set the serializer options or use extension methods in your code.
                
        // custom code...
        
        await builder.Build().RunAsync();
    }
}

AddPerson.razor


<EditForm Model="@model" OnValidSubmit="AddSubmitAsync">

    <XDataAnnotationsValidator @ref="@Validator" />

    <div class="form-group">
        <label for="FirstName" class="-form-label">First Name</label>
        <XInputText @bind-Value="model.FirstName" type="text" class="form-control" />
        <ValidationMessage For="@(() => model.FirstName)" />
    </div>

    <div class="form-group">
        <label for="LastName" class="col-form-label">Last Name</label>
        <XInputText @bind-Value="model.LastName" type="text" class="form-control" />
        <ValidationMessage For="@(() => model.LastName)" />
    </div>

    <div class="form-group">
        <div class="col-md-12 text-center">
            <button class="col-md-12 btn btn-primary">
                Add
            </button>
        </div>
    </div>

</EditForm>

XInputText is a component that allows text to be validated on input.

XDataAnnotationsValidator is a DataAnnotationsValidator derived class that allows insertion of external errors to the edit context.

AddPerson.razor.cs


public sealed class PersonModel
{
    [Required]
    public string FirstName { get; set; } = default!;
    [Required]
    public string LastName { get; set; } = default!;
}

public partial class AddPerson
{
    protected XDataAnnotationsValidator Validator { get; set; } = default!;
    [Inject]
    protected IHttpClientDispatcher HttpClientDispatcher { get; set; } = default!;
    
    private readonly PersonModel model = new();
    
    protected async Task AddSubmitAsync()
    {
        // You can use the AddPersonRequest from the api or create another class
        // We do not specify the action here because the AddPersonRequest definition
        // already hold all the necessary information.
        
        var addRequest = new AddPersonRequest(Guid.NewGuid(), model.FirstName, model.LastName);
        using var addResponse = await HttpClientDispatcher.SendAsync(addRrequest).ConfigureAwait(false);

        OperationResult operationResult = addResponse.ToOperationResult();
        Validator.ValidateModel(operationResult);

        if (addResponse.IsValid()) // or operationResult is Success
        {
            // custom code like displaying the result
            var createdId = operationResult.Headers["newId"];
        }
    }    
}

Features

Usually, when registering types, we are forced to reference the libraries concerned and we end up with a very coupled set. To avoid this, you can register these types by calling an export extension method, which uses MEF: Managed Extensibility Framework.

In your api startup class


// AddXServiceExport(IConfiguration, Action{ExportServiceOptions}) adds and configures registration of services using 
// the IAddServiceExport interface implementation found in the target libraries according to the export options.
// You can use configuration file to set up the libraries to be scanned.

public class Startup
{
    ....
    services
        .AddXpandableServices()
            .AddXServiceExport(Configuration, options => options.SearchPattern = "your-search-pattern-dll")
        .Build();
    ...
}

In the library you want types to be registered


[Export(typeof(IAddServiceExport))]
public sealed class RegisterServiceExport : IAddServiceExport
{
    public void AddServices(IServiceCollection services, IConfiguration configuration)
    {
        services
            .AddXpandableServices()
                .AddXCommandDispatcher()
                .AddXTokenEngine<TokenEngine>()
            .Build();
        ....
    }
}

Decorator pattern

You can use the extension methods to apply the decorator pattern to your types.


// This method and its extensions ensure that the supplied TDecorator" decorator is returned, wrapping the original 
// registered "TService", by injecting that service type into the constructor of the supplied "TDecorator". 
// Multiple decorators may be applied to the same "TService". By default, a new "TDecorator" instance 
// will be returned on each request, 
// independently of the lifestyle of the wrapped service. Multiple decorators can be applied to the same service type. 
// The order in which they are registered is the order they get applied in. This means that the decorator 
// that gets registered first, gets applied first, which means that the next registered decorator, 
// will wrap the first decorator, which wraps the original service type.

 services
    .AddXpandableServices()
        .XTryDecorate<TService, TDecorator>()
    .Build();
   

Suppose you want to add logging for the AddPersonCommand ...


// The AddPersonCommand decorator for logging

public sealed class AddPersonCommandHandlerLoggingDecorator : 
    ICommandHandler<AddPersonCommand>
{
    private readonly ICommandHandler<AddPersonCommand> _decoratee;
    private readonly ILogger<AddPersonCommandHandler> _logger;
    
    public AddPersonCommandHandlerLoggingDecorator(
        ILogger<AddPersonCommandHandler> logger,
        ICommandHandler<AddPersonCommand> decoratee)
        => (_logger, _decoratee) = (logger, decoratee);

    public async ValueTask<OperationResult> HandleAsync(
        AddPersonCommand command, CancellationToken cancellationToken = default)
    {
        _logger.Information(...);
        
        var response = await _decoratee.HandleAsync(command, cancellationToken).configureAwait(false);
        
        _logger.Information(...)
        
        return response;
    }
}

// Register

services
    .AddXpandableServices()
        .XTryDecorate<AddPersonCommandHandler, AddPersonCommandHandlerLoggingDecorator>()
    .Build();

 // or

services.AddXpandableServices()
    .AddXHandlers(
        options =>
        {
            options.UseValidatorDecorator(); // this option will add the command decorator registration
        },
        typeof(AddPersonCommandHandler).Assembly)
    .Build()

// or you can define the generic model, for all commands that implement ICommand 
// interface or something else.

public sealed class CommandLoggingDecorator<TCommand> : ICommandHandler<TCommand>
    where TCommand : notnull, ICommand // you can add more constraints
{
    private readonly ICommandHandler<TCommand> _ decoratee;
    private readonly ILogger<TCommand> _logger;
    
    public CommandLoggingDecorator(ILogger<TCommand> logger, ICommandHandler<TCommand> decoratee)
        => (_logger, _ decoratee) = (logger, decoratee);

    public async ValueTask<OperationResult> HandleAsync(
         TCommand command, CancellationToken cancellationToken = default)
    {
        _logger.Information(...);
        
        var response = await _decoratee.HandleAsync(command, cancellationToken).configureAwait(false);
        
        _logger.Information(...)
        
        return response;
    }
}

// and for registration

// The CommandLoggingDecorator will be applied to all command handlers whose commands meet 
// the decorator's constraints : 
// To be a notnull and implement ICommand interface

services
    .AddXpandableServices()
        .XTryDecorate(typeof(ICommandHandler<>), typeof(CommandLoggingDecorator<>))
    .Build();

Aggregate{TAggregateId}

Libraries also provide with DDD model implementation 'Aggregate{TAggregateId}' using event sourcing and out-box pattern.

Aggregate definition


// Define the aggregate in the domain layer

public readonly record struct RegisterId(Guid Key) : IAggregateId
{
    public static RegisterId With(Guid value) => new(value);
    public static implicit operator Guid(RegisterId registerId) => registerId.Key;
    public static implicit operator string(RegisterId registerId) => ((IAggregateId)registerId).AsString();
}

// You can use readonly record structs to hold primitive types and apply some validations.

public sealed class Register : Aggregate<RegisterId>
{
    private string _firstName = default!;
    private string _lastName = default!;

    public static OperationResult<Register> Create(Guid id, string firstName, string lastName)
    {
        var registerId = RegisterId.With(id)
        var register = new Register();

        var registerCreated = new RegisterCreated(
            registration.AggregateId, register.Name, register.GetNewVersion(), firstName, lastName);
        register.PushMessage(registerCreated); // add event to the aggregate events collection

        return OperationResult.OkResult(
            register,
            OperationResultHeaderCollection
                .CreateWith(nameof(RegisterId), register.AggregateId));
    }

    // ...


    // All method named "On" get automatically registered and called.
    internal void On(RegisterCreated message)
    {
        AggregateId = message.AggregateId;
        _firstName = message.FiestName;
        _lastName = message.LastName;
    }
}


// Events

public readonly record struct RegisterCreated(
    RegisterId AggregateId,
    Name Name,
    Version Version,
    string FirstName,
    string LastName) : IMessage<RegisterId>;

// The Register repository

public interface IRegisterRepository : IEventRepository<RegisterId, Register> { }
// IEventRepository uses IEventStore

Infrastructure definition


// Reference domain layer

// Use the same PersonEntity definition

public sealed class RegisterRepository : EventRepository<RegisterId, Register>, IRegisterRepository
{
    public RegisterRepository(IEventStore eventStore, IMessagePublisher messagePublisher, IUnitOfWork unitOfWork)
        : base(eventStore, messagePublisher, unitOfWork) { }
}

// For events
// MessageEntity and NotificationEntity are defined in the library

public sealed class MessageEntityTypeConfiguration : IEntityTypeConfiguration<MessageEntity>
{
    public void Configure(EntityTypeBuilder<MessageEntity> builder)
    {
        builder.Property(p => p.Id).ValueGeneratedOnAdd().HasDefaultValueSql("newid()");
        builder.HasKey(p => p.Id);
        builder.HasIndex(p => new { p.AggregateId, p.TypeName, p.Version }).IsUnique();

        builder.Property(p => p.AggregateId).IsConcurrencyToken();
        builder.Property(p => p.Name).HasConversion(v => v.Value, v => new(v));
        builder.Property(p => p.Data);
        builder.Property(p => p.TypeFullName).HasConversion(v => v.Value, v => new(v));
        builder.Property(p => p.TypeName).HasConversion(v => v.Value, v => new(v));
        builder.Property(p => p.EntityState).HasConversion(v => v.Name, v => new(v));
        builder.Property(p => p.Version).IsConcurrencyToken().HasConversion(v => v.Value, v => new(v));
    }
}

public sealed class NotificationEntityTypeConfiguration : IEntityTypeConfiguration<NotificationEntity>
{
    public void Configure(EntityTypeBuilder<NotificationEntity> builder)
    {
        builder.Property(p => p.Id).ValueGeneratedOnAdd().HasDefaultValueSql("newid()");
        builder.HasKey(p => p.Id);
        builder.HasIndex(p => p.Id).IsUnique();

        builder.Property(p => p.AggregateId).IsConcurrencyToken();
        builder.Property(p => p.Name).HasConversion(v => v.Value, v => new(v));
        builder.Property(p => p.Data);
        builder.Property(p => p.TypeFullName).HasConversion(v => v.Value, v => new(v));
        builder.Property(p => p.TypeName).HasConversion(v => v.Value, v => new(v));
        builder.Property(p => p.ErrorTypeFullName);
        builder.Property(p => p.ErrorMessage);
        builder.Property(p => p.EntityState).HasConversion(v => v.Name, v => new(v));
    }
}

// For events and entities, you can use the same database, but here we'll use two different databases
// applying multi-tenancy
// The EntityDataContext and the following one :

public sealed class DomainDataContext : DataContext, IUnitOfWorkContext
{
    public DomainDataContext(DbContextOptions<DomainDataContext> contextOptions)
        : base(contextOptions)
    {
    }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.ApplyConfiguration(new MessageEntityTypeConfiguration());
        modelBuilder.ApplyConfiguration(new NotificationEntityTypeConfiguration());

        base.OnModelCreating(modelBuilder);
    }

    public DbSet<MessageEntity> Messages { get; set; } = default!;

    public DbSet<NotificationEntity> Notifications { get; set; } = default!;
}

Application definition


// Reference Infrastructure layer

[HttpClient(Path = "api/register", IsNullable = true, IsSecured = false, In = ParameterLocation.Body, Method = ParameterMethod.POST)]
public sealed record class CreateRegisterRequest(
    [property: Required] Guid Id,
    [property: Required] string FirstName,
    [property: Required] string LastName) : IHttpClientRequest, IHttpRequestString
{
    public object GetStringContent() => new { Id, FirstName, LastName };
}


// Endpoint route definition

public sealed class CreateRegister : IEndpointRoute
{
    public void AddRoutes(IEndpointRouteBuilder app)
    {
        app.MapPost("api/register", CreateRegisterAsync)
        .WithTags("Register")
        .WithName("CreateRegister")
        .WithXMultiTenancyNameMetadata(MultiTenancyName.DefaultDomainDataContext)
        .AllowAnonymous()
        .Produces(StatusCodes.Status200OK, ContentType.Json)
        .Produces<ValidationProblemDetails>(StatusCodes.Status409Conflict, ContentType.Json)
        .Produces<ProblemDetails>(StatusCodes.Status500InternalServerError, ContentType.Json);
    }

    public void RegisterServices(IServiceCollection services)
    {
        services.AddXpandablesServices()
            .AddXEventRepository<IRegisterRepository, RegisterRepository>();
    }

    public sealed record class Command(Guid Id, string FirstName, string LastName) : ICommand

    internal static async ValueTask<IResult> CreateRegisterAsync(
        CommandHandler<Command> createRegister, IValidator<CreateRegisterRequest> validator, 
        [FromBody] CreateRegisterRequest request, CancellationToken cancellationToken)
    {
        if(await validator.ValidateAsync(request).ConfigureAwait(false) is FailureOperationResult failureOperation)
            return failureOperation.ToResultOperationResult()

        var command = Command.With(request.Id, request.FirstName, request.LastName);

        OperationResult result = await createRegister(command, cancellationToken).ConfigureAwait(false);
        return result.ToResultOperationResult();
    }  

    public sealed class Handler : ICommandHandler<Command>
    {
        private readonly IRegisterRepository _registerRepository;

        public Handler(IRegisterRepository registerRepository)
        {
            _registerRepository = registerRepository ?? throw new ArgumentNullException(nameof(registerRepository));
        }

        public async ValueTask<OperationResult> HandleAsync(Command command, CancellationToken cancellationToken = default)
        {
            OperationResult<Register> registerResult = Register
                .Create(command.Id, command.FirstName, command.LastNale);

            if (registerResult is FailureOperationResult<Register> failureResult)
                return failureResult;

            var persistResult = await _registerRepository
                .PersistAggregateAsync(registerResult.Result, cancellationToken).ConfigureAwait(false);

            return persistResult switch
            {
                FailureOperationResult failureOperation => failureOperation,
                _ => OperationResult.Ok(registerResult.Headers)
            };
        }
    }
}

// You can add the GetPerson route here to read from EntityDataContext
public sealed class GetPerson : IEndpointRoute
{
    public void AddRoutes(IEndpointRouteBuilder app)
    {
        app.MapGet("api/person/{id}", GetPersonAsync)
            .WithTags("Person")
            .WithName("GetPerson")
            .WithXMultiTenancyNameMetadata(MultiTenancyName.DefaultEntityDataContext)
            .AllowAnonymous()
            .Produces<Person>(StatusCodes.Status200OK, ContentType.Json)
            .Produces<ValidationProblemDetails>(StatusCodes.Status404NotFound, ContentType.Json)
            .Produces<ProblemDetails>(StatusCodes.Status500InternalServerError, ContentType.Json);
    }

    internal static async ValueTask<IResult> GetPersonAsync(
        IQueryHandler<GetPersonQuery, Person> getPerson, Guid id, CancellationToken cancellationToken)
    {
        var query = new GetPersonQuery(id);
        OperationResult<Person> result = await getPerson.HandleAsync(query, cancellationToken).ConfigureAwait(false);
        return result.ToResultOperationResult();
    }

    //...
}



// Creates the message handler where you can check for duplicate

// The notification to be published
public readonly record struct RegisterCreatedNotification(
    Guid AggregateId,
    string Name,
    long Version,
    string FirstName,
    string LastName) : INotification;

public sealed class RegisterCreatedMessageHandler : IMessageHandler<RegisterId, RegisterCreated>
{
    private readonly IRegisterRepository _registerRepository;

    public RegisterCreatedMessageHandler(IRegisterRepository registerRepository)
    {
        _registerRepository = registerRepository ?? throw new ArgumentNullException(nameof(registerRepository));
    }

    public async ValueTask<OperationResult> HandleAsync(RegisterCreated message, 
    IMessageHandlerContext messageContext, CancellationToken cancellationToken = default)
    {
        // search in messages collection
        var filter = new MessageFilter 
        { 
            AggregateId = message.AggregateId,
            MessageTypeName = typeof(RegisterCreated).Name
        };

        if (await _registerRepository.ReadMessagesAsync(filter, cancellationToken).AnyAsync(cancellationToken).ConfigureAwait(false))
        {
            return OperationResult.Confict(
                    OperationResultErrorCollection
                        .CreateWith("Id", "The Id already exists."));
        }

        var notification = new RegisterCreatedNotification(message.AggregateId.Key, message.Name, message.Version, message.FirstName, message.LastName);
        messageContext.AddNotification(notification); // The notification get persisted with the message.

        return OperationResult.Ok();
    }
}

// The code here is an example of background service
// NotificationBackgroundSender{T} is an abstract class that derives from BackgroundService
// if registered, the implementation get called by the default IEventRepository implementation
// after aggregate successfully persistence.

public sealed class NotificationSender : NotificationBackgroundSender<NotificationSender>
{
    private readonly IServiceScopeFactory _serviceScopeFactory;
    private readonly JsonSerializerOptions _jsonSerializerOptions;

    public NotificationSender(IServiceScopeFactory serviceScopeFactory, JsonSerializerOptions jsonSerializerOptions)
    {
        _serviceScopeFactory = serviceScopeFactory ?? throw new ArgumentNullException(nameof(serviceScopeFactory));
        _jsonSerializerOptions = jsonSerializerOptions ?? throw new ArgumentNullException(nameof(jsonSerializerOptions));
    }

    // You may customize this code to use something like RabbitMQ
    // The default implementation publishes notification on the same application only for demo purpose.
    protected override async Task SendNotificationsAsync(CancellationToken cancellationToken)
    {
        using var serviceScope = _serviceScopeFactory.CreateAsyncScope();

        // We are in a multi-tenancy environment
        IUnitOfWorkMultiTenancyAccessor multiTenancyAccessor = serviceScope.ServiceProvider.GetRequiredService<IUnitOfWorkMultiTenancyAccessor>();
        multiTenancyAccessor.SetTenantName(MultiTenancyName.DefaultDomainDataContext);

        IUnitOfWork unitOfWork = multiTenancyAccessor.GetUnitOfWork();
        IRepository<NotificationEntity> repository = unitOfWork.GetRepository<NotificationEntity>();

        // fetching notifications that are still activated (newly created) or pending (reset)
        var filter = new NotificationFilter
        {
            Criteria = x => x.EntityState == EntityState.ACTIVATED || x.EntityState == EntityState.PENDING,
            OrderBy = x => x.OrderBy(o => o.CreatedOn),
            Selector = x => x
        };

        using var entity = await repository.FetchAsync(filter, cancellationToken)
                            .LastOrDefaultAsync(cancellationToken)
                            .ConfigureAwait(false);

        // return notification instance from the data
        if (entity?.GetNotification(_jsonSerializerOptions) is not INotification notification) return;

        try
        {
            // We move to another tenancy (the EntityDataContext)
            using (var notificationScope = serviceScope.ServiceProvider.CreateAsyncScope())
            {
                var notificationMultiTenancyAccessor = notificationScope.ServiceProvider.GetRequiredService<IUnitOfWorkMultiTenancyAccessor>();
                notificationMultiTenancyAccessor.SetTenantName(MultiTenancyName.DefaultEntityDataContext);
                var notificationPublisher = notificationScope.ServiceProvider.GetRequiredService<INotificationBus>();

                // This method will call the notification handler matching the type of the notification
                await notificationPublisher.PublishAsync((dynamic)notification, (dynamic)cancellationToken).ConfigureAwait(false);
            }

            // update the notification to DONE state
            entity.OnSuccess();

        }
        catch (Exception exception)
        {
            // update the notification to ONERROR state
            entity.OnError(exception);
        }
        finally
        {
            // persist changes
            await repository.InsertAsync(entity, cancellationToken).ConfigureAwait(false);
            await unitOfWork.PersistAsync(cancellationToken).ConfigureAwait(false);
        }
    }
}

// The notification handler that get called by the NotificationSender
// The code is defined in the same application only for demo purpose, you must move it to another application

public sealed class RegisterCreatedNotificationHandler : INotificationHandler<RegisterCreatedNotification>
{
    private readonly IUnitOfWork _unitOfWork;

    public RegisterCreatedNotificationHandler(IUnitOfWork unitOfWork)
    {
        _unitOfWork = unitOfWork ?? throw new ArgumentNullException(nameof(unitOfWork));
    }

    public async ValueTask HandleAsync(RegisterCreatedNotification notification, CancellationToken cancellationToken = default)
    {
        IWriteRepository<PersonEntity> repository = _unitOfWork.GetWriteRepository<PersonEntity>();

        var newPerson = PersonEntity.Create(
            notification.AggregateId,
            notification.FirstName,
            notification.LastName);

        await repository.InsertAsync(newUser, cancellationToken).ConfigureAwait(false);
        await _unitOfWork.PersistAsync(cancellationToken).ConfigureAwait(false);
    }
}

Api Program definition


var builder = WebApplication.CreateBuilder(args);

builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();

// Register services
builder.Services
    .AddXpandablesServices()
    .AddXUnitOfWorkMultiTenancyAccessor()
    .AddXUnitOfWorkMultiTenancyMiddleware()
    .AddXUnitOfWorkMultiTenancyContext()
    .AddXDataContext<EntityDataContext>(options => options.UseInMemoryDatabase(nameof(EntityDataContext))
    .AddXDataContext<DomainDataContext>(options => options.UseInMemoryDatabase(nameof(DomainDataContext))
    .AddXUnitOfWorkMultiTenancy<EntityDataContext>(nameof(EntityDataContext))
    .AddXUnitOfWorkMultiTenancy<DomainDataContext>(nameof(DomainDataContext))
    .AddXOperationResultMinimalExceptionMiddleware()
    .AddXHandlers(
        options =>
        {
            options.UsePersistenceDecorator();
            options.UseValidatorDecorator();
        },
        ServiceLifetime.Scoped,
        typeof(AddPersonCommand).Assembly)
    .AddXQueryHandlerWrapper()
    .AddXValidator()
    .AddXValidatorDecorator()
    .AddXEventStore()
    .AddXEventRepository()
    .AddXMessagePublisher()
    .AddXNotificationBus()
    .AddXMessageHandlerWrapper()
    .AddXOperationResultConfigureJsonOptions()
    .AddXBackgroundService<INotificationBackgroundSender, NotificationSender>();
    .AddXServiceRegisters(configuration, typeof(AddPersonCommand).Assembly) // Register services from IEndpointRoute implementations
    .Build();

// ... custom registration

var app = builder.Build();

app.UseXpandableApplications()
    .UseXOperationResultMinimalExceptionMiddleware();

// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
    app.UseSwagger();
    app.UseSwaggerUI();
}

app.UseHttpsRedirection();
app.UseXpandableApplications()
    .MapXEndpointRoutes(typeof(AddPesonCommand).Assembly)
    .UseXUnitOfWorkMultiTenancyMiddleware();

app.Run();


Wep Api Test class

Use the same Api test class

Product Compatible and additional computed target framework versions.
.NET net6.0 is compatible.  net6.0-android was computed.  net6.0-ios was computed.  net6.0-maccatalyst was computed.  net6.0-macos was computed.  net6.0-tvos was computed.  net6.0-windows was computed.  net7.0 was computed.  net7.0-android was computed.  net7.0-ios was computed.  net7.0-maccatalyst was computed.  net7.0-macos was computed.  net7.0-tvos was computed.  net7.0-windows was computed.  net8.0 was computed.  net8.0-android was computed.  net8.0-browser was computed.  net8.0-ios was computed.  net8.0-maccatalyst was computed.  net8.0-macos was computed.  net8.0-tvos was computed.  net8.0-windows was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last updated
9.0.0-rc.1 43 10/26/2024
8.1.2 112 9/12/2024
8.0.8 110 6/21/2024
8.0.6 96 5/25/2024
8.0.5 105 5/18/2024
8.0.1 297 2/11/2024
8.0.0 439 12/3/2023
8.0.0-rc.2.1.1 92 11/12/2023
8.0.0-rc.2.1 77 11/6/2023
8.0.0-rc.2.0 71 11/5/2023
7.3.3 492 5/9/2023
7.1.4 559 2/26/2023
7.1.3 603 2/19/2023
7.0.0 661 11/9/2022
7.0.0-rc2.0.1 100 10/12/2022
7.0.0-rc1.0.0 144 9/26/2022
6.1.1 744 8/6/2022
6.0.9 752 7/9/2022
6.0.8 779 6/27/2022
6.0.4 797 3/15/2022
6.0.3 724 2/22/2022
6.0.2 566 1/4/2022
6.0.1 556 12/4/2021
6.0.0 616 11/8/2021
6.0.0-rc.4.3 150 11/3/2021
6.0.0-rc.3.1 163 10/15/2021
6.0.0-rc.3 150 10/14/2021
6.0.0-rc.2 155 9/21/2021
6.0.0-preview.5 167 8/26/2021
5.6.1 666 6/30/2021
5.6.0 671 6/9/2021
5.5.1 631 5/26/2021
5.4.4 631 4/12/2021

Fix Handler/Dispatcher implementations