Eternet.Mediator 2.0.9

Prefix Reserved
dotnet add package Eternet.Mediator --version 2.0.9
                    
NuGet\Install-Package Eternet.Mediator -Version 2.0.9
                    
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="Eternet.Mediator" Version="2.0.9" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="Eternet.Mediator" Version="2.0.9" />
                    
Directory.Packages.props
<PackageReference Include="Eternet.Mediator" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add Eternet.Mediator --version 2.0.9
                    
#r "nuget: Eternet.Mediator, 2.0.9"
                    
#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.
#:package Eternet.Mediator@2.0.9
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=Eternet.Mediator&version=2.0.9
                    
Install as a Cake Addin
#tool nuget:?package=Eternet.Mediator&version=2.0.9
                    
Install as a Cake Tool

Eternet.Mediator

Eternet.Mediator es el núcleo del ecosistema de pipelines de mediator, proporcionando:

  • Pipeline de pasos - Divide la lógica en pasos reutilizables y testeable
  • Testing integrado - Genera clases base de testing automáticamente
  • Type-safe - Comunicación entre pasos con tipado fuerte
  • Source Generators - Código generado en tiempo de compilación con separación de generadores

Tabla de Contenidos


Instalación

dotnet add package Eternet.Mediator

Registrar los servicios en el contenedor:

services.RegisterMediatorServicesAndBehaviors();
services.AddEternetMediatorStepsServices();
services.AddEternetMediatorHandlersServices();

Paquetes opcionales:

dotnet add package Eternet.Mediator.EntityFramework
dotnet add package Eternet.Mediator.Polly
  • Eternet.Mediator.EntityFramework: persistencia para pipelines stateful.
  • Eternet.Mediator.Polly: políticas de retry con Polly para pasos.

Generadores

Eternet.Mediator utiliza dos generadores separados para garantizar que el código de testing nunca se incluya en ejecutables de producción:

1. Eternet.Mediator.Generator (Producción)

Responsable de generar el código de ejecución del pipeline:

  • ✅ Genera PipelineExecutor (ejecuta los pasos del pipeline)
  • ✅ Genera StepsResults (almacena resultados de pasos)
  • ✅ Genera métodos de registro de servicios
  • NO genera código de testing

Uso:

[GenerateExecutePipeline<Step1>]
[GenerateExecutePipeline<Step2>]
[GenerateExecutePipeline<Step3>]
public class MyHandler : DomainResultHandlerAsync<MyHandler.Request, MyHandler.Response>
{
    // ...
}

2. Eternet.Mediator.Testing.Generator (Testing)

Responsable de generar el código para testing de pipelines:

  • ✅ Genera [GeneratePipelineTestBase] atributo
  • ✅ Genera clases {Handler}PipelineTestBase (base para tests)
  • ✅ Genera interfaces para override de pasos
  • Completamente separado del generador de producción

Uso:

[GenerateExecutePipeline<Step1>]
[GenerateExecutePipeline<Step2>]
[GenerateExecutePipeline<Step3>]
[GeneratePipelineTestBase]  // ← Activar generación de TestBase
public class MyHandler : DomainResultHandlerAsync<MyHandler.Request, MyHandler.Response>
{
    // ...
}

Arquitectura:

┌─────────────────────────────────┐
│ Tu Código                       │
│ [GenerateExecutePipeline<...>]  │
│ [GeneratePipelineTestBase]      │
└──────────────┬──────────────────┘
               │
       ┌───────┴─────────┐
       ▼                 ▼
   Generador         Generador
   Producción        Testing
   (Pipeline)        (TestBase)
       │                 │
       ▼                 ▼
   Ejecutables       Proyectos
   de Prod.        de Test

Para más detalles, ver Eternet.Mediator.Testing.Generator/README.md

Para clientes OData con ISG (atributos + DI + metadata CSDL), ver Eternet.Client.OData.Generator/README.md.

Para clientes HTTP con soporte de transportes Internal/Gateway, metadata ApiGatewayEndpoint y selección en runtime vía IGetResponseFactory, ver Eternet.Client.Http.Generator/README.md.


Clientes HTTP Generados

Eternet.Client.Http.Generator genera handlers HTTP a partir de contratos mediator y mantiene compatibilidad con el caso clásico:

[GenerateHttpClient("Default")]
internal abstract class DefaultHttpClient
{
    internal abstract class AddNodeHandler : AddNode;
}

Si el mismo contrato puede consumirse por múltiples transportes, se puede repetir el atributo:

[GenerateHttpClient("Default", Transport = HttpClientTransport.Internal)]
[GenerateHttpClient("Default", Transport = HttpClientTransport.Gateway)]
internal abstract class DefaultHttpClient
{
    internal abstract class GetProductHandler : GetProduct;
}

Con eso el generator emite:

  • AddDefaultHttpClient(...) como alias compatible
  • AddDefaultHttpClientInternal(...)
  • AddDefaultHttpClientGateway(...)
  • AddHttpClientHandlers()

Para el flujo default seguí usando IGetResponse<TResponse>.

Para elegir explícitamente gateway en runtime, usá IGetResponseFactory:

var response = responseFactory
    .Gateway<GetProduct.Response>(OperationNamespace.Customers);

var result = await response.Get(new GetProduct.Request { Id = 7 }, ct);

Las transformaciones de gateway salen de ApiGatewayEndpointAttribute y hoy soportan:

  • namespace publicado
  • route override
  • HTTP method override

Guía completa y ejemplos reales: Eternet.Client.Http.Generator/README.md.


Diagnósticos y CodeFixes

Eternet.Mediator.Generator incluye también el ensamblado Eternet.Mediator.Generator.Analyzers, por lo que los diagnósticos y fixes se distribuyen en el mismo paquete NuGet.

Diagnósticos disponibles:

ID Severidad Mensaje
EM002 Warning Handler '{StepName}' is async but does not have 'Async' suffix
EM003 Warning Handler '{StepName}' has 'Async' suffix but is not async
EM004 Warning Parameter '{ParameterName}' has [KeyedParameter("{Key}")] but the key matches the parameter name. Remove [KeyedParameter] (code fix available).
EM013 Warning Chunk/stateful request should avoid collection payloads

CodeFixes disponibles:

  • EM002: Add 'Async' suffix to class name
  • EM003: Remove 'Async' suffix from class name
  • EM004: Remove redundant [KeyedParameter] attribute

Fix All:

  • EM002/EM003: soportan Document, Project y Solution.
  • EM004: soporta Document, Project y Solution.

Detalle técnico y ejemplos: doc/AnalyzerDiagnostics.md.


Chunk Workflows

For replication, replay, and large backfill commands, keep the request small and let the worker own the repetition.

  • Prefer identifier-only requests, or identifiers plus ExecutionId / ForceStateless when the command also supports stateful pause/resume.
  • Return ChunkCommandResult or another response implementing IChunkCommandResult.
  • Keep HasMore in the response and repeat from the worker instead of looping inside the handler.
  • Use GeneratePipelineTestBase chunk helpers to assert phase and cursor progression.

Reference guide: doc/ChunkedReplicationWorkflows.md.


Creación de un Pipeline

Un pipeline se compone de pasos que se ejecutan secuencialmente. Cada paso puede recibir inputs de pasos anteriores o del request.

1. Definir los Pasos

// Paso 1: Obtener datos del usuario
public class GetUserDataAsync(UserDbContext db)
{
    public async Task<User> Handle(UserId userId, CancellationToken cancellationToken)
    {
        return await db.Users.FindAsync(userId, cancellationToken) 
            ?? throw new UserNotFoundException(userId);
    }
}

// Paso 2: Calcular descuento (recibe el User del paso anterior)
public class CalculateDiscount
{
    public decimal Handle(User user)
    {
        return user.IsPremium ? 0.20m : 0.05m;
    }
}

// Paso 3: Crear orden (recibe User y decimal de pasos anteriores)
public class CreateOrderAsync(OrderDbContext db)
{
    public async Task<Order> Handle(
        User user, 
        decimal discount,
        OrderItems items,
        CancellationToken cancellationToken)
    {
        var order = new Order(user, items, discount);
        db.Orders.Add(order);
        await db.SaveChangesAsync(cancellationToken);
        return order;
    }
}

Nota: si un parámetro de un paso tiene = null como valor por defecto, el analizador lo considera opcional y no emitirá diagnósticos si ese input no está disponible.

Auto-construccion de DTOs de input

Un Handle(...) puede recibir un DTO complejo aunque no exista un input directo de ese tipo, siempre que su construcción sea determinística.

Qué valida el generador (compile-time):

  1. El DTO debe ser concreto (no abstract/interface).
  2. Debe existir un constructor público viable con mayor aridad de forma no ambigua.
  3. Si no puede probar esa construcción, emite EM001.

Qué hace el runtime (ScopedStatesExtensions.GetInstance<T>) al materializar ese DTO:

  1. Resuelve argumentos desde ScopedStates (keyed/unkeyed, incluyendo fallback de nullabilidad cuando corresponde).
  2. Si no alcanza, busca en propiedades del request por nombre/key.
  3. Aplica resolución anidada por [RegisterProperties] (dot-path, concatenated key y leaf key único).
  4. Soporta construcción recursiva de DTOs anidados.
  5. Si detecta ciclo de constructores, detiene la resolución en lugar de entrar en loop.

Límites importantes:

  • No se intenta fallback por constructor para tipos escalares/System-like, interfaces ni colecciones.
  • Si hay múltiples constructores públicos empatados en máxima aridad, la selección es ambigua.
  • Los parámetros opcionales del constructor respetan su valor por defecto.

Detalle completo y ejemplos: ../doc/RegisterProperties.md (sección "DTO Auto-Construction for Step Inputs").

2. Definir el Handler con el Pipeline

[GenerateExecutePipeline<GetUserDataAsync>]
[GenerateExecutePipeline<CalculateDiscount>]
[GenerateExecutePipeline<CreateOrderAsync>]
[GeneratePipelineTestBase]  // ← Genera clase base para testing
public class CreateOrder : DomainResultHandlerAsync<CreateOrder.Request, CreateOrder.Response>
{
    public record Request(UserId UserId, OrderItems Items) : IRequest<Response>;
    
    public record Response(OrderId OrderId, decimal TotalWithDiscount) : DomainResult;

    public override async ValueTask<Response> Handle(Request request, CancellationToken ct)
    {
        // Los resultados de los pasos están disponibles en StepsResults
        var order = request.StepsResults.Order;
        return new Response(order.Id, order.Total);
    }
}

Guardrail ETM003: No Step-to-Step DI

El analizador ETM003 emite error de compilación cuando un step inyecta otro step por constructor.

Regla:

  • Los steps son unidades de pipeline, no servicios reutilizables por DI entre sí.
  • El pipeline orquesta con [GenerateExecutePipeline<...>].
  • Los datos fluyen por parámetros de Handle(...), no por llamadas directas entre steps.

❌ Incorrecto (dispara ETM003)

[GenerateExecutePipeline<ParseInput>]
[GenerateExecutePipeline<ValidateModel>]
public class ProcessRequest : DomainResultHandler<ProcessRequest.Request, ProcessRequest.Response>
{
    public record Request(string Raw) : IRequest<Response>;
    public record Response : DomainResult;
    public override Response Handle(Request request) => new();
}

public class ParseInput
{
    public RequestModel Handle(string raw) => new(raw);
}

public class ValidateModel(ParseInput parseInput)
{
    public ValidationResult Handle(RequestModel model) => parseInput.Handle(model.Raw).Validate();
}

✅ Correcto

public class ParseInput
{
    public RequestModel Handle(string raw) => new(raw);
}

public class ValidateModel
{
    public ValidationResult Handle(RequestModel model) => model.Validate();
}

Pipelines Stateful

Los pipelines stateful persisten resultados de pasos y permiten pausa/reanudación. Se habilitan con [GenerateStatefulExecutePipeline] y un ExecutionId estable.

Requisitos básicos

  • Agregar [GenerateStatefulExecutePipeline] al handler.
  • Proveer ExecutionId (por defecto Request.ExecutionId o Request.Id; configurable con ExecutionIdProperty).
  • Registrar un IPipelineStore (el paquete Eternet.Mediator.EntityFramework provee implementación EF Core).
  • Opcional: implementar IRequestStateful para habilitar ForceStateless.

Ejemplo mínimo

[GenerateStatefulExecutePipeline]
[GenerateExecutePipeline<LoadDraftState>]
[GenerateExecutePipeline<AwaitApproval>]
[GenerateExecutePipeline<FinalizeDraft>]
public sealed partial class ProcessDraft
    : DomainResultHandler<ProcessDraft.Request, ProcessDraft.Response>
{
    public partial record Request(string? ExecutionId) : IRequest<Response>, IRequestStateful
    {
        public bool ForceStateless { get; init; }
    }

    public record Response : DomainResult;
}
  • Si ForceStateless = true, el pipeline se ejecuta sin persistencia (ExecutionId puede ser null).
  • Si ForceStateless = false, ExecutionId es obligatorio en pipelines stateful.

Persistencia con EF Core

services.AddEternetPipelineStore(options =>
{
    options.UseSqlServer(connectionString);
});

// SQLite (dev/test)
services.AddEternetPipelineStoreSqlite("DataSource=:memory:");

Reejecución de pasos en resume

Cada paso puede controlar si reutiliza resultados persistidos:

  • ReuseIfAvailable (default): reutiliza resultados guardados.
  • AlwaysReExecute: siempre re-ejecuta el paso.
  • ReExecuteOnResume: re-ejecuta solo cuando se reanuda desde pausa.
[GenerateExecutePipeline<FetchExternalData>(ReExecutionPolicy = StepReExecutionPolicy.AlwaysReExecute)]

Snapshots y tags

  • GenerateStatefulExecutePipeline soporta StoreRequestSnapshot (default true). Si registras IPipelineRequestSnapshotter, se guarda un snapshot del request.
  • IPipelineStore expone tags (SetTagsAsync/GetTagsAsync) y búsqueda de pausas por tags.

Replay parcial entre ejecuciones

Para escenarios de diseño/prueba (por ejemplo evitar costo de LLMs), Eternet.Mediator.EntityFramework expone IPipelineReplayStore:

  • GetAttemptsAsync(executionId) para listar intentos históricos.
  • SeedAttemptFromAsync(request) para crear un nuevo intento basado en uno previo, eligiendo qué pasos se deben re-ejecutar.
var replayStore = serviceProvider.GetRequiredService<IPipelineReplayStore>();

var result = await replayStore.SeedAttemptFromAsync(
    new PipelineReplaySeedRequest
    {
        SourceExecutionId = previousExecutionId,
        SourceAttemptId = previousAttemptId,
        TargetExecutionId = newExecutionId,
        TargetAttemptId = newAttemptId,
        StepOrdersToReExecute = [4, 5],
        ReExecuteDownstream = true
    },
     cancellationToken);

Si quieres preparar replay en una sola llamada (resolver source attempt, target execution/attempt y seed), usa IPipelineReplayPreparer:

var replayPreparer = serviceProvider.GetRequiredService<IPipelineReplayPreparer>();

var prepared = await replayPreparer.PrepareReplayAttemptAsync(
    new PipelineReplayPrepareRequest
    {
        SourceExecutionId = previousExecutionId,
        // SourceAttemptId = null => usa el intento más reciente
        StepOrdersToReExecute = [4, 5],
        ReExecuteDownstream = true,
        SeedRequestInvocationCache = true
    },
    cancellationToken);
  • TargetExecutionId es opcional (si no se envía, se genera uno nuevo).
  • Si el target ya tiene un intento activo, falla por defecto para evitar ambigüedad.
  • SeedRequestInvocationCache copia lineage de request/response cache completa del source al target.
  • El replay de cache permanece conservador: EnableResponseCacheRead sigue deshabilitado por defecto y solo usa payloads completos.
  • IPipelineReplayStore sigue disponible para escenarios de bajo nivel y compatibilidad existente.

Retry de Pasos

Los pasos pueden declarar RetryPolicy en los atributos. Por defecto no hay reintentos; para habilitarlos, usa el paquete Eternet.Mediator.Polly.

[GenerateExecutePipeline<FetchExternalData>(RetryPolicy = StepRetryPolicy.TransientExternal)]

Registro con Polly:

services.AddEternetMediatorPollyRetries();

Opcionalmente, puedes personalizar políticas:

services.AddEternetMediatorPollyRetries(options =>
{
    options.Policies[StepRetryPolicy.TransientExternal] = new StepRetryPolicyDefinition
    {
        Policy = StepRetryPolicy.TransientExternal,
        MaxAttempts = 4,
        ExhaustedBehavior = StepRetryExhaustedBehavior.Pause,
        ShouldRetry = ex => ex is TimeoutException,
        GetDelay = attempt => TimeSpan.FromSeconds(attempt)
    };
});

También puedes sobrescribir por paso implementando IStepRetryPolicyDefinitionProvider. En pipelines stateful, cuando se agotan reintentos con ExhaustedBehavior = Pause, el pipeline se pausa y se registran tags retry.transient.*.

Mutación de inputs en reintentos (LLM-friendly)

Para escenarios donde necesitas ajustar el prompt o inputs entre intentos, implementa:

  • IStepRetryInputMutationProvider<TRequest> (tipado fuerte)
  • o IStepRetryInputMutationProvider (no genérico)

El hook se ejecuta según InputMutationMode en la policy efectiva:

  • RetryOnly (default): solo en intentos con AttemptNumber > 1.
  • AllAttempts: también antes del primer intento.

Luego del hook, el runtime vuelve a enlazar los inputs del step desde StepsResults/ScopedStates antes de llamar nuevamente a Handle(...). El contrato es ValueTask, para soportar mutaciones sync o async.

public sealed class ExecuteNamingReviewStepAsync
    : IStepRetryPolicyDefinitionProvider,
      IStepRetryInputMutationProvider<RunReview.Request>
{
    public StepRetryPolicyDefinition? GetRetryPolicyDefinition(StepRetryContext context) => new()
    {
        Policy = context.Policy,
        MaxAttempts = 3,
        InputMutationMode = StepRetryInputMutationMode.AllAttempts,
        ShouldRetry = ex => ex is TimeoutException,
        GetDelay = _ => TimeSpan.Zero
    };

    public ValueTask OnRetryAttemptAsync(
        RunReview.Request request,
        ScopedStates scopedStates,
        StepRetryAttemptContext context,
        CancellationToken cancellationToken)
    {
        request.StepsResults!.NamingReviewStepDefinition =
            request.StepsResults!.NamingReviewStepDefinition with
            {
                Prompt = $"Retry attempt {context.AttemptNumber}: stricter JSON output."
            };

        return ValueTask.CompletedTask;
    }
}

Este mecanismo funciona también cuando el step está envuelto por adapters generados (I{StepName}Step), ya que el adapter forwardea tanto IStepRetryPolicyDefinitionProvider como IStepRetryInputMutationProvider.


Testing de Pipelines

Estrategia de Testing

El testing de Mediator sigue una estrategia piramidal donde cada nivel tiene un propósito distinto:

Nivel Herramienta Propósito Cantidad
Unit Tests Instanciación directa del step Lógica interna, edge cases, complejidad ciclomática Muchos
Pipeline Tests ExecutePipelineAsync Orquestación, StepsResults, cortocircuitos, ChangeTracker Pocos
Integration Tests ExecuteWithMediatorAsync Behaviors (validation, transactions) Mínimos

💡 Regla de Oro: Si testeaste exhaustivamente cada step, el pipeline test NO debe re-testear su lógica. Su valor está en verificar la orquestación.

Qué Testear en Cada Nivel

Unit Test (Step):

// Testear: lógica interna, edge cases, mensajes de error
var sut = new CheckPersonalTaxIdAreValid();
var result = sut.Handle(records);
result.Error.Message.Should().Contain(CheckPersonalTaxIdAreValid.InvalidPersonalTaxIdMessage("123"));

Pipeline Test (PipelineTestBase):

// Testear: orquestación, StepsResults, cortocircuitos
var response = await ExecutePipelineAsync(request, TestContext.Current.CancellationToken);
request.StepsResults.PrivateHealthInsurances.Should().HaveCount(2);
request.StepsResults.Source.Should().Be("OSDE");

Integration Test:

// Testear: behaviors (validation, transactions)
var response = await ExecuteWithMediatorAsync(request, TestContext.Current.CancellationToken);
response.IsValidationError.Should().BeTrue(); // ValidationBehavior rechazó request inválido

GeneratePipelineTestBase

El atributo [GeneratePipelineTestBase] genera automáticamente una clase base abstracta que facilita el testing del pipeline.

[GenerateExecutePipeline<Step1>]
[GenerateExecutePipeline<Step2>]
[GeneratePipelineTestBase]  // ← Añadir este atributo
public class MyHandler : DomainResultHandlerAsync<...>

Esto genera:

📁 Generated/
  └── MyHandlerPipelineTestBase.g.cs
      ├── class MyHandlerPipelineTestBase (abstract)
      ├── interface IOverrideStep1
      ├── interface IOverrideStep2
      └── interface IOverrideMyHandlerSteps

Tipos de Tests

1. Test Unitario Aislado (sin behaviors)

Usa ExecutePipelineAsync para ejecutar solo los pasos, sin pasar por behaviors como validación o caching:

public class CreateOrderTests : CreateOrderPipelineTestBase
{
    [Fact]
    public async Task Should_CreateOrder_WithDiscount()
    {
        // Arrange
        var request = new CreateOrder.Request(
            UserId: new UserId("user-1"),
            Items: new OrderItems([new("product-1", 2)])
        );

        // Act - Ejecuta solo los pasos del pipeline
        var response = await ExecutePipelineAsync(request);

        // Assert
        response.TotalWithDiscount.Should().BeGreaterThan(0);
    }
}
2. Test de Integración (con behaviors completos)

Usa ExecuteWithMediatorAsync para ejecutar el pipeline completo incluyendo todos los IPipelineBehavior:

public class CreateOrderIntegrationTests : CreateOrderPipelineTestBase
{
    [Fact]
    public async Task Should_ValidateRequest_BeforeProcessing()
    {
        // Arrange
        var invalidRequest = new CreateOrder.Request(
            UserId: default,  // Invalid
            Items: new OrderItems([])
        );

        // Act - Ejecuta con ValidationBehavior, CachingBehavior, etc.
        var response = await ExecuteWithMediatorAsync(invalidRequest);

        // Assert
        response.IsValidationError.Should().BeTrue();
    }
}

Sobreescribir Pasos

Hay 3 formas de sobreescribir pasos para testing (en orden de prioridad):

Opción 1: Interfaces Individuales (Mayor Prioridad)

Para sobreescribir un paso específico, implementa su interfaz individual:

public class TestWithMockedUser : CreateOrderPipelineTestBase, IOverrideGetUserDataAsync
{
    public Task<User> GetUserDataAsyncAsync(
        CreateOrder.Request request, 
        UserId userId, 
        CancellationToken ct)
    {
        // Retorna un usuario mockeado
        return Task.FromResult(new User 
        { 
            Id = userId, 
            Name = "Test User",
            IsPremium = true 
        });
    }

    [Fact]
    public async Task PremiumUser_Gets20PercentDiscount()
    {
        var request = new CreateOrder.Request(...);
        
        // El paso GetUserDataAsync usará nuestro mock
        var response = await ExecutePipelineAsync(request);
        
        response.TotalWithDiscount.Should().Be(80); // 20% descuento
    }
}
Opción 2: Interface General (Prioridad Media)

Para sobreescribir múltiples pasos, implementa la interfaz general:

public class TestWithAllStepsMocked : CreateOrderPipelineTestBase, IOverrideCreateOrderSteps
{
    public Task<User> GetUserDataAsyncAsync(...) => 
        Task.FromResult(new User { IsPremium = true });

    public Task<decimal> CalculateDiscountAsync(...) => 
        Task.FromResult(0.50m); // 50% descuento fijo para test

    public Task<Order> CreateOrderAsyncAsync(...) => 
        Task.FromResult(new Order { Id = "test-order" });

    [Fact]
    public async Task AllStepsAreMocked()
    {
        var response = await ExecutePipelineAsync(new Request(...));
        // Todos los pasos usan nuestros mocks
    }
}
Opción 3: Override de Métodos Virtuales (Menor Prioridad)

⚠️ Nota: Esta opción existe por compatibilidad pero se recomienda usar las interfaces (IOverride{StepName} o IOverride{Handler}Steps) que son más explícitas y fáciles de mantener.

Sobreescribe los métodos virtuales Execute{StepName}Async:

public class TestWithVirtualOverride : CreateOrderPipelineTestBase
{
    protected override Task<User> ExecuteGetUserDataAsyncAsync(
        CreateOrder.Request request,
        UserId userId,
        CancellationToken ct)
    {
        return Task.FromResult(new User { Name = "Virtual Override User" });
    }

    [Fact]
    public async Task UsesVirtualOverride()
    {
        var response = await ExecutePipelineAsync(new Request(...));
        // Usa el override virtual
    }
}

Ejemplos Completos

Ejemplo 1: Test con Servicios Mockeados

💡 Nota: Esta opción existe para casos donde se necesita mockear dependencias externas, pero se recomienda preferir el uso de las interfaces IOverride{StepName} para sobreescribir el comportamiento de pasos completos.

public class OrderCreationWithMockedDb : CreateOrderPipelineTestBase
{
    private readonly Mock<IOrderRepository> _orderRepoMock = new();

    protected override void ConfigureServices(IServiceCollection services)
    {
        // Inyectar mocks en el contenedor
        services.AddSingleton(_orderRepoMock.Object);
    }

    [Fact]
    public async Task Should_SaveOrder_ToRepository()
    {
        // Arrange
        _orderRepoMock
            .Setup(x => x.SaveAsync(It.IsAny<Order>(), It.IsAny<CancellationToken>()))
            .ReturnsAsync(new Order { Id = "saved-123" });

        // Act
        var response = await ExecutePipelineAsync(new Request(...));

        // Assert
        _orderRepoMock.Verify(
            x => x.SaveAsync(It.IsAny<Order>(), It.IsAny<CancellationToken>()), 
            Times.Once);
    }
}
Ejemplo 2: Test de Error en Paso Intermedio

⚠️ Recomendación: Para testear el manejo de errores de un paso específico, es preferible escribir tests unitarios directamente sobre el paso. Este patrón debe usarse solo cuando se necesita verificar la propagación de errores a través del pipeline completo.

public class OrderCreationErrorHandling : CreateOrderPipelineTestBase, IOverrideGetUserDataAsync
{
    public Task<User> GetUserDataAsyncAsync(...)
    {
        // Simular que el usuario no existe
        throw new UserNotFoundException("user-not-found");
    }

    [Fact]
    public async Task Should_PropagateException_WhenUserNotFound()
    {
        var request = new Request(UserId: new("user-not-found"), ...);

        await Assert.ThrowsAsync<UserNotFoundException>(
            () => ExecutePipelineAsync(request));
    }
}
Ejemplo 3: Test con servicio personalizado (Cache en por ejemplo)
public class OrderCreationWithCustomCache : CreateOrderPipelineTestBase
{
    protected override void ConfigureHybridCache(IServiceCollection services)
    {
        // Usar un cache real en memoria para este test
        services.AddMemoryCache();
        services.AddSingleton<IHybridCache, MemoryHybridCache>();
    }

    [Fact]
    public async Task Should_CacheUserData()
    {
        var request = new Request(...);

        // Primera llamada - hit a DB
        await ExecuteWithMediatorAsync(request);
        
        // Segunda llamada - debería usar cache
        await ExecuteWithMediatorAsync(request);

        // Verificar que solo hubo 1 hit a DB
    }
}
Ejemplo 4: Test de Pipeline con StepResult (Manejo de Errores Idiomático)

La clase base PipelineTestBase hereda de BaseHandlerResponses, lo que permite usar los métodos idiomáticos para crear errores. Además, existe conversión implícita de errores a StepResult<T>:

public class ValidationStepTests : ValidateOrderPipelineTestBase, IOverrideCheckInventoryAsync
{
    public Task<StepResult<InventoryStatus>> CheckInventoryAsyncAsync(
        Request request,
        OrderItems items,
        CancellationToken ct)
    {
        // ✅ Forma idiomática: usar métodos heredados de BaseHandlerResponses
        // La conversión implícita de InvalidStateError a StepResult<T> se aplica automáticamente
        return InvalidStateError("Insufficient inventory");
    }

    [Fact]
    public async Task Should_ReturnError_WhenInventoryInsufficient()
    {
        var response = await ExecutePipelineAsync(new Request(...));

        response.IsInvalidStateError.Should().BeTrue();
        response.AsInvalidStateError().Error.Should().Contain("inventory");
    }
}
Ejemplo 4b: Resultados acotados (ResultOrX)

Si un paso solo puede devolver un tipo de error, es preferible usar wrappers acotados: ResultOrInvalidState<T>, ResultOrNotFound<T>, ResultOrValidationError<T> o ResultOrPause<T>. Esto limita el contrato del paso y mantiene el pipeline más legible.

public class CheckInventory : BaseHandlerResponses
{
    public ResultOrInvalidState<InventoryStatus> Handle(Request request)
    {
        if (request.Quantity <= 0)
        {
            return InvalidStateResult<InventoryStatus>("Quantity must be positive");
        }

        return new InventoryStatus(request.Quantity);
    }
}

Nota: El analizador puede sugerir estos wrappers cuando detecta pasos que solo devuelven un tipo de error.

Ejemplo 5: Herencia Multinivel (Composición de Tests)

El framework soporta herencia multinivel, permitiendo crear jerarquías de clases de test que reutilizan y especializan comportamiento. Este patrón es extremadamente poderoso para:

  • Reutilización: Define mocks base que múltiples tests pueden heredar
  • Especialización: Sobrescribe solo los pasos específicos que necesitas cambiar
  • Composición: Combina overrides de múltiples niveles
Nivel 1: Clase Base con Mocks Compartidos
// Base abstracta que proporciona mocks para TODOS los pasos
// Implementa IOverrideCreateOrderSteps para sobrescribir todos los pasos
public abstract class BaseTestWithMockedSteps 
    : CreateOrderPipelineTestBase,
      IOverrideCreateOrderSteps
{
    public Task<User> GetUserDataAsyncAsync(...)
    {
        return Task.FromResult(new User 
        { 
            Id = userId, 
            Name = "Base Mock User",
            IsPremium = true 
        });
    }

    public Task<decimal> CalculateDiscountAsync(...)
    {
        // Mock base: 15% descuento para todos
        return Task.FromResult(0.15m);
    }

    public Task<Order> CreateOrderAsyncAsync(...)
    {
        return Task.FromResult(new Order { Id = "mock-order", Total = 100.00m });
    }
}
Nivel 2: Sobrescribir UN paso específico
// Hereda TODOS los mocks del base, pero sobrescribe solo GetUser
public class TestWithPremiumUser : BaseTestWithMockedSteps, IOverrideGetUserDataAsync
{
    // ✅ Mayor prioridad: individual interface sobrescribe el base
    public Task<User> GetUserDataAsyncAsync(...)
    {
        return Task.FromResult(new User 
        { 
            Id = userId,
            Name = "Premium Test User",
            IsPremium = true,
            DiscountLevel = 25  // Usuario especial con 25% descuento
        });
    }

    [Fact]
    public async Task PremiumUser_GetsSuperDiscount()
    {
        var response = await ExecutePipelineAsync(new Request(...));
        
        // GetUser: usa ESTE override (individual interface - máxima prioridad)
        // CalculateDiscount: usa BaseTestWithMockedSteps (15% del base)
        // CreateOrder: usa BaseTestWithMockedSteps
        response.UserName.Should().Be("Premium Test User");
    }
}
Nivel 3: Cadena de Herencia Profunda
// Hereda de Level2 y agrega OTRO override adicional
public class TestWithPremiumUserAndCustomTax 
    : TestWithPremiumUser, 
      IOverrideCalculateDiscountAsync
{
    // Override adicional: descuento personalizado
    public Task<decimal> CalculateDiscountAsync(...)
    {
        return Task.FromResult(0.30m); // 30% descuento!
    }

    [Fact]
    public async Task Level3_CombinesAllOverrides()
    {
        var response = await ExecutePipelineAsync(new Request(...));
        
        // GetUser: desde Level2 (TestWithPremiumUser)
        // CalculateDiscount: desde Level3 (ESTE nivel) - 30%
        // CreateOrder: desde Level1 (BaseTestWithMockedSteps)
        
        response.UserName.Should().Be("Premium Test User");
        response.Discount.Should().Be(0.30m);
    }
}

⚠️ Nota Importante: Los métodos en BaseTestWithMockedSteps NO son virtual porque se está usando el patrón de interfaces (IOverrideCreateOrderSteps). Los niveles superiores sobrescriben usando interfaces de mayor prioridad (IOverrideGetUserDataAsync, etc.), no mediante herencia virtual.

Prioridad en Herencia Multinivel:

┌─────────────────────────────────────────────────────┐
│ 1. Individual Interface (IOverride{StepName})      │ ← MAYOR
│    Ejemplo: IOverrideGetUserDataAsync              │
├─────────────────────────────────────────────────────┤
│ 2. All Steps Interface (IOverride{Handler}Steps)   │
│    Ejemplo: IOverrideCreateOrderSteps              │
├─────────────────────────────────────────────────────┤
│ 3. Virtual Method Override                         │ ← MENOR
│    Ejemplo: ExecuteGetUserDataAsyncAsync()         │
└─────────────────────────────────────────────────────┘

Ventajas de la Herencia Multinivel:

  • DRY: Define mocks comunes una vez
  • Flexibilidad: Cada nivel puede especializar solo lo que necesita
  • Mantenibilidad: Cambios en el base se propagan automáticamente
  • Claridad: La jerarquía documenta las dependencias entre tests

Métodos disponibles heredados de BaseHandlerResponses:

Método Descripción
InvalidStateError(message) Error de estado inválido
NotFoundError(message) Recurso no encontrado
ValidationError(message) Error de validación
PauseResult<T>(reason) Pausa acotada para ResultOrPause<T>
InvalidStateResult<T>(message, result?) Resultado o InvalidStateError
NotFoundResult<T>(message) Resultado o NotFoundError
ValidationResult<T>(message, result?) Resultado o BadRequestError
ExceptionError(exception) Error por excepción
Next() Continuar al siguiente paso
Break() Detener el pipeline
Break<T>(value) Detener con un valor

API Reference

Atributos

Atributo Uso
[GenerateExecutePipeline<TStep>] Define un paso en el pipeline
[GenerateStatefulExecutePipeline] Habilita persistencia + pausa/reanudación para el pipeline
[GeneratePipelineTestBase] Genera la clase base de testing
[KeyedResult("name")] Nombra el resultado de un paso
[KeyedParameter("name")] Mapea un parámetro a una clave específica
[RegisterProperties] Expone propiedades como inputs por key (incluye traversal anidado opcional)
RegisterProperties

[RegisterProperties] sirve para "aplanar" propiedades y hacerlas accesibles a pasos por key (ej: "PlanItem.Title").

  • Recomendado: aplicarlo en propiedades del request (opt-in selectivo) para evitar aplanar requests grandes sin control.
  • Control: Depth (default -1 ⇒ se interpreta como Depth = 1 cuando el atributo esta presente) y RegisterLeafWhenUnique (default true).
  • Keys:
    • dot-path: siempre disponible cuando traversal esta habilitado
    • leaf: solo cuando es unico (si RegisterLeafWhenUnique=true)
    • concatenated dot-path: PlanItem.TitleplanItemTitle (solo cuando es unico y RegisterLeafWhenUnique=true)

Detalles: ver ../doc/RegisterProperties.md.

Stateful y Retry

Los atributos de pasos soportan:

  • ReExecutionPolicy (StepReExecutionPolicy) para controlar reutilización en resume.
  • RetryPolicy (StepRetryPolicy) para reintentos por paso.

Ejemplo:

[GenerateExecutePipeline<FetchExternalData>(
    ReExecutionPolicy = StepReExecutionPolicy.ReExecuteOnResume,
    RetryPolicy = StepRetryPolicy.TransientExternal)]

IRequestStateful (opcional)

Si tu request implementa IRequestStateful, el runtime habilita ForceStateless para ejecutar un pipeline stateful sin persistencia.

public partial record Request(string? ExecutionId) : IRequest<Response>, IRequestStateful
{
    public bool ForceStateless { get; init; }
}

Clase Base de Test Generada

La clase {Handler}PipelineTestBase hereda de BaseHandlerResponses, proporcionando acceso idiomático a los métodos de creación de errores.

Miembro Descripción
ExecutePipelineAsync(request) Ejecuta solo los pasos (sin behaviors)
ExecuteWithMediatorAsync(request) Ejecuta el pipeline completo con behaviors
GetService<T>() Obtiene un servicio del contenedor
ConfigureServices(services) Override para configurar servicios custom
ConfigureHybridCache(services) Override para configurar el cache
Execute{StepName}Async(...) Métodos virtuales para cada paso

Métodos Heredados de BaseHandlerResponses

Método Descripción
InvalidStateError(message) Crea un error de estado inválido
NotFoundError(message) Crea un error de recurso no encontrado
ValidationError(message) Crea un error de validación
PauseResult<T>(reason) Crea ResultOrPause<T>
InvalidStateResult<T>(message, result?) Crea ResultOrInvalidState<T>
NotFoundResult<T>(message) Crea ResultOrNotFound<T>
ValidationResult<T>(message, result?) Crea ResultOrValidationError<T>
ExceptionError(exception) Crea un error a partir de una excepción
Next() Indica que el paso debe continuar
Break() Detiene la ejecución del pipeline
Break<T>(value) Detiene el pipeline retornando un valor
StepResult<T>(value) Crea un StepResult con un valor

Wrappers de Resultado Acotado

Wrapper Respuesta permitida
ResultOrPause<T> T o pausa
ResultOrInvalidState<T> T o InvalidStateError
ResultOrNotFound<T> T o NotFoundError
ResultOrValidationError<T> T o BadRequestError

Interfaces Generadas

Interface Descripción
IOverride{StepName} Sobreescribe un paso específico
IOverride{HandlerName}Steps Sobreescribe todos los pasos

Prioridad de Overrides

1. IOverride{StepName}           ← Mayor prioridad
2. IOverride{HandlerName}Steps   
3. Virtual method override       ← Menor prioridad

Migración desde Testing Manual

Si tienes tests manuales, migrar a GeneratePipelineTestBase es sencillo:

Antes (manual):

public class MyTests
{
    [Fact]
    public async Task Test()
    {
        var services = new ServiceCollection();
        services.AddMediator();
        // ... configurar todos los servicios manualmente
        var provider = services.BuildServiceProvider();
        var mediator = provider.GetRequiredService<IMediator>();
        
        var response = await mediator.Send(request);
    }
}

Después (con TestBase):

public class MyTests : MyHandlerPipelineTestBase
{
    [Fact]
    public async Task Test()
    {
        // El contenedor ya está configurado
        var response = await ExecutePipelineAsync(request);
    }
}

Recursos Adicionales


Licencia

Copyright © Eternet

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

NuGet packages (5)

Showing the top 5 NuGet packages that depend on Eternet.Mediator:

Package Downloads
Eternet.AspNetCore.DocumentDb.Crud

AspNet Core DocumentDb CRUD framework using Marten for Document DB and Events Store

Eternet.Crud.Relational

Eternet Crud Relational Behaviors and Abstractions

Eternet.Crud.Document

Eternet CRUD Document Behaviors and Abstractions

Eternet.Messaging.Mediator.RabbitMQ

Mediator integration for Eternet.Messaging.RabbitMQ, providing zero-boilerplate RabbitMQ consumers backed by Eternet.Mediator.

Eternet.UserPreferences.Client

Package Description

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
2.0.9 43 3/27/2026
2.0.8 56 3/27/2026
2.0.7 103 3/20/2026
2.0.6 83 3/20/2026
2.0.5 118 3/20/2026
2.0.4 113 3/20/2026
2.0.3 100 3/18/2026
2.0.2 145 3/18/2026
2.0.1 81 3/18/2026
1.3.13 80 3/18/2026
1.3.12 135 3/14/2026
1.3.11 213 2/26/2026
1.3.10 91 2/25/2026
1.3.9 141 2/18/2026
1.3.8 106 2/17/2026
1.3.7 100 2/13/2026
1.3.6 100 2/13/2026
1.3.5 146 2/11/2026
1.3.4 118 2/10/2026
1.3.3 134 2/9/2026
Loading failed