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
<PackageReference Include="Eternet.Mediator" Version="2.0.9" />
<PackageVersion Include="Eternet.Mediator" Version="2.0.9" />
<PackageReference Include="Eternet.Mediator" />
paket add Eternet.Mediator --version 2.0.9
#r "nuget: Eternet.Mediator, 2.0.9"
#:package Eternet.Mediator@2.0.9
#addin nuget:?package=Eternet.Mediator&version=2.0.9
#tool nuget:?package=Eternet.Mediator&version=2.0.9
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
- Generadores
- Clientes HTTP Generados
- Diagnósticos y CodeFixes
- Creación de un Pipeline
- Pipelines Stateful
- Chunk Workflows
- Retry de Pasos
- Testing de Pipelines
- API Reference
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 compatibleAddDefaultHttpClientInternal(...)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 nameEM003:Remove 'Async' suffix from class nameEM004:Remove redundant [KeyedParameter] attribute
Fix All:
EM002/EM003: soportanDocument,ProjectySolution.EM004: soportaDocument,ProjectySolution.
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/ForceStatelesswhen the command also supports stateful pause/resume. - Return
ChunkCommandResultor another response implementingIChunkCommandResult. - Keep
HasMorein the response and repeat from the worker instead of looping inside the handler. - Use
GeneratePipelineTestBasechunk 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
= nullcomo 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):
- El DTO debe ser concreto (no abstract/interface).
- Debe existir un constructor público viable con mayor aridad de forma no ambigua.
- Si no puede probar esa construcción, emite
EM001.
Qué hace el runtime (ScopedStatesExtensions.GetInstance<T>) al materializar ese DTO:
- Resuelve argumentos desde
ScopedStates(keyed/unkeyed, incluyendo fallback de nullabilidad cuando corresponde). - Si no alcanza, busca en propiedades del request por nombre/key.
- Aplica resolución anidada por
[RegisterProperties](dot-path, concatenated key y leaf key único). - Soporta construcción recursiva de DTOs anidados.
- 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 defectoRequest.ExecutionIdoRequest.Id; configurable conExecutionIdProperty). - Registrar un
IPipelineStore(el paqueteEternet.Mediator.EntityFrameworkprovee implementación EF Core). - Opcional: implementar
IRequestStatefulpara habilitarForceStateless.
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,ExecutionIdes 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
GenerateStatefulExecutePipelinesoportaStoreRequestSnapshot(defaulttrue). Si registrasIPipelineRequestSnapshotter, se guarda un snapshot del request.IPipelineStoreexpone 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);
TargetExecutionIdes 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.
SeedRequestInvocationCachecopia lineage de request/response cache completa del source al target.- El replay de cache permanece conservador:
EnableResponseCacheReadsigue deshabilitado por defecto y solo usa payloads completos. IPipelineReplayStoresigue 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 conAttemptNumber > 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}oIOverride{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
BaseTestWithMockedStepsNO sonvirtualporque 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 comoDepth = 1cuando el atributo esta presente) yRegisterLeafWhenUnique(defaulttrue). - Keys:
- dot-path: siempre disponible cuando traversal esta habilitado
- leaf: solo cuando es unico (si
RegisterLeafWhenUnique=true) - concatenated dot-path:
PlanItem.Title→planItemTitle(solo cuando es unico yRegisterLeafWhenUnique=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
- Documentación de Behaviors
- Documentación de Comandos
- Ejemplos Completos
- Analyzer diagnostics y codefixes
- Stateful Pipelines (resumen)
- Stateful Pipelines (full)
- Step Retry Plan
- AGENTS.md - Instrucciones para AI agents
Licencia
Copyright © Eternet
| Product | Versions 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. |
-
net10.0
- Eternet.Mediator.Abstractions (>= 2.0.7)
- Eternet.Mediator.Stateful.Abstractions (>= 2.0.3)
- FluentValidation (>= 12.0.0)
- Microsoft.Extensions.DependencyInjection.Abstractions (>= 9.0.10)
- Microsoft.Extensions.Logging.Abstractions (>= 9.0.10)
- OneOf (>= 3.0.271)
- OneOf.SourceGenerator (>= 3.0.271)
-
net9.0
- Eternet.Mediator.Abstractions (>= 2.0.7)
- Eternet.Mediator.Stateful.Abstractions (>= 2.0.3)
- FluentValidation (>= 12.0.0)
- Microsoft.Extensions.DependencyInjection.Abstractions (>= 9.0.10)
- Microsoft.Extensions.Logging.Abstractions (>= 9.0.10)
- OneOf (>= 3.0.271)
- OneOf.SourceGenerator (>= 3.0.271)
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 |