From 95f6fc1cea66eee431c7c06d8107e73ff28484ed Mon Sep 17 00:00:00 2001 From: StuartFerguson Date: Wed, 16 Sep 2026 20:41:45 +0100 Subject: [PATCH 1/2] Handle domain event handler failures concurrently --- .../DomainEventControllerTests.cs | 140 ++++++++++++++++++ TransactionProcessor.Tests/ImposterUsings.cs | 2 + .../Controllers/DomainEventController.cs | 65 +++++++- 3 files changed, 200 insertions(+), 7 deletions(-) create mode 100644 TransactionProcessor.Tests/ControllerTests/DomainEventControllerTests.cs diff --git a/TransactionProcessor.Tests/ControllerTests/DomainEventControllerTests.cs b/TransactionProcessor.Tests/ControllerTests/DomainEventControllerTests.cs new file mode 100644 index 00000000..cc8135aa --- /dev/null +++ b/TransactionProcessor.Tests/ControllerTests/DomainEventControllerTests.cs @@ -0,0 +1,140 @@ +using Imposter.Abstractions; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Shouldly; +using SimpleResults; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Shared.DomainDrivenDesign.EventSourcing; +using Shared.EventStore.Aggregate; +using Shared.EventStore.EventHandling; +using Shared.General; +using Shared.Serialisation; +using Shared.Logger; +using TransactionProcessor.Controllers; +using TransactionProcessor.DomainEvents; +using Xunit; + +namespace TransactionProcessor.Tests.ControllerTests; + +public class DomainEventControllerTests +{ + [Fact] + public async Task PostEventAsync_WhenAllHandlersSucceed_ReturnsOk() + { + InitialiseDependencies(); + DomainEvent domainEvent = CreateDomainEvent(); + DomainEventController controller = CreateController( + domainEvent, + new ResultDomainEventHandler(Result.Success())); + + IActionResult result = await controller.PostEventAsync(domainEvent, CancellationToken.None); + + result.ShouldBeOfType(); + } + + [Fact] + public async Task PostEventAsync_WhenNoHandlersAreConfigured_ReturnsOk() + { + InitialiseDependencies(); + DomainEvent domainEvent = CreateDomainEvent(); + DomainEventController controller = CreateController(domainEvent); + + IActionResult result = await controller.PostEventAsync(domainEvent, CancellationToken.None); + + result.ShouldBeOfType(); + } + + [Fact] + public async Task PostEventAsync_WhenHandlerReturnsFailure_ReturnsDetailedProblemDetails() + { + InitialiseDependencies(); + DomainEvent domainEvent = CreateDomainEvent(); + IDomainEventHandler handler = new ResultDomainEventHandler(Result.Failure("Transaction was not found")); + + DomainEventController controller = CreateController(domainEvent, handler); + + ObjectResult result = (ObjectResult)await controller.PostEventAsync(domainEvent, CancellationToken.None); + + result.StatusCode.ShouldBe(500); + ProblemDetails problemDetails = (ProblemDetails)result.Value; + problemDetails.Title.ShouldBe("One or more event handlers failed"); + problemDetails.Status.ShouldBe(500); + problemDetails.Extensions["eventId"].ShouldBeOfType(); + problemDetails.Extensions["eventType"].ShouldBe(domainEvent.GetType().Name); + String failuresJson = System.Text.Json.JsonSerializer.Serialize(problemDetails.Extensions["failures"]); + failuresJson.ShouldContain("Transaction was not found"); + failuresJson.ShouldContain(nameof(ResultDomainEventHandler)); + } + + [Fact] + public async Task PostEventAsync_WhenMultipleHandlersFail_ReturnsEveryFailure() + { + InitialiseDependencies(); + DomainEvent domainEvent = CreateDomainEvent(); + + DomainEventController controller = CreateController( + domainEvent, + new ResultDomainEventHandler(Result.Failure("First failure")), + new ThrowingDomainEventHandler("Second failure")); + + ObjectResult result = (ObjectResult)await controller.PostEventAsync(domainEvent, CancellationToken.None); + + String failuresJson = System.Text.Json.JsonSerializer.Serialize(((ProblemDetails)result.Value).Extensions["failures"]); + result.StatusCode.ShouldBe(500); + failuresJson.ShouldContain("First failure"); + failuresJson.ShouldContain("Second failure"); + failuresJson.ShouldNotContain("StackTrace"); + } + + private static DomainEventController CreateController(DomainEvent domainEvent, params IDomainEventHandler[] handlers) + { + IDomainEventHandlerResolverImposter resolver = new(); + resolver.GetDomainEventHandlers(Arg.Any()) + .Returns(Result.Success(handlers.ToList())); + + DomainEventController controller = new(resolver.Instance()) + { + ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() } + }; + controller.Request.Headers["eventType"] = domainEvent.GetType().Name; + return controller; + } + + private static DomainEvent CreateDomainEvent() + { + DomainEvent domainEvent = new TransactionDomainEvents.ProductDetailsAddedToTransactionEvent( + Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid(), DateTime.UtcNow); + TypeMap.AddType(domainEvent.GetType(), domainEvent.GetType().Name); + return domainEvent; + } + + private static void InitialiseDependencies() + { + StringSerialiser.Initialise(new SystemTextJsonSerializer(new System.Text.Json.JsonSerializerOptions())); + Logger.Initialise(NullLogger.Instance); + } + + private sealed class ResultDomainEventHandler : IDomainEventHandler + { + private readonly Result Result; + + public ResultDomainEventHandler(Result result) => this.Result = result; + + public Task Handle(IDomainEvent domainEvent, CancellationToken cancellationToken) + => Task.FromResult(this.Result); + } + + private sealed class ThrowingDomainEventHandler : IDomainEventHandler + { + private readonly String Error; + + public ThrowingDomainEventHandler(String error) => this.Error = error; + + public Task Handle(IDomainEvent domainEvent, CancellationToken cancellationToken) + => throw new InvalidOperationException(this.Error); + } +} diff --git a/TransactionProcessor.Tests/ImposterUsings.cs b/TransactionProcessor.Tests/ImposterUsings.cs index 74830d16..bca038b2 100644 --- a/TransactionProcessor.Tests/ImposterUsings.cs +++ b/TransactionProcessor.Tests/ImposterUsings.cs @@ -4,8 +4,10 @@ using TransactionProcessor.BusinessLogic.OperatorInterfaces; using Shared.EntityFramework; using TransactionProcessor.Database.Contexts; +using Shared.EventStore.EventHandling; [assembly: GenerateImposter(typeof(IMediator))] [assembly: GenerateImposter(typeof(IWebHostEnvironment))] [assembly: GenerateImposter(typeof(IOperatorProxy))] [assembly: GenerateImposter(typeof(IDbContextResolver))] +[assembly: GenerateImposter(typeof(IDomainEventHandlerResolver))] diff --git a/TransactionProcessor/Controllers/DomainEventController.cs b/TransactionProcessor/Controllers/DomainEventController.cs index b18332ca..62954d6e 100644 --- a/TransactionProcessor/Controllers/DomainEventController.cs +++ b/TransactionProcessor/Controllers/DomainEventController.cs @@ -75,16 +75,31 @@ public async Task PostEventAsync([FromBody] Object request, return this.Ok(); } - List> tasks = new(); - foreach (IDomainEventHandler domainEventHandler in eventHandlers) + List executions = eventHandlers + .Select(domainEventHandler => this.ExecuteHandler(domainEventHandler, domainEvent, cancellationToken)) + .ToList(); + + try + { + await Task.WhenAll(executions.Select(execution => execution.Task)); + } + catch (Exception ex) { - tasks.Add(domainEventHandler.Handle(domainEvent, cancellationToken)); + Logger.LogError(new Exception($"One or more handlers threw while processing event [{domainEvent.EventId}]", ex)); } - Task.WaitAll(tasks.ToArray()); - var anyFailed = tasks.Any(t => t.Result.IsFailed); - if (anyFailed) - return this.StatusCode(500); + List failures = executions.SelectMany(this.GetFailures).ToList(); + if (failures.Any()) + { + return this.Problem(title: "One or more event handlers failed", + statusCode: 500, + extensions: new Dictionary + { + ["eventId"] = domainEvent.EventId, + ["eventType"] = domainEvent.GetType().Name, + ["failures"] = failures + }); + } Logger.LogWarning($"Finished processing event - ID [{domainEvent.EventId}]"); @@ -109,6 +124,38 @@ private void Callback(CancellationToken cancellationToken, } } + private IEnumerable GetFailures(HandlerExecution execution) + { + if (execution.Task.IsFaulted) + { + String error = String.Join("; ", execution.Task.Exception?.Flatten().InnerExceptions + .SelectMany(exception => exception.GetExceptionMessages()) ?? Enumerable.Empty()); + yield return new HandlerFailure(execution.Handler.GetType().Name, error); + } + else if (execution.Task.IsCanceled) + { + yield return new HandlerFailure(execution.Handler.GetType().Name, "Handler execution was cancelled"); + } + else if (execution.Task.Result.IsFailed) + { + yield return new HandlerFailure(execution.Handler.GetType().Name, execution.Task.Result.Message); + } + } + + private HandlerExecution ExecuteHandler(IDomainEventHandler handler, + IDomainEvent domainEvent, + CancellationToken cancellationToken) + { + try + { + return new HandlerExecution(handler, handler.Handle(domainEvent, cancellationToken)); + } + catch (Exception ex) + { + return new HandlerExecution(handler, Task.FromException(ex)); + } + } + private List GetDomainEventHandlers(IDomainEvent domainEvent) { if (this.Request.Headers.ContainsKey("subscriptionId")) { @@ -164,6 +211,10 @@ private async Task GetDomainEvent(Object domainEvent) /// private const String ControllerRoute = "api/" + DomainEventController.ControllerName; + private sealed record HandlerExecution(IDomainEventHandler Handler, Task Task); + + private sealed record HandlerFailure(String Handler, String Error); + #endregion } } From 167a97cbb56acf9f2151e131e15b768303ca788f Mon Sep 17 00:00:00 2001 From: StuartFerguson Date: Wed, 16 Sep 2026 21:43:31 +0100 Subject: [PATCH 2/2] Configure MVC services for domain event controller tests --- .../ControllerTests/DomainEventControllerTests.cs | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/TransactionProcessor.Tests/ControllerTests/DomainEventControllerTests.cs b/TransactionProcessor.Tests/ControllerTests/DomainEventControllerTests.cs index cc8135aa..00532a39 100644 --- a/TransactionProcessor.Tests/ControllerTests/DomainEventControllerTests.cs +++ b/TransactionProcessor.Tests/ControllerTests/DomainEventControllerTests.cs @@ -1,6 +1,7 @@ using Imposter.Abstractions; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.DependencyInjection; using Shouldly; using SimpleResults; using System; @@ -98,7 +99,16 @@ private static DomainEventController CreateController(DomainEvent domainEvent, p DomainEventController controller = new(resolver.Instance()) { - ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() } + ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext + { + RequestServices = new ServiceCollection() + .AddMvcCore() + .Services + .BuildServiceProvider() + } + } }; controller.Request.Headers["eventType"] = domainEvent.GetType().Name; return controller;