diff --git a/Examples/CleanWithCQRS/HR.LeaveManagement.Application.UnitTests/HR.LeaveManagement.Application.UnitTests.csproj b/Examples/CleanWithCQRS/HR.LeaveManagement.Application.UnitTests/HR.LeaveManagement.Application.UnitTests.csproj index cd52e612..835784b7 100644 --- a/Examples/CleanWithCQRS/HR.LeaveManagement.Application.UnitTests/HR.LeaveManagement.Application.UnitTests.csproj +++ b/Examples/CleanWithCQRS/HR.LeaveManagement.Application.UnitTests/HR.LeaveManagement.Application.UnitTests.csproj @@ -8,7 +8,6 @@ - diff --git a/Examples/CleanWithCQRS/HR.LeaveManagement.Application.UnitTests/LeaveTypes/Commands/CreateLeaveTypeCommandHandlerTests.cs b/Examples/CleanWithCQRS/HR.LeaveManagement.Application.UnitTests/LeaveTypes/Commands/CreateLeaveTypeCommandHandlerTests.cs index f5d8d737..74ed2300 100644 --- a/Examples/CleanWithCQRS/HR.LeaveManagement.Application.UnitTests/LeaveTypes/Commands/CreateLeaveTypeCommandHandlerTests.cs +++ b/Examples/CleanWithCQRS/HR.LeaveManagement.Application.UnitTests/LeaveTypes/Commands/CreateLeaveTypeCommandHandlerTests.cs @@ -1,11 +1,9 @@ -using AutoMapper; using HR.LeaveManagement.Application.DTOs.LeaveType; using HR.LeaveManagement.Application.Exceptions; using HR.LeaveManagement.Application.Features.LeaveTypes.Handlers.Commands; using HR.LeaveManagement.Application.Features.LeaveTypes.Handlers.Queries; using HR.LeaveManagement.Application.Features.LeaveTypes.Requests.Commands; using HR.LeaveManagement.Application.Features.LeaveTypes.Requests.Queries; -using HR.LeaveManagement.Application.Profiles; using HR.LeaveManagement.Application.Responses; using HR.LeaveManagement.Domain; using Moq; @@ -26,21 +24,11 @@ namespace HR.LeaveManagement.Application.UnitTests.LeaveTypes.Commands [TestFixture()] public class CreateLeaveTypeCommandHandlerTests { - private readonly IMapper _mapper; - private readonly CreateLeaveTypeDto _leaveTypeDto; private readonly CreateLeaveTypeCommandHandler _handler; public CreateLeaveTypeCommandHandlerTests() { - - var mapperConfig = new MapperConfiguration(c => - { - c.AddProfile(); - }, null); - - _mapper = mapperConfig.CreateMapper(); - var testData = new List(); var mock = new Mock>(); var validationMock = new Mock(); @@ -56,7 +44,7 @@ public CreateLeaveTypeCommandHandlerTests() validationMock.Setup(x => x.ValidateAsync(_leaveTypeDto, false, CancellationToken.None)) .Returns(() => Task.FromResult(new ValidationOutcome())); - _handler = new CreateLeaveTypeCommandHandler(_mapper, mock.Object, validationMock.Object); + _handler = new CreateLeaveTypeCommandHandler(mock.Object, validationMock.Object); } [Test] @@ -77,7 +65,7 @@ public async Task InValid_LeaveType_Added() //leaveTypes.Count.ShouldBe(3); result.ShouldBeOfType(); - + } } } diff --git a/Examples/CleanWithCQRS/HR.LeaveManagement.Application.UnitTests/LeaveTypes/Queries/GetLeaveTypeListRequestHandlerTests.cs b/Examples/CleanWithCQRS/HR.LeaveManagement.Application.UnitTests/LeaveTypes/Queries/GetLeaveTypeListRequestHandlerTests.cs index e34e34b7..35965ad4 100644 --- a/Examples/CleanWithCQRS/HR.LeaveManagement.Application.UnitTests/LeaveTypes/Queries/GetLeaveTypeListRequestHandlerTests.cs +++ b/Examples/CleanWithCQRS/HR.LeaveManagement.Application.UnitTests/LeaveTypes/Queries/GetLeaveTypeListRequestHandlerTests.cs @@ -1,8 +1,6 @@ -using AutoMapper; using HR.LeaveManagement.Application.DTOs.LeaveType; using HR.LeaveManagement.Application.Features.LeaveTypes.Handlers.Queries; using HR.LeaveManagement.Application.Features.LeaveTypes.Requests.Queries; -using HR.LeaveManagement.Application.Profiles; using HR.LeaveManagement.Domain; using Moq; using NUnit.Framework; @@ -22,19 +20,6 @@ namespace HR.LeaveManagement.Application.UnitTests.LeaveTypes.Queries [TestFixture()] public class GetLeaveTypeListRequestHandlerTests { - private readonly IMapper _mapper; - public GetLeaveTypeListRequestHandlerTests() - { - //_mockRepo = MockLeaveTypeRepository.GetLeaveTypeRepository(); - - var mapperConfig = new MapperConfiguration(c => - { - c.AddProfile(); - }, null); - - _mapper = mapperConfig.CreateMapper(); - } - [Test] public async Task GetLeaveTypeListTest() { @@ -46,8 +31,8 @@ public async Task GetLeaveTypeListTest() var mock = new Mock>(); mock.Setup(x => x.FindAsync(x=>true, CancellationToken.None)) .Returns(() => Task.FromResult(testData as ICollection)); - - var handler = new GetLeaveTypeListRequestHandler(mock.Object, _mapper); + + var handler = new GetLeaveTypeListRequestHandler(mock.Object); var result = await handler.HandleAsync(new GetLeaveTypeListRequest(), CancellationToken.None); result.ShouldBeOfType>(); diff --git a/Examples/CleanWithCQRS/HR.LeaveManagement.Application/ApplicationServicesRegistration.cs b/Examples/CleanWithCQRS/HR.LeaveManagement.Application/ApplicationServicesRegistration.cs index 61825b54..d5b8ad88 100644 --- a/Examples/CleanWithCQRS/HR.LeaveManagement.Application/ApplicationServicesRegistration.cs +++ b/Examples/CleanWithCQRS/HR.LeaveManagement.Application/ApplicationServicesRegistration.cs @@ -1,4 +1,3 @@ -using HR.LeaveManagement.Application.Profiles; using Microsoft.Extensions.DependencyInjection; using System; using System.Collections.Generic; @@ -11,8 +10,6 @@ public static class ApplicationServicesRegistration { public static IServiceCollection ConfigureApplicationServices(this IServiceCollection services) { - services.AddAutoMapper(x => x.AddProfile(new MappingProfile())); - return services; } } diff --git a/Examples/CleanWithCQRS/HR.LeaveManagement.Application/Features/LeaveAllocations/Handlers/Commands/CreateLeaveAllocationCommandHandler.cs b/Examples/CleanWithCQRS/HR.LeaveManagement.Application/Features/LeaveAllocations/Handlers/Commands/CreateLeaveAllocationCommandHandler.cs index 780a6755..9b5ae37b 100644 --- a/Examples/CleanWithCQRS/HR.LeaveManagement.Application/Features/LeaveAllocations/Handlers/Commands/CreateLeaveAllocationCommandHandler.cs +++ b/Examples/CleanWithCQRS/HR.LeaveManagement.Application/Features/LeaveAllocations/Handlers/Commands/CreateLeaveAllocationCommandHandler.cs @@ -1,4 +1,3 @@ -using AutoMapper; using HR.LeaveManagement.Application.DTOs.LeaveAllocation.Validators; using HR.LeaveManagement.Application.Exceptions; using HR.LeaveManagement.Application.Features.LeaveAllocations.Requests.Commands; @@ -25,13 +24,11 @@ public class CreateLeaveAllocationCommandHandler : IAppRequestHandler _leaveTypeRepository; private readonly IGraphRepository _leaveAllocationRepository; private readonly IUserService _userService; - private readonly IMapper _mapper; private readonly IValidationService _validationService; public CreateLeaveAllocationCommandHandler(IGraphRepository leaveTypeRepository, IGraphRepository leaveAllocationRepository, IUserService userService, - IMapper mapper, IValidationService validationService) { this._leaveTypeRepository = leaveTypeRepository; @@ -39,7 +36,6 @@ public CreateLeaveAllocationCommandHandler(IGraphRepository leaveType this._leaveAllocationRepository.DataStoreName = DataStoreNamesConst.LeaveManagement; this._leaveTypeRepository.DataStoreName = DataStoreNamesConst.LeaveManagement; this._userService = userService; - _mapper = mapper; _validationService = validationService; } @@ -77,12 +73,11 @@ public async Task HandleAsync(CreateLeaveAllocationCommand { await _leaveAllocationRepository.AddAsync(item); } - + response.Success = true; response.Message = "Allocations Successful"; } - return response; } } diff --git a/Examples/CleanWithCQRS/HR.LeaveManagement.Application/Features/LeaveAllocations/Handlers/Commands/DeleteLeaveAllocationCommandHandler.cs b/Examples/CleanWithCQRS/HR.LeaveManagement.Application/Features/LeaveAllocations/Handlers/Commands/DeleteLeaveAllocationCommandHandler.cs index 704c1158..ee36dac8 100644 --- a/Examples/CleanWithCQRS/HR.LeaveManagement.Application/Features/LeaveAllocations/Handlers/Commands/DeleteLeaveAllocationCommandHandler.cs +++ b/Examples/CleanWithCQRS/HR.LeaveManagement.Application/Features/LeaveAllocations/Handlers/Commands/DeleteLeaveAllocationCommandHandler.cs @@ -1,4 +1,3 @@ -using AutoMapper; using HR.LeaveManagement.Application.Exceptions; using HR.LeaveManagement.Application.Features.LeaveAllocations.Requests.Commands; using HR.LeaveManagement.Application.Features.LeaveTypes.Requests.Commands; @@ -17,13 +16,11 @@ namespace HR.LeaveManagement.Application.Features.LeaveAllocations.Handlers.Comm public class DeleteLeaveAllocationCommandHandler : IAppRequestHandler { private readonly IGraphRepository _leaveAllocationRepository; - private readonly IMapper _mapper; - public DeleteLeaveAllocationCommandHandler(IGraphRepository leaveAllocationRepository, IMapper mapper) + public DeleteLeaveAllocationCommandHandler(IGraphRepository leaveAllocationRepository) { this._leaveAllocationRepository = leaveAllocationRepository; this._leaveAllocationRepository.DataStoreName = DataStoreNamesConst.LeaveManagement; - _mapper = mapper; } public async Task HandleAsync(DeleteLeaveAllocationCommand request, CancellationToken cancellationToken) diff --git a/Examples/CleanWithCQRS/HR.LeaveManagement.Application/Features/LeaveAllocations/Handlers/Commands/UpdateLeaveAllocationCommandHandler.cs b/Examples/CleanWithCQRS/HR.LeaveManagement.Application/Features/LeaveAllocations/Handlers/Commands/UpdateLeaveAllocationCommandHandler.cs index f67537e3..9f1860d0 100644 --- a/Examples/CleanWithCQRS/HR.LeaveManagement.Application/Features/LeaveAllocations/Handlers/Commands/UpdateLeaveAllocationCommandHandler.cs +++ b/Examples/CleanWithCQRS/HR.LeaveManagement.Application/Features/LeaveAllocations/Handlers/Commands/UpdateLeaveAllocationCommandHandler.cs @@ -1,8 +1,8 @@ -using AutoMapper; using HR.LeaveManagement.Application.DTOs.LeaveAllocation.Validators; using HR.LeaveManagement.Application.Exceptions; using HR.LeaveManagement.Application.Features.LeaveAllocations.Requests.Commands; using HR.LeaveManagement.Application.Features.LeaveTypes.Requests.Commands; +using HR.LeaveManagement.Application.Mappings; using HR.LeaveManagement.Domain; using RCommon.Mediator.Subscribers; using System; @@ -20,19 +20,16 @@ public class UpdateLeaveAllocationCommandHandler : IAppRequestHandler _leaveAllocationRepository; private readonly IReadOnlyRepository _leaveTypeRepository; - private readonly IMapper _mapper; private readonly IValidationService _validationService; public UpdateLeaveAllocationCommandHandler(IGraphRepository leaveAllocationRepository, IReadOnlyRepository leaveTypeRepository, - IMapper mapper, IValidationService validationService) { this._leaveAllocationRepository = leaveAllocationRepository; this._leaveTypeRepository = leaveTypeRepository; this._leaveAllocationRepository.DataStoreName = DataStoreNamesConst.LeaveManagement; this._leaveTypeRepository.DataStoreName = DataStoreNamesConst.LeaveManagement; - _mapper = mapper; _validationService = validationService; } @@ -48,10 +45,9 @@ public async Task HandleAsync(UpdateLeaveAllocationCommand request, Cancellation if (leaveAllocation is null) throw new NotFoundException(nameof(leaveAllocation), request.LeaveAllocationDto.Id); - _mapper.Map(request.LeaveAllocationDto, leaveAllocation); + request.LeaveAllocationDto.ApplyTo(leaveAllocation); await _leaveAllocationRepository.UpdateAsync(leaveAllocation); - } } } diff --git a/Examples/CleanWithCQRS/HR.LeaveManagement.Application/Features/LeaveAllocations/Handlers/Queries/GetLeaveAllocationDetailRequestHandler.cs b/Examples/CleanWithCQRS/HR.LeaveManagement.Application/Features/LeaveAllocations/Handlers/Queries/GetLeaveAllocationDetailRequestHandler.cs index eb09fbe4..524cc9f6 100644 --- a/Examples/CleanWithCQRS/HR.LeaveManagement.Application/Features/LeaveAllocations/Handlers/Queries/GetLeaveAllocationDetailRequestHandler.cs +++ b/Examples/CleanWithCQRS/HR.LeaveManagement.Application/Features/LeaveAllocations/Handlers/Queries/GetLeaveAllocationDetailRequestHandler.cs @@ -1,7 +1,7 @@ -using AutoMapper; using HR.LeaveManagement.Application.DTOs; using HR.LeaveManagement.Application.DTOs.LeaveAllocation; using HR.LeaveManagement.Application.Features.LeaveAllocations.Requests.Queries; +using HR.LeaveManagement.Application.Mappings; using HR.LeaveManagement.Domain; using RCommon.Mediator.Subscribers; using RCommon.Persistence; @@ -14,19 +14,18 @@ namespace HR.LeaveManagement.Application.Features.LeaveAllocations.Handlers.Quer public class GetLeaveAllocationDetailRequestHandler : IAppRequestHandler { private readonly IGraphRepository _leaveAllocationRepository; - private readonly IMapper _mapper; - public GetLeaveAllocationDetailRequestHandler(IGraphRepository leaveAllocationRepository, IMapper mapper) + public GetLeaveAllocationDetailRequestHandler(IGraphRepository leaveAllocationRepository) { _leaveAllocationRepository = leaveAllocationRepository; this._leaveAllocationRepository.DataStoreName = DataStoreNamesConst.LeaveManagement; - _mapper = mapper; } + public async Task HandleAsync(GetLeaveAllocationDetailRequest request, CancellationToken cancellationToken) { _leaveAllocationRepository.Include(x => x.LeaveType); var leaveAllocation = await _leaveAllocationRepository.FindAsync(request.Id); - return _mapper.Map(leaveAllocation); + return leaveAllocation.ToLeaveAllocationDto(); } } } diff --git a/Examples/CleanWithCQRS/HR.LeaveManagement.Application/Features/LeaveAllocations/Handlers/Queries/GetLeaveAllocationListRequestHandler.cs b/Examples/CleanWithCQRS/HR.LeaveManagement.Application/Features/LeaveAllocations/Handlers/Queries/GetLeaveAllocationListRequestHandler.cs index 2622e4ac..51444c31 100644 --- a/Examples/CleanWithCQRS/HR.LeaveManagement.Application/Features/LeaveAllocations/Handlers/Queries/GetLeaveAllocationListRequestHandler.cs +++ b/Examples/CleanWithCQRS/HR.LeaveManagement.Application/Features/LeaveAllocations/Handlers/Queries/GetLeaveAllocationListRequestHandler.cs @@ -1,9 +1,10 @@ -using AutoMapper; using HR.LeaveManagement.Application.DTOs; using HR.LeaveManagement.Application.DTOs.LeaveAllocation; using HR.LeaveManagement.Application.Features.LeaveAllocations.Requests.Queries; +using HR.LeaveManagement.Application.Mappings; using RCommon.Mediator.Subscribers; using System.Collections.Generic; +using System.Linq; using System.Threading; using System.Threading.Tasks; using HR.LeaveManagement.Application.Contracts.Identity; @@ -19,18 +20,15 @@ namespace HR.LeaveManagement.Application.Features.LeaveAllocations.Handlers.Quer public class GetLeaveAllocationListRequestHandler : IAppRequestHandler> { private readonly IGraphRepository _leaveAllocationRepository; - private readonly IMapper _mapper; private readonly IHttpContextAccessor _httpContextAccessor; private readonly IUserService _userService; public GetLeaveAllocationListRequestHandler(IGraphRepository leaveAllocationRepository, - IMapper mapper, IHttpContextAccessor httpContextAccessor, IUserService userService) { _leaveAllocationRepository = leaveAllocationRepository; this._leaveAllocationRepository.DataStoreName = DataStoreNamesConst.LeaveManagement; - _mapper = mapper; this._httpContextAccessor = httpContextAccessor; this._userService = userService; } @@ -44,10 +42,10 @@ public async Task> HandleAsync(GetLeaveAllocationListRe { var userId = _httpContextAccessor.HttpContext.User.FindFirst( q => q.Type == CustomClaimTypes.Uid)?.Value; - leaveAllocations = await _leaveAllocationRepository.FindAsync(x=>x.EmployeeId == userId) as List; + leaveAllocations = await _leaveAllocationRepository.FindAsync(x => x.EmployeeId == userId) as List; var employee = await _userService.GetEmployee(userId); - allocations = _mapper.Map>(leaveAllocations); + allocations = leaveAllocations.Select(x => x.ToLeaveAllocationDto()).ToList(); foreach (var alloc in allocations) { alloc.Employee = employee; @@ -55,8 +53,8 @@ public async Task> HandleAsync(GetLeaveAllocationListRe } else { - leaveAllocations = await _leaveAllocationRepository.FindAsync(x=>true) as List; - allocations = _mapper.Map>(leaveAllocations); + leaveAllocations = await _leaveAllocationRepository.FindAsync(x => true) as List; + allocations = leaveAllocations.Select(x => x.ToLeaveAllocationDto()).ToList(); foreach (var req in allocations) { req.Employee = await _userService.GetEmployee(req.EmployeeId); diff --git a/Examples/CleanWithCQRS/HR.LeaveManagement.Application/Features/LeaveRequests/Handlers/Commands/CreateLeaveRequestCommandHandler.cs b/Examples/CleanWithCQRS/HR.LeaveManagement.Application/Features/LeaveRequests/Handlers/Commands/CreateLeaveRequestCommandHandler.cs index 4e11cb32..74c6aef2 100644 --- a/Examples/CleanWithCQRS/HR.LeaveManagement.Application/Features/LeaveRequests/Handlers/Commands/CreateLeaveRequestCommandHandler.cs +++ b/Examples/CleanWithCQRS/HR.LeaveManagement.Application/Features/LeaveRequests/Handlers/Commands/CreateLeaveRequestCommandHandler.cs @@ -1,8 +1,8 @@ -using AutoMapper; using HR.LeaveManagement.Application.DTOs.LeaveRequest.Validators; using HR.LeaveManagement.Application.Exceptions; using HR.LeaveManagement.Application.Features.LeaveRequests.Requests.Commands; using HR.LeaveManagement.Application.Features.LeaveTypes.Requests.Commands; +using HR.LeaveManagement.Application.Mappings; using HR.LeaveManagement.Application.Responses; using HR.LeaveManagement.Domain; using RCommon.Mediator.Subscribers; @@ -32,7 +32,6 @@ public class CreateLeaveRequestCommandHandler : IAppRequestHandler _emailSettings; - private readonly IMapper _mapper; private readonly IValidationService _validationService; private readonly IReadOnlyRepository _leaveTypeRepository; private readonly IGraphRepository _leaveAllocationRepository; @@ -45,7 +44,6 @@ public CreateLeaveRequestCommandHandler( IEmailService emailSender, ICurrentUser currentUser, IOptions emailSettings, - IMapper mapper, IValidationService validationService) { _leaveTypeRepository = leaveTypeRepository; @@ -56,8 +54,7 @@ public CreateLeaveRequestCommandHandler( this._leaveRequestRepository.DataStoreName = DataStoreNamesConst.LeaveManagement; _emailSender = emailSender; this._currentUser = currentUser; - _emailSettings=emailSettings; - _mapper = mapper; + _emailSettings = emailSettings; _validationService = validationService; } @@ -67,8 +64,8 @@ public async Task HandleAsync(CreateLeaveRequestCommand req var validationResult = await _validationService.ValidateAsync(request.LeaveRequestDto); var userId = _currentUser.FindClaimValue(CustomClaimTypes.Uid); - var allocation = _leaveAllocationRepository.FirstOrDefault(x=>x.EmployeeId == userId && x.LeaveTypeId == request.LeaveRequestDto.LeaveTypeId); - if(allocation is null) + var allocation = _leaveAllocationRepository.FirstOrDefault(x => x.EmployeeId == userId && x.LeaveTypeId == request.LeaveRequestDto.LeaveTypeId); + if (allocation is null) { validationResult.Errors.Add(new ValidationFault(nameof(request.LeaveRequestDto.LeaveTypeId), "You do not have any allocations for this leave type.")); @@ -82,7 +79,7 @@ public async Task HandleAsync(CreateLeaveRequestCommand req nameof(request.LeaveRequestDto.EndDate), "You do not have enough days for this request")); } } - + if (validationResult.IsValid == false) { response.Success = false; @@ -91,7 +88,7 @@ public async Task HandleAsync(CreateLeaveRequestCommand req } else { - var leaveRequest = _mapper.Map(request.LeaveRequestDto); + var leaveRequest = request.LeaveRequestDto.ToLeaveRequest(); leaveRequest.RequestingEmployeeId = userId; await _leaveRequestRepository.AddAsync(leaveRequest); //TODO: May need to get Id out @@ -104,7 +101,7 @@ public async Task HandleAsync(CreateLeaveRequestCommand req { var emailAddress = _currentUser.FindClaimValue(ClaimTypes.Email); - var email = new MailMessage(new MailAddress(this._emailSettings.Value.FromEmailDefault, this._emailSettings.Value.FromNameDefault), + var email = new MailMessage(new MailAddress(this._emailSettings.Value.FromEmailDefault, this._emailSettings.Value.FromNameDefault), new MailAddress(emailAddress)) { Body = $"Your leave request for {request.LeaveRequestDto.StartDate:D} to {request.LeaveRequestDto.EndDate:D} " + @@ -119,7 +116,7 @@ public async Task HandleAsync(CreateLeaveRequestCommand req //// Log or handle error, but don't throw... } } - + return response; } } diff --git a/Examples/CleanWithCQRS/HR.LeaveManagement.Application/Features/LeaveRequests/Handlers/Commands/DeleteLeaveRequestCommandHandler.cs b/Examples/CleanWithCQRS/HR.LeaveManagement.Application/Features/LeaveRequests/Handlers/Commands/DeleteLeaveRequestCommandHandler.cs index 33f6d3a4..ff11797c 100644 --- a/Examples/CleanWithCQRS/HR.LeaveManagement.Application/Features/LeaveRequests/Handlers/Commands/DeleteLeaveRequestCommandHandler.cs +++ b/Examples/CleanWithCQRS/HR.LeaveManagement.Application/Features/LeaveRequests/Handlers/Commands/DeleteLeaveRequestCommandHandler.cs @@ -1,4 +1,3 @@ -using AutoMapper; using HR.LeaveManagement.Application.Exceptions; using HR.LeaveManagement.Application.Features.LeaveRequests.Requests.Commands; using HR.LeaveManagement.Application.Features.LeaveTypes.Requests.Commands; diff --git a/Examples/CleanWithCQRS/HR.LeaveManagement.Application/Features/LeaveRequests/Handlers/Commands/UpdateLeaveRequestCommandHandler.cs b/Examples/CleanWithCQRS/HR.LeaveManagement.Application/Features/LeaveRequests/Handlers/Commands/UpdateLeaveRequestCommandHandler.cs index 93f6af47..06b2e975 100644 --- a/Examples/CleanWithCQRS/HR.LeaveManagement.Application/Features/LeaveRequests/Handlers/Commands/UpdateLeaveRequestCommandHandler.cs +++ b/Examples/CleanWithCQRS/HR.LeaveManagement.Application/Features/LeaveRequests/Handlers/Commands/UpdateLeaveRequestCommandHandler.cs @@ -1,9 +1,9 @@ -using AutoMapper; using HR.LeaveManagement.Application.DTOs.LeaveRequest.Validators; using HR.LeaveManagement.Application.Exceptions; using HR.LeaveManagement.Application.Features.LeaveAllocations.Requests.Commands; using HR.LeaveManagement.Application.Features.LeaveRequests.Requests.Commands; using HR.LeaveManagement.Application.Features.LeaveTypes.Requests.Commands; +using HR.LeaveManagement.Application.Mappings; using HR.LeaveManagement.Domain; using RCommon.Mediator.Subscribers; using System; @@ -22,15 +22,13 @@ public class UpdateLeaveRequestCommandHandler : IAppRequestHandler _leaveRequestRepository; private readonly IReadOnlyRepository _leaveTypeRepository; private readonly IGraphRepository _leaveAllocationRepository; - private readonly IMapper _mapper; private readonly IValidationService _validationService; public UpdateLeaveRequestCommandHandler( IGraphRepository leaveRequestRepository, IReadOnlyRepository leaveTypeRepository, IGraphRepository leaveAllocationRepository, - IMapper mapper, - IValidationService validationService) + IValidationService validationService) { this._leaveRequestRepository = leaveRequestRepository; _leaveTypeRepository = leaveTypeRepository; @@ -38,7 +36,6 @@ public UpdateLeaveRequestCommandHandler( this._leaveAllocationRepository.DataStoreName = DataStoreNamesConst.LeaveManagement; this._leaveTypeRepository.DataStoreName = DataStoreNamesConst.LeaveManagement; this._leaveRequestRepository.DataStoreName = DataStoreNamesConst.LeaveManagement; - _mapper = mapper; _validationService = validationService; } @@ -46,7 +43,7 @@ public async Task HandleAsync(UpdateLeaveRequestCommand request, CancellationTok { var leaveRequest = await _leaveRequestRepository.FindAsync(request.Id); - if(leaveRequest is null) + if (leaveRequest is null) throw new NotFoundException(nameof(leaveRequest), request.Id); if (request.LeaveRequestDto != null) @@ -55,11 +52,11 @@ public async Task HandleAsync(UpdateLeaveRequestCommand request, CancellationTok if (validationResult.IsValid == false) throw new ValidationException(validationResult.Errors); - _mapper.Map(request.LeaveRequestDto, leaveRequest); + request.LeaveRequestDto.ApplyTo(leaveRequest); await _leaveRequestRepository.UpdateAsync(leaveRequest); } - else if(request.ChangeLeaveRequestApprovalDto != null) + else if (request.ChangeLeaveRequestApprovalDto != null) { leaveRequest.Approved = request.ChangeLeaveRequestApprovalDto.Approved; await _leaveRequestRepository.UpdateAsync(leaveRequest); @@ -75,8 +72,6 @@ public async Task HandleAsync(UpdateLeaveRequestCommand request, CancellationTok await _leaveAllocationRepository.UpdateAsync(allocation); } } - - } } } diff --git a/Examples/CleanWithCQRS/HR.LeaveManagement.Application/Features/LeaveRequests/Handlers/Queries/GetLeaveRequestDetailRequestHandler.cs b/Examples/CleanWithCQRS/HR.LeaveManagement.Application/Features/LeaveRequests/Handlers/Queries/GetLeaveRequestDetailRequestHandler.cs index 7136a0e2..92458bfc 100644 --- a/Examples/CleanWithCQRS/HR.LeaveManagement.Application/Features/LeaveRequests/Handlers/Queries/GetLeaveRequestDetailRequestHandler.cs +++ b/Examples/CleanWithCQRS/HR.LeaveManagement.Application/Features/LeaveRequests/Handlers/Queries/GetLeaveRequestDetailRequestHandler.cs @@ -1,9 +1,9 @@ -using AutoMapper; using HR.LeaveManagement.Application.DTOs; using HR.LeaveManagement.Application.DTOs.LeaveRequest; using HR.LeaveManagement.Application.Features.LeaveRequests.Requests.Queries; using HR.LeaveManagement.Application.Features.LeaveTypes.Requests; using HR.LeaveManagement.Application.Features.LeaveTypes.Requests.Queries; +using HR.LeaveManagement.Application.Mappings; using RCommon.Mediator.Subscribers; using System; using System.Collections.Generic; @@ -20,24 +20,22 @@ namespace HR.LeaveManagement.Application.Features.LeaveRequests.Handlers.Queries public class GetLeaveRequestDetailRequestHandler : IAppRequestHandler { private readonly IGraphRepository _leaveRequestRepository; - private readonly IMapper _mapper; private readonly IUserService _userService; public GetLeaveRequestDetailRequestHandler(IGraphRepository leaveRequestRepository, - IMapper mapper, IUserService userService) { _leaveRequestRepository = leaveRequestRepository; this._leaveRequestRepository.DataStoreName = DataStoreNamesConst.LeaveManagement; - _mapper = mapper; this._userService = userService; } + public async Task HandleAsync(GetLeaveRequestDetailRequest request, CancellationToken cancellationToken) { _leaveRequestRepository.Include(x => x.LeaveType); - var leaveRequest = _mapper.Map(await _leaveRequestRepository.FindAsync(request.Id)); - leaveRequest.Employee = await _userService.GetEmployee(leaveRequest.RequestingEmployeeId); - return leaveRequest; + var leaveRequestDto = (await _leaveRequestRepository.FindAsync(request.Id)).ToLeaveRequestDto(); + leaveRequestDto.Employee = await _userService.GetEmployee(leaveRequestDto.RequestingEmployeeId); + return leaveRequestDto; } } } diff --git a/Examples/CleanWithCQRS/HR.LeaveManagement.Application/Features/LeaveRequests/Handlers/Queries/GetLeaveRequestListRequestHandler.cs b/Examples/CleanWithCQRS/HR.LeaveManagement.Application/Features/LeaveRequests/Handlers/Queries/GetLeaveRequestListRequestHandler.cs index 31c60552..e6f8d667 100644 --- a/Examples/CleanWithCQRS/HR.LeaveManagement.Application/Features/LeaveRequests/Handlers/Queries/GetLeaveRequestListRequestHandler.cs +++ b/Examples/CleanWithCQRS/HR.LeaveManagement.Application/Features/LeaveRequests/Handlers/Queries/GetLeaveRequestListRequestHandler.cs @@ -1,12 +1,13 @@ -using AutoMapper; using HR.LeaveManagement.Application.DTOs; using HR.LeaveManagement.Application.DTOs.LeaveRequest; using HR.LeaveManagement.Application.Features.LeaveRequests.Requests.Queries; using HR.LeaveManagement.Application.Features.LeaveTypes.Requests; using HR.LeaveManagement.Application.Features.LeaveTypes.Requests.Queries; +using HR.LeaveManagement.Application.Mappings; using RCommon.Mediator.Subscribers; using System; using System.Collections.Generic; +using System.Linq; using System.Text; using System.Threading; using System.Threading.Tasks; @@ -22,18 +23,15 @@ namespace HR.LeaveManagement.Application.Features.LeaveRequests.Handlers.Queries public class GetLeaveRequestListRequestHandler : IAppRequestHandler> { private readonly IGraphRepository _leaveRequestRepository; - private readonly IMapper _mapper; private readonly IHttpContextAccessor _httpContextAccessor; private readonly IUserService _userService; public GetLeaveRequestListRequestHandler(IGraphRepository leaveRequestRepository, - IMapper mapper, IHttpContextAccessor httpContextAccessor, IUserService userService) { _leaveRequestRepository = leaveRequestRepository; this._leaveRequestRepository.DataStoreName = DataStoreNamesConst.LeaveManagement; - _mapper = mapper; this._httpContextAccessor = httpContextAccessor; this._userService = userService; } @@ -48,10 +46,10 @@ public async Task> HandleAsync(GetLeaveRequestListRequ { var userId = _httpContextAccessor.HttpContext.User.FindFirst( q => q.Type == CustomClaimTypes.Uid)?.Value; - leaveRequests = await _leaveRequestRepository.FindAsync(x=>x.RequestingEmployeeId == userId) as List; + leaveRequests = await _leaveRequestRepository.FindAsync(x => x.RequestingEmployeeId == userId) as List; var employee = await _userService.GetEmployee(userId); - requests = _mapper.Map>(leaveRequests); + requests = leaveRequests.Select(x => x.ToLeaveRequestListDto()).ToList(); foreach (var req in requests) { req.Employee = employee; @@ -60,7 +58,7 @@ public async Task> HandleAsync(GetLeaveRequestListRequ else { leaveRequests = await _leaveRequestRepository.FindAsync(x => true) as List; - requests = _mapper.Map>(leaveRequests); + requests = leaveRequests.Select(x => x.ToLeaveRequestListDto()).ToList(); foreach (var req in requests) { req.Employee = await _userService.GetEmployee(req.RequestingEmployeeId); @@ -68,8 +66,6 @@ public async Task> HandleAsync(GetLeaveRequestListRequ } return requests; - - } } } diff --git a/Examples/CleanWithCQRS/HR.LeaveManagement.Application/Features/LeaveTypes/Handlers/Commands/CreateLeaveTypeCommandHandler.cs b/Examples/CleanWithCQRS/HR.LeaveManagement.Application/Features/LeaveTypes/Handlers/Commands/CreateLeaveTypeCommandHandler.cs index 87a89b01..5c03cf37 100644 --- a/Examples/CleanWithCQRS/HR.LeaveManagement.Application/Features/LeaveTypes/Handlers/Commands/CreateLeaveTypeCommandHandler.cs +++ b/Examples/CleanWithCQRS/HR.LeaveManagement.Application/Features/LeaveTypes/Handlers/Commands/CreateLeaveTypeCommandHandler.cs @@ -1,7 +1,7 @@ -using AutoMapper; using HR.LeaveManagement.Application.DTOs.LeaveType.Validators; using HR.LeaveManagement.Application.Exceptions; using HR.LeaveManagement.Application.Features.LeaveTypes.Requests.Commands; +using HR.LeaveManagement.Application.Mappings; using HR.LeaveManagement.Domain; using RCommon.Mediator.Subscribers; using System; @@ -19,13 +19,11 @@ namespace HR.LeaveManagement.Application.Features.LeaveTypes.Handlers.Commands { public class CreateLeaveTypeCommandHandler : IAppRequestHandler { - private readonly IMapper _mapper; private readonly IGraphRepository _leaveTypeRepository; private readonly IValidationService _validationService; - public CreateLeaveTypeCommandHandler(IMapper mapper, IGraphRepository leaveTypeRepository, IValidationService validationService) + public CreateLeaveTypeCommandHandler(IGraphRepository leaveTypeRepository, IValidationService validationService) { - _mapper = mapper; _leaveTypeRepository = leaveTypeRepository; _validationService = validationService; this._leaveTypeRepository.DataStoreName = DataStoreNamesConst.LeaveManagement; @@ -44,7 +42,7 @@ public async Task HandleAsync(CreateLeaveTypeCommand reques } else { - var leaveType = _mapper.Map(request.LeaveTypeDto); + var leaveType = request.LeaveTypeDto.ToLeaveType(); await _leaveTypeRepository.AddAsync(leaveType); diff --git a/Examples/CleanWithCQRS/HR.LeaveManagement.Application/Features/LeaveTypes/Handlers/Commands/DeleteLeaveTypeCommandHandler.cs b/Examples/CleanWithCQRS/HR.LeaveManagement.Application/Features/LeaveTypes/Handlers/Commands/DeleteLeaveTypeCommandHandler.cs index d3de1054..bccc384c 100644 --- a/Examples/CleanWithCQRS/HR.LeaveManagement.Application/Features/LeaveTypes/Handlers/Commands/DeleteLeaveTypeCommandHandler.cs +++ b/Examples/CleanWithCQRS/HR.LeaveManagement.Application/Features/LeaveTypes/Handlers/Commands/DeleteLeaveTypeCommandHandler.cs @@ -1,4 +1,3 @@ -using AutoMapper; using HR.LeaveManagement.Application.Exceptions; using HR.LeaveManagement.Application.Features.LeaveTypes.Requests.Commands; using HR.LeaveManagement.Domain; @@ -15,12 +14,10 @@ namespace HR.LeaveManagement.Application.Features.LeaveTypes.Handlers.Commands { public class DeleteLeaveTypeCommandHandler : IAppRequestHandler { - private readonly IMapper _mapper; private readonly IGraphRepository _leaveTypeRepository; - public DeleteLeaveTypeCommandHandler(IMapper mapper, IGraphRepository leaveTypeRepository) + public DeleteLeaveTypeCommandHandler(IGraphRepository leaveTypeRepository) { - _mapper = mapper; _leaveTypeRepository = leaveTypeRepository; this._leaveTypeRepository.DataStoreName = DataStoreNamesConst.LeaveManagement; } diff --git a/Examples/CleanWithCQRS/HR.LeaveManagement.Application/Features/LeaveTypes/Handlers/Commands/UpdateLeaveTypeCommandHandler.cs b/Examples/CleanWithCQRS/HR.LeaveManagement.Application/Features/LeaveTypes/Handlers/Commands/UpdateLeaveTypeCommandHandler.cs index 331a23da..7b09d655 100644 --- a/Examples/CleanWithCQRS/HR.LeaveManagement.Application/Features/LeaveTypes/Handlers/Commands/UpdateLeaveTypeCommandHandler.cs +++ b/Examples/CleanWithCQRS/HR.LeaveManagement.Application/Features/LeaveTypes/Handlers/Commands/UpdateLeaveTypeCommandHandler.cs @@ -1,7 +1,7 @@ -using AutoMapper; using HR.LeaveManagement.Application.DTOs.LeaveType.Validators; using HR.LeaveManagement.Application.Exceptions; using HR.LeaveManagement.Application.Features.LeaveTypes.Requests.Commands; +using HR.LeaveManagement.Application.Mappings; using HR.LeaveManagement.Domain; using RCommon.Mediator.Subscribers; using System; @@ -17,13 +17,11 @@ namespace HR.LeaveManagement.Application.Features.LeaveTypes.Handlers.Commands { public class UpdateLeaveTypeCommandHandler : IAppRequestHandler { - private readonly IMapper _mapper; private readonly IGraphRepository _leaveTypeRepository; private readonly IValidationService _validationService; - public UpdateLeaveTypeCommandHandler(IMapper mapper, IGraphRepository leaveTypeRepository, IValidationService validationService) + public UpdateLeaveTypeCommandHandler(IGraphRepository leaveTypeRepository, IValidationService validationService) { - _mapper = mapper; _leaveTypeRepository = leaveTypeRepository; _validationService = validationService; this._leaveTypeRepository.DataStoreName = DataStoreNamesConst.LeaveManagement; @@ -41,11 +39,9 @@ public async Task HandleAsync(UpdateLeaveTypeCommand request, CancellationToken if (leaveType is null) throw new NotFoundException(nameof(leaveType), request.LeaveTypeDto.Id); - _mapper.Map(request.LeaveTypeDto, leaveType); + request.LeaveTypeDto.ApplyTo(leaveType); await _leaveTypeRepository.UpdateAsync(leaveType); - - } } } diff --git a/Examples/CleanWithCQRS/HR.LeaveManagement.Application/Features/LeaveTypes/Handlers/Queries/GetLeaveTypeDetailRequestHandler.cs b/Examples/CleanWithCQRS/HR.LeaveManagement.Application/Features/LeaveTypes/Handlers/Queries/GetLeaveTypeDetailRequestHandler.cs index 76f3b9c4..fda1bb41 100644 --- a/Examples/CleanWithCQRS/HR.LeaveManagement.Application/Features/LeaveTypes/Handlers/Queries/GetLeaveTypeDetailRequestHandler.cs +++ b/Examples/CleanWithCQRS/HR.LeaveManagement.Application/Features/LeaveTypes/Handlers/Queries/GetLeaveTypeDetailRequestHandler.cs @@ -1,9 +1,9 @@ -using AutoMapper; using HR.LeaveManagement.Application.DTOs; using HR.LeaveManagement.Application.DTOs.LeaveType; using HR.LeaveManagement.Application.Features.LeaveRequests.Requests.Queries; using HR.LeaveManagement.Application.Features.LeaveTypes.Requests; using HR.LeaveManagement.Application.Features.LeaveTypes.Requests.Queries; +using HR.LeaveManagement.Application.Mappings; using HR.LeaveManagement.Domain; using RCommon.Mediator.Subscribers; using RCommon.Persistence; @@ -19,18 +19,17 @@ namespace HR.LeaveManagement.Application.Features.LeaveTypes.Handlers.Queries public class GetLeaveTypeDetailRequestHandler : IAppRequestHandler { private readonly IGraphRepository _leaveTypeRepository; - private readonly IMapper _mapper; - public GetLeaveTypeDetailRequestHandler(IGraphRepository leaveTypeRepository, IMapper mapper) + public GetLeaveTypeDetailRequestHandler(IGraphRepository leaveTypeRepository) { _leaveTypeRepository = leaveTypeRepository; this._leaveTypeRepository.DataStoreName = DataStoreNamesConst.LeaveManagement; - _mapper = mapper; } + public async Task HandleAsync(GetLeaveTypeDetailRequest request, CancellationToken cancellationToken) { var leaveType = await _leaveTypeRepository.FindAsync(request.Id); - return _mapper.Map(leaveType); + return leaveType.ToLeaveTypeDto(); } } } diff --git a/Examples/CleanWithCQRS/HR.LeaveManagement.Application/Features/LeaveTypes/Handlers/Queries/GetLeaveTypeListRequestHandler.cs b/Examples/CleanWithCQRS/HR.LeaveManagement.Application/Features/LeaveTypes/Handlers/Queries/GetLeaveTypeListRequestHandler.cs index b4f7a4f1..325209ec 100644 --- a/Examples/CleanWithCQRS/HR.LeaveManagement.Application/Features/LeaveTypes/Handlers/Queries/GetLeaveTypeListRequestHandler.cs +++ b/Examples/CleanWithCQRS/HR.LeaveManagement.Application/Features/LeaveTypes/Handlers/Queries/GetLeaveTypeListRequestHandler.cs @@ -1,14 +1,15 @@ -using AutoMapper; using HR.LeaveManagement.Application.DTOs; using HR.LeaveManagement.Application.DTOs.LeaveType; using HR.LeaveManagement.Application.Features.LeaveTypes.Requests; using HR.LeaveManagement.Application.Features.LeaveTypes.Requests.Queries; +using HR.LeaveManagement.Application.Mappings; using HR.LeaveManagement.Domain; using RCommon.Mediator.Subscribers; using RCommon.Persistence; using RCommon.Persistence.Crud; using System; using System.Collections.Generic; +using System.Linq; using System.Text; using System.Threading; using System.Threading.Tasks; @@ -18,19 +19,17 @@ namespace HR.LeaveManagement.Application.Features.LeaveTypes.Handlers.Queries public class GetLeaveTypeListRequestHandler : IAppRequestHandler> { private readonly IGraphRepository _leaveTypeRepository; - private readonly IMapper _mapper; - public GetLeaveTypeListRequestHandler(IGraphRepository leaveTypeRepository, IMapper mapper) + public GetLeaveTypeListRequestHandler(IGraphRepository leaveTypeRepository) { _leaveTypeRepository = leaveTypeRepository; this._leaveTypeRepository.DataStoreName = DataStoreNamesConst.LeaveManagement; - _mapper = mapper; } public async Task> HandleAsync(GetLeaveTypeListRequest request, CancellationToken cancellationToken) { - var leaveTypes = await _leaveTypeRepository.FindAsync(x=> true); - return _mapper.Map>(leaveTypes); + var leaveTypes = await _leaveTypeRepository.FindAsync(x => true); + return leaveTypes.Select(x => x.ToLeaveTypeDto()).ToList(); } } } diff --git a/Examples/CleanWithCQRS/HR.LeaveManagement.Application/HR.LeaveManagement.Application.csproj b/Examples/CleanWithCQRS/HR.LeaveManagement.Application/HR.LeaveManagement.Application.csproj index be0e28e2..396fe89b 100644 --- a/Examples/CleanWithCQRS/HR.LeaveManagement.Application/HR.LeaveManagement.Application.csproj +++ b/Examples/CleanWithCQRS/HR.LeaveManagement.Application/HR.LeaveManagement.Application.csproj @@ -9,7 +9,6 @@ - diff --git a/Examples/CleanWithCQRS/HR.LeaveManagement.Application/Mappings/LeaveAllocationMappings.cs b/Examples/CleanWithCQRS/HR.LeaveManagement.Application/Mappings/LeaveAllocationMappings.cs new file mode 100644 index 00000000..b099c26b --- /dev/null +++ b/Examples/CleanWithCQRS/HR.LeaveManagement.Application/Mappings/LeaveAllocationMappings.cs @@ -0,0 +1,30 @@ +using HR.LeaveManagement.Application.DTOs.LeaveAllocation; +using HR.LeaveManagement.Domain; + +namespace HR.LeaveManagement.Application.Mappings +{ + public static class LeaveAllocationMappings + { + public static LeaveAllocationDto ToLeaveAllocationDto(this LeaveAllocation source) + { + if (source == null) return null; + return new LeaveAllocationDto + { + Id = source.Id, + NumberOfDays = source.NumberOfDays, + LeaveTypeId = source.LeaveTypeId, + LeaveType = source.LeaveType?.ToLeaveTypeDto(), + Period = source.Period, + EmployeeId = source.EmployeeId + }; + } + + public static void ApplyTo(this UpdateLeaveAllocationDto source, LeaveAllocation destination) + { + if (source == null || destination == null) return; + destination.NumberOfDays = source.NumberOfDays; + destination.LeaveTypeId = source.LeaveTypeId; + destination.Period = source.Period; + } + } +} diff --git a/Examples/CleanWithCQRS/HR.LeaveManagement.Application/Mappings/LeaveRequestMappings.cs b/Examples/CleanWithCQRS/HR.LeaveManagement.Application/Mappings/LeaveRequestMappings.cs new file mode 100644 index 00000000..1a376007 --- /dev/null +++ b/Examples/CleanWithCQRS/HR.LeaveManagement.Application/Mappings/LeaveRequestMappings.cs @@ -0,0 +1,66 @@ +using HR.LeaveManagement.Application.DTOs.LeaveRequest; +using HR.LeaveManagement.Domain; +using System; + +namespace HR.LeaveManagement.Application.Mappings +{ + public static class LeaveRequestMappings + { + public static LeaveRequestDto ToLeaveRequestDto(this LeaveRequest source) + { + if (source == null) return null; + return new LeaveRequestDto + { + Id = source.Id, + StartDate = source.StartDate, + EndDate = source.EndDate, + LeaveTypeId = source.LeaveTypeId, + LeaveType = source.LeaveType?.ToLeaveTypeDto(), + DateRequested = source.DateRequested, + RequestComments = source.RequestComments, + DateActioned = source.DateActioned, + Approved = source.Approved, + Cancelled = source.Cancelled, + RequestingEmployeeId = source.RequestingEmployeeId + }; + } + + public static LeaveRequestListDto ToLeaveRequestListDto(this LeaveRequest source) + { + if (source == null) return null; + return new LeaveRequestListDto + { + Id = source.Id, + RequestingEmployeeId = source.RequestingEmployeeId, + LeaveType = source.LeaveType?.ToLeaveTypeDto(), + // DateRequested maps from DateCreated (audit field) per MappingProfile + DateRequested = source.DateCreated ?? DateTime.MinValue, + StartDate = source.StartDate, + EndDate = source.EndDate, + Approved = source.Approved + }; + } + + public static LeaveRequest ToLeaveRequest(this CreateLeaveRequestDto source) + { + if (source == null) return null; + return new LeaveRequest + { + StartDate = source.StartDate, + EndDate = source.EndDate, + LeaveTypeId = source.LeaveTypeId, + RequestComments = source.RequestComments + }; + } + + public static void ApplyTo(this UpdateLeaveRequestDto source, LeaveRequest destination) + { + if (source == null || destination == null) return; + destination.StartDate = source.StartDate; + destination.EndDate = source.EndDate; + destination.LeaveTypeId = source.LeaveTypeId; + destination.RequestComments = source.RequestComments; + destination.Cancelled = source.Cancelled; + } + } +} diff --git a/Examples/CleanWithCQRS/HR.LeaveManagement.Application/Mappings/LeaveTypeMappings.cs b/Examples/CleanWithCQRS/HR.LeaveManagement.Application/Mappings/LeaveTypeMappings.cs new file mode 100644 index 00000000..ba60af8f --- /dev/null +++ b/Examples/CleanWithCQRS/HR.LeaveManagement.Application/Mappings/LeaveTypeMappings.cs @@ -0,0 +1,36 @@ +using HR.LeaveManagement.Application.DTOs.LeaveType; +using HR.LeaveManagement.Domain; + +namespace HR.LeaveManagement.Application.Mappings +{ + public static class LeaveTypeMappings + { + public static LeaveTypeDto ToLeaveTypeDto(this LeaveType source) + { + if (source == null) return null; + return new LeaveTypeDto + { + Id = source.Id, + Name = source.Name, + DefaultDays = source.DefaultDays + }; + } + + public static LeaveType ToLeaveType(this CreateLeaveTypeDto source) + { + if (source == null) return null; + return new LeaveType + { + Name = source.Name, + DefaultDays = source.DefaultDays + }; + } + + public static void ApplyTo(this LeaveTypeDto source, LeaveType destination) + { + if (source == null || destination == null) return; + destination.Name = source.Name; + destination.DefaultDays = source.DefaultDays; + } + } +} diff --git a/Examples/CleanWithCQRS/HR.LeaveManagement.Application/Profiles/MappingProfile.cs b/Examples/CleanWithCQRS/HR.LeaveManagement.Application/Profiles/MappingProfile.cs deleted file mode 100644 index 1f52c4b8..00000000 --- a/Examples/CleanWithCQRS/HR.LeaveManagement.Application/Profiles/MappingProfile.cs +++ /dev/null @@ -1,34 +0,0 @@ -using AutoMapper; -using HR.LeaveManagement.Application.DTOs; -using HR.LeaveManagement.Application.DTOs.LeaveAllocation; -using HR.LeaveManagement.Application.DTOs.LeaveRequest; -using HR.LeaveManagement.Application.DTOs.LeaveType; -using HR.LeaveManagement.Domain; -using System; -using System.Collections.Generic; -using System.Text; - -namespace HR.LeaveManagement.Application.Profiles -{ - public class MappingProfile : Profile - { - public MappingProfile() - { - #region LeaveRequest Mappings - CreateMap().ReverseMap(); - CreateMap() - .ForMember(dest => dest.DateRequested, opt => opt.MapFrom(src => src.DateCreated)) - .ReverseMap(); - CreateMap().ReverseMap(); - CreateMap().ReverseMap(); - #endregion LeaveRequest - - CreateMap().ReverseMap(); - CreateMap().ReverseMap(); - CreateMap().ReverseMap(); - - CreateMap().ReverseMap(); - CreateMap().ReverseMap(); - } - } -} diff --git a/Examples/CleanWithCQRS/HR.LeaveManagement.Identity/Services/UserService.cs b/Examples/CleanWithCQRS/HR.LeaveManagement.Identity/Services/UserService.cs index 8c84247a..3cc4ca31 100644 --- a/Examples/CleanWithCQRS/HR.LeaveManagement.Identity/Services/UserService.cs +++ b/Examples/CleanWithCQRS/HR.LeaveManagement.Identity/Services/UserService.cs @@ -1,4 +1,3 @@ -using AutoMapper; using HR.LeaveManagement.Application.Contracts.Identity; using HR.LeaveManagement.Application.Models.Identity; using HR.LeaveManagement.Identity.Models; @@ -36,7 +35,7 @@ public async Task GetEmployee(string userId) public async Task> GetEmployees() { var employees = await _userManager.GetUsersInRoleAsync("Employee"); - return employees.Select(q => new Employee { + return employees.Select(q => new Employee { Id = q.Id, Email = q.Email, Firstname = q.FirstName, diff --git a/Examples/CleanWithCQRS/HR.LeaveManagement.MVC/Controllers/LeaveRequestsController.cs b/Examples/CleanWithCQRS/HR.LeaveManagement.MVC/Controllers/LeaveRequestsController.cs index 6153fb8a..a0eae72c 100644 --- a/Examples/CleanWithCQRS/HR.LeaveManagement.MVC/Controllers/LeaveRequestsController.cs +++ b/Examples/CleanWithCQRS/HR.LeaveManagement.MVC/Controllers/LeaveRequestsController.cs @@ -1,8 +1,7 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; -using AutoMapper; using HR.LeaveManagement.MVC.Contracts; using HR.LeaveManagement.MVC.Models; using Microsoft.AspNetCore.Authorization; @@ -17,14 +16,11 @@ public class LeaveRequestsController : Controller { private readonly ILeaveTypeService _leaveTypeService; private readonly ILeaveRequestService _leaveRequestService; - private readonly IMapper _mapper; - public LeaveRequestsController(ILeaveTypeService leaveTypeService, ILeaveRequestService leaveRequestService, - IMapper mapper) + public LeaveRequestsController(ILeaveTypeService leaveTypeService, ILeaveRequestService leaveRequestService) { this._leaveTypeService = leaveTypeService; this._leaveRequestService = leaveRequestService; - this._mapper = mapper; } // GET: LeaveRequest/Create @@ -97,4 +93,4 @@ public async Task ApproveRequest(int id, bool approved) } } } -} \ No newline at end of file +} diff --git a/Examples/CleanWithCQRS/HR.LeaveManagement.MVC/Controllers/UsersController.cs b/Examples/CleanWithCQRS/HR.LeaveManagement.MVC/Controllers/UsersController.cs index 487e334e..88f8cf17 100644 --- a/Examples/CleanWithCQRS/HR.LeaveManagement.MVC/Controllers/UsersController.cs +++ b/Examples/CleanWithCQRS/HR.LeaveManagement.MVC/Controllers/UsersController.cs @@ -1,4 +1,3 @@ -using AutoMapper; using HR.LeaveManagement.MVC.Contracts; using HR.LeaveManagement.MVC.Models; using HR.LeaveManagement.MVC.Services.Base; @@ -53,7 +52,7 @@ public async Task Register(RegisterVM registration) if (isCreated) return LocalRedirect(returnUrl); } - + ModelState.AddModelError("", "Registration Attempt Failed. Please try again."); return View(registration); } diff --git a/Examples/CleanWithCQRS/HR.LeaveManagement.MVC/HR.LeaveManagement.MVC.csproj b/Examples/CleanWithCQRS/HR.LeaveManagement.MVC/HR.LeaveManagement.MVC.csproj index 16872819..b01e1000 100644 --- a/Examples/CleanWithCQRS/HR.LeaveManagement.MVC/HR.LeaveManagement.MVC.csproj +++ b/Examples/CleanWithCQRS/HR.LeaveManagement.MVC/HR.LeaveManagement.MVC.csproj @@ -9,7 +9,6 @@ - diff --git a/Examples/CleanWithCQRS/HR.LeaveManagement.MVC/MappingProfile.cs b/Examples/CleanWithCQRS/HR.LeaveManagement.MVC/MappingProfile.cs deleted file mode 100644 index f5b0353b..00000000 --- a/Examples/CleanWithCQRS/HR.LeaveManagement.MVC/MappingProfile.cs +++ /dev/null @@ -1,34 +0,0 @@ -using AutoMapper; -using HR.LeaveManagement.MVC.Models; -using HR.LeaveManagement.MVC.Services.Base; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; - -namespace HR.LeaveManagement.MVC -{ - public class MappingProfile : Profile - { - public MappingProfile() - { - CreateMap().ReverseMap(); - CreateMap().ReverseMap(); - CreateMap() - .ForMember(q => q.DateRequested, opt => opt.MapFrom(x => x.DateRequested.DateTime)) - .ForMember(q => q.StartDate, opt => opt.MapFrom(x => x.StartDate.DateTime)) - .ForMember(q => q.EndDate, opt => opt.MapFrom(x => x.EndDate.DateTime)) - .ReverseMap(); - CreateMap() - .ForMember(q => q.DateRequested, opt => opt.MapFrom(x => x.DateRequested.DateTime)) - .ForMember(q => q.StartDate, opt => opt.MapFrom(x => x.StartDate.DateTime)) - .ForMember(q => q.EndDate, opt => opt.MapFrom(x => x.EndDate.DateTime)) - .ReverseMap(); - CreateMap().ReverseMap(); - CreateMap().ReverseMap(); - CreateMap().ReverseMap(); - CreateMap().ReverseMap(); - } - } - -} diff --git a/Examples/CleanWithCQRS/HR.LeaveManagement.MVC/Mappings/ViewModelMappings.cs b/Examples/CleanWithCQRS/HR.LeaveManagement.MVC/Mappings/ViewModelMappings.cs new file mode 100644 index 00000000..f7803043 --- /dev/null +++ b/Examples/CleanWithCQRS/HR.LeaveManagement.MVC/Mappings/ViewModelMappings.cs @@ -0,0 +1,155 @@ +using HR.LeaveManagement.MVC.Models; +using HR.LeaveManagement.MVC.Services.Base; +using System.Collections.Generic; +using System.Linq; + +namespace HR.LeaveManagement.MVC.Mappings +{ + public static class ViewModelMappings + { + // ─── LeaveType ─────────────────────────────────────────────────────────── + + public static LeaveTypeVM ToLeaveTypeVM(this LeaveTypeDto source) + { + if (source == null) return null; + return new LeaveTypeVM + { + Id = source.Id, + Name = source.Name, + DefaultDays = source.DefaultDays + }; + } + + public static List ToLeaveTypeVMList(this ICollection source) + { + if (source == null) return new List(); + return source.Select(x => x.ToLeaveTypeVM()).ToList(); + } + + public static CreateLeaveTypeDto ToCreateLeaveTypeDto(this CreateLeaveTypeVM source) + { + if (source == null) return null; + return new CreateLeaveTypeDto + { + Name = source.Name, + DefaultDays = source.DefaultDays + }; + } + + public static LeaveTypeDto ToLeaveTypeDto(this LeaveTypeVM source) + { + if (source == null) return null; + return new LeaveTypeDto + { + Id = source.Id, + Name = source.Name, + DefaultDays = source.DefaultDays + }; + } + + // ─── Employee ──────────────────────────────────────────────────────────── + + public static EmployeeVM ToEmployeeVM(this Employee source) + { + if (source == null) return null; + return new EmployeeVM + { + Id = source.Id, + Email = source.Email, + Firstname = source.Firstname, + Lastname = source.Lastname + }; + } + + // ─── LeaveRequest ──────────────────────────────────────────────────────── + + public static LeaveRequestVM ToLeaveRequestVM(this LeaveRequestDto source) + { + if (source == null) return null; + return new LeaveRequestVM + { + Id = source.Id, + StartDate = source.StartDate.DateTime, + EndDate = source.EndDate.DateTime, + DateRequested = source.DateRequested.DateTime, + DateActioned = source.DateActioned?.DateTime ?? default, + LeaveTypeId = source.LeaveTypeId, + LeaveType = source.LeaveType?.ToLeaveTypeVM(), + Employee = source.Employee?.ToEmployeeVM(), + RequestComments = source.RequestComments, + Approved = source.Approved, + Cancelled = source.Cancelled + }; + } + + public static LeaveRequestVM ToLeaveRequestVM(this LeaveRequestListDto source) + { + if (source == null) return null; + return new LeaveRequestVM + { + Id = source.Id, + StartDate = source.StartDate.DateTime, + EndDate = source.EndDate.DateTime, + DateRequested = source.DateRequested.DateTime, + LeaveTypeId = source.LeaveType?.Id ?? 0, + LeaveType = source.LeaveType?.ToLeaveTypeVM(), + Employee = source.Employee?.ToEmployeeVM(), + Approved = source.Approved + }; + } + + public static List ToLeaveRequestVMList(this ICollection source) + { + if (source == null) return new List(); + return source.Select(x => x.ToLeaveRequestVM()).ToList(); + } + + public static CreateLeaveRequestDto ToCreateLeaveRequestDto(this CreateLeaveRequestVM source) + { + if (source == null) return null; + return new CreateLeaveRequestDto + { + StartDate = source.StartDate, + EndDate = source.EndDate, + LeaveTypeId = source.LeaveTypeId, + RequestComments = source.RequestComments + }; + } + + // ─── LeaveAllocation ───────────────────────────────────────────────────── + + public static LeaveAllocationVM ToLeaveAllocationVM(this LeaveAllocationDto source) + { + if (source == null) return null; + return new LeaveAllocationVM + { + Id = source.Id, + NumberOfDays = source.NumberOfDays, + Period = source.Period, + LeaveTypeId = source.LeaveTypeId, + LeaveType = source.LeaveType?.ToLeaveTypeVM() + }; + } + + public static List ToLeaveAllocationVMList(this ICollection source) + { + if (source == null) return new List(); + return source.Select(x => x.ToLeaveAllocationVM()).ToList(); + } + + // ─── Registration ──────────────────────────────────────────────────────── + + public static RegistrationRequest ToRegistrationRequest(this RegisterVM source) + { + if (source == null) return null; + return new RegistrationRequest + { + FirstName = source.FirstName, + LastName = source.LastName, + Email = source.Email, + UserName = source.UserName, + Password = source.Password + }; + } + } +} diff --git a/Examples/CleanWithCQRS/HR.LeaveManagement.MVC/Program.cs b/Examples/CleanWithCQRS/HR.LeaveManagement.MVC/Program.cs index f8374e1e..4e47eb50 100644 --- a/Examples/CleanWithCQRS/HR.LeaveManagement.MVC/Program.cs +++ b/Examples/CleanWithCQRS/HR.LeaveManagement.MVC/Program.cs @@ -5,7 +5,6 @@ using System.Reflection; using HR.LeaveManagement.MVC.Middleware; using Microsoft.Extensions.DependencyInjection; -using HR.LeaveManagement.MVC; var builder = WebApplication.CreateBuilder(args); @@ -26,7 +25,6 @@ builder.Services.AddTransient(); builder.Services.AddHttpClient(cl => cl.BaseAddress = new Uri("https://localhost:7273")); -builder.Services.AddAutoMapper(x => x.AddProfile(new MappingProfile())); builder.Services.AddScoped(); builder.Services.AddScoped(); diff --git a/Examples/CleanWithCQRS/HR.LeaveManagement.MVC/Services/AuthenticationService.cs b/Examples/CleanWithCQRS/HR.LeaveManagement.MVC/Services/AuthenticationService.cs index 0f1bb470..3f30d5bc 100644 --- a/Examples/CleanWithCQRS/HR.LeaveManagement.MVC/Services/AuthenticationService.cs +++ b/Examples/CleanWithCQRS/HR.LeaveManagement.MVC/Services/AuthenticationService.cs @@ -1,4 +1,4 @@ -using AutoMapper; +using HR.LeaveManagement.MVC.Mappings; using HR.LeaveManagement.MVC.Models; using HR.LeaveManagement.MVC.Services.Base; using Microsoft.AspNetCore.Authentication; @@ -18,15 +18,12 @@ namespace HR.LeaveManagement.MVC.Contracts public class AuthenticationService : BaseHttpService, IAuthenticationService { private readonly IHttpContextAccessor _httpContextAccessor; - private readonly IMapper _mapper; private JsonWebTokenHandler _tokenHandler; - public AuthenticationService(IClient client, ILocalStorageService localStorage, IHttpContextAccessor httpContextAccessor, - IMapper mapper) + public AuthenticationService(IClient client, ILocalStorageService localStorage, IHttpContextAccessor httpContextAccessor) : base(client, localStorage) { this._httpContextAccessor = httpContextAccessor; - this._mapper = mapper; this._tokenHandler = new JsonWebTokenHandler(); } @@ -50,7 +47,7 @@ public async Task Authenticate(string email, string password) } return false; } - catch + catch { return false; } @@ -58,8 +55,7 @@ public async Task Authenticate(string email, string password) public async Task Register(RegisterVM registration) { - - RegistrationRequest registrationRequest = _mapper.Map(registration); + RegistrationRequest registrationRequest = registration.ToRegistrationRequest(); var response = await _client.RegisterAsync(registrationRequest); if (!string.IsNullOrEmpty(response.UserId)) diff --git a/Examples/CleanWithCQRS/HR.LeaveManagement.MVC/Services/LeaveAllocationService.cs b/Examples/CleanWithCQRS/HR.LeaveManagement.MVC/Services/LeaveAllocationService.cs index f1b812ad..e6d41350 100644 --- a/Examples/CleanWithCQRS/HR.LeaveManagement.MVC/Services/LeaveAllocationService.cs +++ b/Examples/CleanWithCQRS/HR.LeaveManagement.MVC/Services/LeaveAllocationService.cs @@ -1,5 +1,4 @@ -using AutoMapper; -using HR.LeaveManagement.MVC.Contracts; +using HR.LeaveManagement.MVC.Contracts; using HR.LeaveManagement.MVC.Models; using HR.LeaveManagement.MVC.Services.Base; using System; diff --git a/Examples/CleanWithCQRS/HR.LeaveManagement.MVC/Services/LeaveRequestService.cs b/Examples/CleanWithCQRS/HR.LeaveManagement.MVC/Services/LeaveRequestService.cs index 56059636..0fa25926 100644 --- a/Examples/CleanWithCQRS/HR.LeaveManagement.MVC/Services/LeaveRequestService.cs +++ b/Examples/CleanWithCQRS/HR.LeaveManagement.MVC/Services/LeaveRequestService.cs @@ -1,5 +1,5 @@ -using AutoMapper; using HR.LeaveManagement.MVC.Contracts; +using HR.LeaveManagement.MVC.Mappings; using HR.LeaveManagement.MVC.Models; using HR.LeaveManagement.MVC.Services.Base; using System; @@ -12,13 +12,11 @@ namespace HR.LeaveManagement.MVC.Services public class LeaveRequestService : BaseHttpService, ILeaveRequestService { private readonly ILocalStorageService _localStorageService; - private readonly IMapper _mapper; private readonly IClient _httpclient; - public LeaveRequestService(IMapper mapper, IClient httpclient, ILocalStorageService localStorageService) : base(httpclient, localStorageService) + public LeaveRequestService(IClient httpclient, ILocalStorageService localStorageService) : base(httpclient, localStorageService) { this._localStorageService = localStorageService; - this._mapper = mapper; this._httpclient = httpclient; } @@ -32,7 +30,6 @@ public async Task ApproveLeaveRequest(int id, bool approved) } catch (Exception) { - throw; } } @@ -42,7 +39,7 @@ public async Task> CreateLeaveRequest(CreateLeaveRequestVM leaveRe try { var response = new Response(); - CreateLeaveRequestDto createLeaveRequest = _mapper.Map(leaveRequest); + CreateLeaveRequestDto createLeaveRequest = leaveRequest.ToCreateLeaveRequestDto(); AddBearerToken(); var apiResponse = await _client.LeaveRequestsPOSTAsync(createLeaveRequest); if (apiResponse.Success) @@ -81,7 +78,7 @@ public async Task GetAdminLeaveRequestList() ApprovedRequests = leaveRequests.Count(q => q.Approved == true), PendingRequests = leaveRequests.Count(q => q.Approved == null), RejectedRequests = leaveRequests.Count(q => q.Approved == false), - LeaveRequests = _mapper.Map>(leaveRequests) + LeaveRequests = leaveRequests.ToLeaveRequestVMList() }; return model; } @@ -90,7 +87,7 @@ public async Task GetLeaveRequest(int id) { AddBearerToken(); var leaveRequest = await _client.LeaveRequestsGETAsync(id); - return _mapper.Map(leaveRequest); + return leaveRequest.ToLeaveRequestVM(); } public async Task GetUserLeaveRequests() @@ -100,8 +97,8 @@ public async Task GetUserLeaveRequests() var allocations = await _client.LeaveAllocationsAllAsync(isLoggedInUser: true); var model = new EmployeeLeaveRequestViewVM { - LeaveAllocations = _mapper.Map>(allocations), - LeaveRequests = _mapper.Map>(leaveRequests) + LeaveAllocations = allocations.ToLeaveAllocationVMList(), + LeaveRequests = leaveRequests.ToLeaveRequestVMList() }; return model; diff --git a/Examples/CleanWithCQRS/HR.LeaveManagement.MVC/Services/LeaveTypeService.cs b/Examples/CleanWithCQRS/HR.LeaveManagement.MVC/Services/LeaveTypeService.cs index 80a53ea3..91482b1e 100644 --- a/Examples/CleanWithCQRS/HR.LeaveManagement.MVC/Services/LeaveTypeService.cs +++ b/Examples/CleanWithCQRS/HR.LeaveManagement.MVC/Services/LeaveTypeService.cs @@ -1,5 +1,5 @@ -using AutoMapper; using HR.LeaveManagement.MVC.Contracts; +using HR.LeaveManagement.MVC.Mappings; using HR.LeaveManagement.MVC.Models; using HR.LeaveManagement.MVC.Services.Base; using System; @@ -12,13 +12,11 @@ namespace HR.LeaveManagement.MVC.Services public class LeaveTypeService : BaseHttpService, ILeaveTypeService { private readonly ILocalStorageService _localStorageService; - private readonly IMapper _mapper; private readonly IClient _httpclient; - public LeaveTypeService(IMapper mapper, IClient httpclient, ILocalStorageService localStorageService) : base(httpclient, localStorageService) + public LeaveTypeService(IClient httpclient, ILocalStorageService localStorageService) : base(httpclient, localStorageService) { this._localStorageService = localStorageService; - this._mapper = mapper; this._httpclient = httpclient; } @@ -27,7 +25,7 @@ public async Task> CreateLeaveType(CreateLeaveTypeVM leaveType) try { var response = new Response(); - CreateLeaveTypeDto createLeaveType = _mapper.Map(leaveType); + CreateLeaveTypeDto createLeaveType = leaveType.ToCreateLeaveTypeDto(); AddBearerToken(); var apiResponse = await _client.LeaveTypesPOSTAsync(createLeaveType); if (apiResponse.Success) @@ -68,21 +66,21 @@ public async Task GetLeaveTypeDetails(int id) { AddBearerToken(); var leaveType = await _client.LeaveTypesGETAsync(id); - return _mapper.Map(leaveType); + return leaveType.ToLeaveTypeVM(); } public async Task> GetLeaveTypes() { AddBearerToken(); var leaveTypes = await _client.LeaveTypesAllAsync(); - return _mapper.Map>(leaveTypes); + return leaveTypes.ToLeaveTypeVMList(); } public async Task> UpdateLeaveType(int id, LeaveTypeVM leaveType) { try { - LeaveTypeDto leaveTypeDto = _mapper.Map(leaveType); + LeaveTypeDto leaveTypeDto = leaveType.ToLeaveTypeDto(); AddBearerToken(); await _client.LeaveTypesPUTAsync(id.ToString(), leaveTypeDto); return new Response() { Success = true }; @@ -92,6 +90,5 @@ public async Task> UpdateLeaveType(int id, LeaveTypeVM leaveType) return ConvertApiExceptions(ex); } } - } } diff --git a/Src/RCommon.ApplicationServices/Validation/ValidationService.cs b/Src/RCommon.ApplicationServices/Validation/ValidationService.cs index ab5c785b..d7bb9ea9 100644 --- a/Src/RCommon.ApplicationServices/Validation/ValidationService.cs +++ b/Src/RCommon.ApplicationServices/Validation/ValidationService.cs @@ -38,7 +38,7 @@ public async Task ValidateAsync(T target, bool throwOnFaul { var provider = scope.ServiceProvider.GetService(); Guard.IsNotNull(provider!, nameof(provider)); - var outcome = await provider!.ValidateAsync(target, throwOnFaults, cancellationToken); + var outcome = await provider!.ValidateAsync(target, throwOnFaults, cancellationToken).ConfigureAwait(false); return outcome; } } diff --git a/Src/RCommon.Core/DisposableResource.cs b/Src/RCommon.Core/DisposableResource.cs index c67bd65b..22324c5e 100644 --- a/Src/RCommon.Core/DisposableResource.cs +++ b/Src/RCommon.Core/DisposableResource.cs @@ -13,19 +13,10 @@ namespace RCommon /// /// /// Derived classes should override and/or - /// to release managed and unmanaged resources. The finalizer calls - /// with false to release unmanaged resources only. + /// to release managed and unmanaged resources. /// public abstract class DisposableResource : IDisposable, IAsyncDisposable { - /// - /// Finalizer that invokes with false to release unmanaged resources. - /// - ~DisposableResource() - { - Dispose(false); - } - /// /// Performs application-defined tasks associated with freeing, releasing, or resetting resources. /// Suppresses finalization after disposal. @@ -44,7 +35,7 @@ public void Dispose() /// A representing the asynchronous dispose operation. public async ValueTask DisposeAsync() { - await this.DisposeAsync(true); + await this.DisposeAsync(true).ConfigureAwait(false); GC.SuppressFinalize(this); } @@ -61,10 +52,9 @@ protected virtual void Dispose(bool disposing) /// /// true to release both managed and unmanaged resources; false to release only unmanaged resources. /// A representing the asynchronous dispose operation. - protected async virtual Task DisposeAsync(bool disposing) + protected virtual Task DisposeAsync(bool disposing) { - - await Task.Yield(); + return Task.CompletedTask; } } } diff --git a/Src/RCommon.Core/EventHandling/IEventBus.cs b/Src/RCommon.Core/EventHandling/IEventBus.cs index 17034927..c1efcf0d 100644 --- a/Src/RCommon.Core/EventHandling/IEventBus.cs +++ b/Src/RCommon.Core/EventHandling/IEventBus.cs @@ -1,4 +1,5 @@ -using System.Threading.Tasks; +using System.Threading; +using System.Threading.Tasks; namespace RCommon.EventHandling { @@ -13,8 +14,9 @@ public interface IEventBus /// /// The type of event to publish. /// The event instance to publish. + /// Optional cancellation token. /// A representing the asynchronous operation. - Task PublishAsync(TEvent @event); + Task PublishAsync(TEvent @event, CancellationToken cancellationToken = default); /// /// Subscribes a specific event handler to a specific event type. diff --git a/Src/RCommon.Core/EventHandling/InMemoryEventBus.cs b/Src/RCommon.Core/EventHandling/InMemoryEventBus.cs index aa224abe..7008aa1d 100644 --- a/Src/RCommon.Core/EventHandling/InMemoryEventBus.cs +++ b/Src/RCommon.Core/EventHandling/InMemoryEventBus.cs @@ -1,18 +1,18 @@ -#region MIT License +#region MIT License // The MIT License (MIT) -// +// // Original Source: https://github.com/jacqueskang/EventBus/blob/develop/src/JKang.EventBus.Core/InMemory/InMemoryEventBus.cs -// +// // Permission is hereby granted, free of charge, to any person obtaining a copy of // this software and associated documentation files (the "Software"), to deal in // the Software without restriction, including without limitation the rights to // use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of // the Software, and to permit persons to whom the Software is furnished to do so, // subject to the following conditions: -// +// // The above copyright notice and this permission notice shall be included in all // copies or substantial portions of the Software. -// +// // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS // FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR @@ -39,38 +39,42 @@ namespace RCommon.EventHandling /// handlers from the dependency injection container. /// /// - /// Subscriptions are registered directly into the at configuration time. - /// Publishing creates a new scope and resolves all handlers via reflection to support polymorphic event dispatch. + /// Subscriptions registered via or + /// are tracked internally and resolved + /// at publish time via . For best results, register subscribers + /// in the DI container during configuration using InMemoryEventBusBuilderExtensions.AddSubscriber. /// public class InMemoryEventBus : IEventBus { - private readonly IServiceCollection _services; private readonly IServiceProvider _serviceProvider; + private readonly ConcurrentBag<(Type serviceType, Type implementationType)> _dynamicSubscriptions = new(); /// /// Initializes a new instance of . /// /// The root service provider used to create scopes for event publishing. - /// The service collection for registering subscriber services at configuration time. - public InMemoryEventBus(IServiceProvider serviceProvider, IServiceCollection services) + public InMemoryEventBus(IServiceProvider serviceProvider) { _serviceProvider = serviceProvider; - _services = services; } /// + /// + /// Tracks the subscription internally. The handler will be resolved via + /// at publish time within a new scope. + /// public IEventBus Subscribe() where TEvent : class where TEventHandler : class, ISubscriber { - _services.AddScoped, TEventHandler>(); + _dynamicSubscriptions.Add((typeof(ISubscriber), typeof(TEventHandler))); return this; } /// /// /// Uses reflection to discover all interfaces on - /// and registers each as a scoped service. + /// and tracks each for resolution at publish time. /// public IEventBus SubscribeAllHandledEvents() where TEventHandler : class @@ -84,7 +88,7 @@ public IEventBus SubscribeAllHandledEvents() foreach (Type serviceType in serviceTypes) { - _services.AddScoped(serviceType, implementationType); + _dynamicSubscriptions.Add((serviceType, implementationType)); } return this; @@ -94,8 +98,9 @@ public IEventBus SubscribeAllHandledEvents() /// /// Creates a new DI scope and uses reflection to resolve handlers for the runtime event type, /// invoking on each handler sequentially. + /// Also resolves handlers from dynamic subscriptions registered via . /// - public async Task PublishAsync(TEvent @event) + public async Task PublishAsync(TEvent @event, CancellationToken cancellationToken = default) { using (IServiceScope scope = _serviceProvider.CreateScope()) { @@ -104,7 +109,13 @@ public async Task PublishAsync(TEvent @event) Type openHandlerType = typeof(ISubscriber<>); Type handlerType = openHandlerType.MakeGenericType(eventType); IEnumerable handlers = scope.ServiceProvider.GetServices(handlerType); - foreach (object? handler in handlers) + + // Also resolve dynamically subscribed handlers via ActivatorUtilities + var dynamicHandlers = _dynamicSubscriptions + .Where(s => s.serviceType == handlerType) + .Select(s => ActivatorUtilities.CreateInstance(scope.ServiceProvider, s.implementationType)); + + foreach (object? handler in handlers.Concat(dynamicHandlers)) { if (handler == null) continue; @@ -112,10 +123,10 @@ public async Task PublishAsync(TEvent @event) object? result = handlerType .GetTypeInfo() .GetDeclaredMethod(nameof(ISubscriber.HandleAsync)) - ?.Invoke(handler, new object[] { @event, CancellationToken.None}); + ?.Invoke(handler, new object[] { @event, cancellationToken }); if (result is Task task) { - await task; + await task.ConfigureAwait(false); } } } diff --git a/Src/RCommon.Core/EventHandling/Producers/EventSubscriptionManager.cs b/Src/RCommon.Core/EventHandling/Producers/EventSubscriptionManager.cs index f88aadaa..b17b972e 100644 --- a/Src/RCommon.Core/EventHandling/Producers/EventSubscriptionManager.cs +++ b/Src/RCommon.Core/EventHandling/Producers/EventSubscriptionManager.cs @@ -66,7 +66,11 @@ public IEnumerable GetProducersForEvent( { if (_eventProducerMap.TryGetValue(eventType, out var allowedProducerTypes)) { - return allProducers.Where(p => allowedProducerTypes.Contains(p.GetType())); + lock (allowedProducerTypes) + { + var snapshot = allowedProducerTypes.ToHashSet(); + return allProducers.Where(p => snapshot.Contains(p.GetType())); + } } // No subscriptions registered for this event type - fall back to all producers @@ -82,7 +86,10 @@ public bool ShouldProduceEvent(Type producerType, Type eventType) { if (_eventProducerMap.TryGetValue(eventType, out var allowedProducerTypes)) { - return allowedProducerTypes.Contains(producerType); + lock (allowedProducerTypes) + { + return allowedProducerTypes.Contains(producerType); + } } // No subscriptions registered for this event type - allow all producers @@ -93,5 +100,14 @@ public bool ShouldProduceEvent(Type producerType, Type eventType) /// Returns true if any subscriptions have been configured at all. /// public bool HasSubscriptions => !_eventProducerMap.IsEmpty; + + /// + /// Clears all subscriptions. Primarily intended for testing scenarios. + /// + public void ClearSubscriptions() + { + _builderProducerMap.Clear(); + _eventProducerMap.Clear(); + } } } diff --git a/Src/RCommon.Core/EventHandling/Producers/IEventRouter.cs b/Src/RCommon.Core/EventHandling/Producers/IEventRouter.cs index 486eaf82..bb7f1ec5 100644 --- a/Src/RCommon.Core/EventHandling/Producers/IEventRouter.cs +++ b/Src/RCommon.Core/EventHandling/Producers/IEventRouter.cs @@ -1,5 +1,6 @@ using RCommon.Models.Events; using System.Collections.Generic; +using System.Threading; using System.Threading.Tasks; namespace RCommon.EventHandling.Producers @@ -27,17 +28,19 @@ public interface IEventRouter /// Wires up all of the event producers for all stored transactional events and then executes each /// for events and events. /// + /// Optional cancellation token. /// Async Task Result - Task RouteEventsAsync(); + Task RouteEventsAsync(CancellationToken cancellationToken = default); /// /// Wires up all of the event producers for all events passed into parameters and then executes each - /// for events and events. + /// for events and events. /// - /// Events that needs to be published or sent through the + /// Events that needs to be published or sent through the /// producers that are registered. + /// Optional cancellation token. /// Async Task Result /// This will not send stored transactional events, only the events sent through the parameter. - Task RouteEventsAsync(IEnumerable transactionalEvents); + Task RouteEventsAsync(IEnumerable transactionalEvents, CancellationToken cancellationToken = default); } } diff --git a/Src/RCommon.Core/EventHandling/Producers/InMemoryTransactionalEventRouter.cs b/Src/RCommon.Core/EventHandling/Producers/InMemoryTransactionalEventRouter.cs index 7cf810cb..3c77f0bc 100644 --- a/Src/RCommon.Core/EventHandling/Producers/InMemoryTransactionalEventRouter.cs +++ b/Src/RCommon.Core/EventHandling/Producers/InMemoryTransactionalEventRouter.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Linq; using System.Text; +using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; @@ -38,7 +39,7 @@ public InMemoryTransactionalEventRouter(IServiceProvider serviceProvider, ILogge } /// - public async Task RouteEventsAsync(IEnumerable transactionalEvents) + public async Task RouteEventsAsync(IEnumerable transactionalEvents, CancellationToken cancellationToken = default) { try { @@ -58,21 +59,21 @@ public async Task RouteEventsAsync(IEnumerable transactional { // Produce the Synchronized Events first _logger.LogInformation($"{this.GetGenericTypeName()} is routing {syncEvents.Count().ToString()} synchronized transactional events."); - await this.ProduceSyncEvents(syncEvents, eventProducers).ConfigureAwait(false); + await this.ProduceSyncEvents(syncEvents, eventProducers, cancellationToken).ConfigureAwait(false); } if (asyncEvents.Any()) { // Produce the Async Events _logger.LogInformation($"{this.GetGenericTypeName()} is routing {asyncEvents.Count().ToString()} asynchronous transactional events."); - await this.ProduceAsyncEvents(asyncEvents, eventProducers).ConfigureAwait(false); + await this.ProduceAsyncEvents(asyncEvents, eventProducers, cancellationToken).ConfigureAwait(false); } - + if (remainingEvents.Any()) // Could be ISerializable events left over that are not marked as ISyncEvent or IAsyncEvent { // Send as synchronized by default _logger.LogInformation($"No sync/async events found. {this.GetGenericTypeName()} is routing {remainingEvents.Count().ToString()} serializable events as synchronized transactional events by default."); - await this.ProduceSyncEvents(remainingEvents, eventProducers).ConfigureAwait(false); + await this.ProduceSyncEvents(remainingEvents, eventProducers, cancellationToken).ConfigureAwait(false); } } @@ -97,7 +98,7 @@ public async Task RouteEventsAsync(IEnumerable transactional /// /// The async events to produce. /// All registered event producers (will be filtered per event). - private async Task ProduceAsyncEvents(IEnumerable asyncEvents, IEnumerable eventProducers) + private async Task ProduceAsyncEvents(IEnumerable asyncEvents, IEnumerable eventProducers, CancellationToken cancellationToken = default) { var eventTaskList = new List(); foreach (var @event in asyncEvents) @@ -105,10 +106,10 @@ private async Task ProduceAsyncEvents(IEnumerable asyncEvent var filteredProducers = _subscriptionManager.GetProducersForEvent(eventProducers, @event.GetType()); foreach (var producer in filteredProducers) { - eventTaskList.Add(producer.ProduceEventAsync(@event)); + eventTaskList.Add(producer.ProduceEventAsync(@event, cancellationToken)); } } - await Task.WhenAll(eventTaskList); + await Task.WhenAll(eventTaskList).ConfigureAwait(false); } /// @@ -116,7 +117,7 @@ private async Task ProduceAsyncEvents(IEnumerable asyncEvent /// /// The synchronous events to produce. /// All registered event producers (will be filtered per event). - private async Task ProduceSyncEvents(IEnumerable syncEvents, IEnumerable eventProducers) + private async Task ProduceSyncEvents(IEnumerable syncEvents, IEnumerable eventProducers, CancellationToken cancellationToken = default) { foreach (var @event in syncEvents) { @@ -124,7 +125,7 @@ private async Task ProduceSyncEvents(IEnumerable syncEvents, var filteredProducers = _subscriptionManager.GetProducersForEvent(eventProducers, @event.GetType()); foreach (var producer in filteredProducers) { - await producer.ProduceEventAsync(@event).ConfigureAwait(false); + await producer.ProduceEventAsync(@event, cancellationToken).ConfigureAwait(false); } } } @@ -134,14 +135,14 @@ private async Task ProduceSyncEvents(IEnumerable syncEvents, /// /// Completed Task /// This should help us avoid race conditions e.g. a subscriber/event handler adds new events while we are processing the current list - public async Task RouteEventsAsync() + public async Task RouteEventsAsync(CancellationToken cancellationToken = default) { - - while (_storedTransactionalEvents.Any()) + + while (_storedTransactionalEvents.Any()) { var currentEvents = new List(); _storedTransactionalEvents.ForEach(x => currentEvents.Add(x)); - await this.RouteEventsAsync(currentEvents).ConfigureAwait(false); + await this.RouteEventsAsync(currentEvents, cancellationToken).ConfigureAwait(false); RemoveEvents(currentEvents); } } diff --git a/Src/RCommon.Core/EventHandling/Producers/PublishWithEventBusEventProducer.cs b/Src/RCommon.Core/EventHandling/Producers/PublishWithEventBusEventProducer.cs index 3ff47304..766037c1 100644 --- a/Src/RCommon.Core/EventHandling/Producers/PublishWithEventBusEventProducer.cs +++ b/Src/RCommon.Core/EventHandling/Producers/PublishWithEventBusEventProducer.cs @@ -62,7 +62,7 @@ public async Task ProduceEventAsync(T @event, CancellationToken cancellationT } // This should already be using a Scoped publish method - await _eventBus.PublishAsync(@event).ConfigureAwait(false); + await _eventBus.PublishAsync(@event, cancellationToken).ConfigureAwait(false); } catch (Exception ex) { diff --git a/Src/RCommon.Core/Extensions/CollectionExtensions.cs b/Src/RCommon.Core/Extensions/CollectionExtensions.cs index bec0ac7d..e93bbda7 100644 --- a/Src/RCommon.Core/Extensions/CollectionExtensions.cs +++ b/Src/RCommon.Core/Extensions/CollectionExtensions.cs @@ -245,15 +245,20 @@ public static async Task ForEachAsync(this LinkedList linkedList, Action /// The type that this extension is applicable for. /// The IEnumerable instance that ths extension operates on. - /// The action excecuted for each item in the enumerable. - public static void TryForEach(this IEnumerable collection, Action action) + /// The action executed for each item in the enumerable. + /// Optional callback invoked for each exception. If null, exceptions are silently ignored. + public static void TryForEach(this IEnumerable collection, Action action, Action? onError = null) { foreach (var item in collection) { try { action(item); - }catch{} + } + catch (Exception ex) + { + onError?.Invoke(item, ex); + } } } @@ -273,16 +278,21 @@ private static bool ForEachHelper() /// action delegate and if the action throws an exception, continues executing. /// /// The type that this extension is applicable for. - /// The IEnumerator instace + /// The IEnumerator instance. /// The action executed for each item in the enumerator. - public static void TryForEach(this IEnumerator enumerator, Action action) + /// Optional callback invoked for each exception. If null, exceptions are silently ignored. + public static void TryForEach(this IEnumerator enumerator, Action action, Action? onError = null) { while (enumerator.MoveNext()) { try { action(enumerator.Current); - }catch{} + } + catch (Exception ex) + { + onError?.Invoke(enumerator.Current, ex); + } } } diff --git a/Src/RCommon.Core/Extensions/IDataReaderExtensions.cs b/Src/RCommon.Core/Extensions/IDataReaderExtensions.cs index e4c3e87f..e5a9142a 100644 --- a/Src/RCommon.Core/Extensions/IDataReaderExtensions.cs +++ b/Src/RCommon.Core/Extensions/IDataReaderExtensions.cs @@ -34,9 +34,8 @@ public static object GetValue(this IDataReader dr, int index, object defaultValu rv = defaultValue; } } - catch (Exception) + catch (IndexOutOfRangeException) { - rv = defaultValue; } return rv; @@ -63,9 +62,8 @@ public static object GetValue(this IDataReader dr, string columnName, object def rv = defaultValue; } } - catch (Exception) + catch (IndexOutOfRangeException) { - rv = defaultValue; } return rv; @@ -83,7 +81,7 @@ public static DataTable ToDataTable(this IDataReader dr) DataTable dtData = new DataTable(); DataColumn dc; DataRow row; - System.Collections.ArrayList al = new System.Collections.ArrayList(); + var al = new List(); if (dtSchema == null) return dtData; @@ -113,7 +111,7 @@ public static DataTable ToDataTable(this IDataReader dr) for (int i = 0; i < al.Count; i++) { - row[((string)al[i]!)] = dr[(string)al[i]!]; + row[al[i]] = dr[al[i]]; } dtData.Rows.Add(row); @@ -137,7 +135,7 @@ public static DataTable ToDataTable(this IDataReader dr, bool destroyReader) DataTable dtData = new DataTable(); DataColumn dc; DataRow row; - System.Collections.ArrayList al = new System.Collections.ArrayList(); + var al = new List(); if (dtSchema == null) return dtData; @@ -165,7 +163,7 @@ public static DataTable ToDataTable(this IDataReader dr, bool destroyReader) for (int i = 0; i < al.Count; i++) { - row[((string)al[i]!)] = dr[(string)al[i]!]; + row[al[i]] = dr[al[i]]; } dtData.Rows.Add(row); @@ -175,11 +173,6 @@ public static DataTable ToDataTable(this IDataReader dr, bool destroyReader) return dtData; } - catch (Exception) - { - - throw; - } finally { if (destroyReader && !dr.IsClosed) diff --git a/Src/RCommon.Core/Extensions/SpecificationExtensions.cs b/Src/RCommon.Core/Extensions/SpecificationExtensions.cs index 0df8593e..de727624 100644 --- a/Src/RCommon.Core/Extensions/SpecificationExtensions.cs +++ b/Src/RCommon.Core/Extensions/SpecificationExtensions.cs @@ -45,5 +45,19 @@ public static ISpecification Or(this ISpecification rightHand, ISpecifi Expression.Lambda>(newExpression, leftHand.Predicate.Parameters) ); } + + /// + /// Returns a new specification that negates the given specification. + /// + /// + /// The specification to negate. + /// A new specification whose predicate is the logical negation of the input. + public static ISpecification Not(this ISpecification specification) + { + var negated = Expression.Not(specification.Predicate.Body); + return new Specification( + Expression.Lambda>(negated, specification.Predicate.Parameters) + ); + } } } diff --git a/Src/RCommon.Core/Extensions/TypeExtensions.cs b/Src/RCommon.Core/Extensions/TypeExtensions.cs index 3afb9dd5..a849bc34 100644 --- a/Src/RCommon.Core/Extensions/TypeExtensions.cs +++ b/Src/RCommon.Core/Extensions/TypeExtensions.cs @@ -38,6 +38,7 @@ namespace RCommon /// public static class TypeExtensions { + private const int MaxCacheSize = 1024; /// /// Gets a human-readable generic type name (e.g., "List<String>" instead of "List`1"). /// For non-generic types, returns . @@ -74,19 +75,27 @@ public static string GetGenericTypeName(this Type type) /// A pretty-printed type name string. public static string PrettyPrint(this Type type) { - return PrettyPrintCache.GetOrAdd( - type, - t => - { - try - { - return PrettyPrintRecursive(t, 0); - } - catch (Exception) - { - return t.Name; - } - }); + if (PrettyPrintCache.TryGetValue(type, out var cached)) + { + return cached; + } + + string result; + try + { + result = PrettyPrintRecursive(type, 0); + } + catch (Exception) + { + result = type.Name; + } + + if (PrettyPrintCache.Count < MaxCacheSize) + { + PrettyPrintCache.TryAdd(type, result); + } + + return result; } /// @@ -102,9 +111,19 @@ public static string PrettyPrint(this Type type) /// A cache key string in the format "TypeName[hash: hashCode]". public static string GetCacheKey(this Type type) { - return TypeCacheKeys.GetOrAdd( - type, - t => $"{t.PrettyPrint()}[hash: {t.GetHashCode()}]"); + if (TypeCacheKeys.TryGetValue(type, out var cached)) + { + return cached; + } + + var result = $"{type.PrettyPrint()}[hash: {type.GetHashCode()}]"; + + if (TypeCacheKeys.Count < MaxCacheSize) + { + TypeCacheKeys.TryAdd(type, result); + } + + return result; } /// diff --git a/Src/RCommon.Core/RCommonBuilder.cs b/Src/RCommon.Core/RCommonBuilder.cs index e8f85e18..14e57c17 100644 --- a/Src/RCommon.Core/RCommonBuilder.cs +++ b/Src/RCommon.Core/RCommonBuilder.cs @@ -38,7 +38,7 @@ public RCommonBuilder(IServiceCollection services) // Event Bus Services.AddSingleton(sp => { - return new InMemoryEventBus(sp, Services); + return new InMemoryEventBus(sp); }); Services.AddScoped(); } diff --git a/Src/RCommon.Core/Reflection/ObjectGraphWalker.cs b/Src/RCommon.Core/Reflection/ObjectGraphWalker.cs index d279f235..47416c84 100644 --- a/Src/RCommon.Core/Reflection/ObjectGraphWalker.cs +++ b/Src/RCommon.Core/Reflection/ObjectGraphWalker.cs @@ -27,7 +27,7 @@ public static class ObjectGraphWalker public static IEnumerable TraverseGraphFor(object root) where T : class { var results = new List(); - var visited = new ArrayList(); + var visited = new HashSet(ReferenceEqualityComparer.Instance); Walk(root, results, visited); return results.ToArray(); } @@ -40,13 +40,18 @@ public static IEnumerable TraverseGraphFor(object root) where T : class /// The type to search for. /// The current object being inspected. /// The accumulator list for matching instances. - /// The list of already-visited objects to prevent cycles. - private static void Walk(object? source, IList results, IList visited) + /// The set of already-visited objects to prevent cycles. + private static void Walk(object? source, IList results, HashSet visited) where T : class { if (source == null) return; - if (visited.Contains(source)) return; - visited.Add(source); + + // Value types cannot match T (which is constrained to class) and cannot form + // circular references, so skip them to avoid infinite recursion on self-referential + // value type properties (e.g., DateTime.Date -> DateTime). + if (source.GetType().IsValueType) return; + + if (!visited.Add(source)) return; // source is instance of T or any derived class if (typeof(T).IsInstanceOfType(source)) @@ -70,9 +75,9 @@ private static void Walk(object? source, IList results, IList visited) /// The type to search for. /// The enumerable sequence to iterate. /// The accumulator list for matching instances. - /// The list of already-visited objects to prevent cycles. + /// The set of already-visited objects to prevent cycles. private static void WalkSequence(IEnumerable? source, - IList results, IList visited) + IList results, HashSet visited) where T : class { if (source == null) return; @@ -89,9 +94,9 @@ private static void WalkSequence(IEnumerable? source, /// The type to search for. /// The complex object whose members are inspected. /// The accumulator list for matching instances. - /// The list of already-visited objects to prevent cycles. + /// The set of already-visited objects to prevent cycles. private static void WalkComplexObject(object? source, - IList results, IList visited) + IList results, HashSet visited) where T : class { if (source == null) return; diff --git a/Src/RCommon.Core/StateMachines/IStateConfigurator.cs b/Src/RCommon.Core/StateMachines/IStateConfigurator.cs new file mode 100644 index 00000000..1dd969f9 --- /dev/null +++ b/Src/RCommon.Core/StateMachines/IStateConfigurator.cs @@ -0,0 +1,16 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace RCommon.StateMachines; + +public interface IStateConfigurator + where TState : struct, Enum + where TTrigger : struct, Enum +{ + IStateConfigurator Permit(TTrigger trigger, TState destinationState); + IStateConfigurator OnEntry(Func action); + IStateConfigurator OnExit(Func action); + IStateConfigurator PermitIf( + TTrigger trigger, TState destinationState, Func guard); +} diff --git a/Src/RCommon.Core/StateMachines/IStateMachine.cs b/Src/RCommon.Core/StateMachines/IStateMachine.cs new file mode 100644 index 00000000..7c116472 --- /dev/null +++ b/Src/RCommon.Core/StateMachines/IStateMachine.cs @@ -0,0 +1,17 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace RCommon.StateMachines; + +public interface IStateMachine + where TState : struct, Enum + where TTrigger : struct, Enum +{ + TState CurrentState { get; } + Task FireAsync(TTrigger trigger, CancellationToken cancellationToken = default); + Task FireAsync(TTrigger trigger, TData data, CancellationToken cancellationToken = default); + bool CanFire(TTrigger trigger); + IEnumerable PermittedTriggers { get; } +} diff --git a/Src/RCommon.Core/StateMachines/IStateMachineConfigurator.cs b/Src/RCommon.Core/StateMachines/IStateMachineConfigurator.cs new file mode 100644 index 00000000..5d8db0ba --- /dev/null +++ b/Src/RCommon.Core/StateMachines/IStateMachineConfigurator.cs @@ -0,0 +1,11 @@ +using System; + +namespace RCommon.StateMachines; + +public interface IStateMachineConfigurator + where TState : struct, Enum + where TTrigger : struct, Enum +{ + IStateConfigurator ForState(TState state); + IStateMachine Build(TState initialState); +} diff --git a/Src/RCommon.Dapper/Crud/DapperAggregateRepository.cs b/Src/RCommon.Dapper/Crud/DapperAggregateRepository.cs new file mode 100644 index 00000000..bdd477f6 --- /dev/null +++ b/Src/RCommon.Dapper/Crud/DapperAggregateRepository.cs @@ -0,0 +1,756 @@ +using Microsoft.Extensions.Logging; +using RCommon.Persistence.Sql; +using System; +using System.Collections.Generic; +using System.Data; +using System.Linq; +using System.Linq.Expressions; +using System.Text; +using System.Threading.Tasks; +using Dapper; +using System.Reflection; +using System.ComponentModel; +using System.Data.Common; +using RCommon.Entities; +using RCommon.Security.Claims; +using System.Threading; +using Microsoft.Extensions.Options; +using Dommel; +using RCommon.Collections; +using RCommon.Persistence.Crud; +using RCommon.Persistence.Transactions; +using static Dapper.SqlMapper; + +namespace RCommon.Persistence.Dapper.Crud +{ + /// + /// A DDD-constrained repository for aggregate roots backed by Dapper and the Dommel extension library. + /// Inherits SQL infrastructure from and exposes the narrow + /// contract for aggregate-appropriate operations only. + /// + /// The aggregate root type. Must implement . + /// The type of the aggregate's identity key. + /// + /// Each operation acquires a from the configured , + /// ensures it is open before executing, and closes it in a finally block. This repository + /// uses Dommel's extension methods (e.g., InsertAsync, DeleteAsync, SelectAsync) + /// for SQL generation from entity mappings. + /// + /// Include and ThenInclude are no-ops on this repository because Dapper does not support eager loading. + /// + public class DapperAggregateRepository : SqlRepositoryBase, IAggregateRepository + where TAggregate : class, IAggregateRoot + where TKey : IEquatable + { + /// + /// Initializes a new instance of . + /// + /// Factory used to resolve the for the configured data store. + /// Factory for creating loggers scoped to this repository type. + /// Tracker used to register entities for domain event dispatching. + /// Options specifying which data store to use when none is explicitly set. + /// Accessor for the current tenant identifier. + public DapperAggregateRepository(IDataStoreFactory dataStoreFactory, + ILoggerFactory logger, IEntityEventTracker eventTracker, + IOptions defaultDataStoreOptions, + ITenantIdAccessor tenantIdAccessor) + : base(dataStoreFactory, logger, eventTracker, defaultDataStoreOptions, tenantIdAccessor) + { + Logger = logger.CreateLogger(GetType().Name); + } + + // ────────────────────────────────────────────────────────────────────── + // SqlRepositoryBase abstract member implementations + // These delegate to the explicit IAggregateRepository implementations + // or replicate the DapperRepository pattern exactly. + // ────────────────────────────────────────────────────────────────────── + + /// + public override async Task AddAsync(TAggregate entity, CancellationToken token = default) + { + await using (var db = DataStore.GetDbConnection()) + { + try + { + if (db.State == ConnectionState.Closed) + { + await db.OpenAsync(token).ConfigureAwait(false); + } + + EventTracker.AddEntity(entity); + MultiTenantHelper.SetTenantIdIfApplicable(entity, _tenantIdAccessor.GetTenantId()); + await db.InsertAsync(entity, cancellationToken: token).ConfigureAwait(false); + } + catch (ApplicationException exception) + { + Logger.LogError(exception, "Error in {0}.AddAsync while executing on the DbConnection.", GetType().FullName); + throw; + } + finally + { + if (db.State == ConnectionState.Open) + { + await db.CloseAsync().ConfigureAwait(false); + } + } + } + } + + /// + public override async Task AddRangeAsync(IEnumerable entities, CancellationToken token = default) + { + if (entities == null) throw new ArgumentNullException(nameof(entities)); + + await using (var db = DataStore.GetDbConnection()) + { + try + { + if (db.State == ConnectionState.Closed) + { + await db.OpenAsync(token).ConfigureAwait(false); + } + + foreach (var entity in entities) + { + EventTracker.AddEntity(entity); + MultiTenantHelper.SetTenantIdIfApplicable(entity, _tenantIdAccessor.GetTenantId()); + await db.InsertAsync(entity, cancellationToken: token).ConfigureAwait(false); + } + } + catch (ApplicationException exception) + { + Logger.LogError(exception, "Error in {0}.AddRangeAsync while executing on the DbConnection.", GetType().FullName); + throw; + } + finally + { + if (db.State == ConnectionState.Open) + { + await db.CloseAsync().ConfigureAwait(false); + } + } + } + } + + /// + /// Deletes the aggregate. If implements , + /// a soft delete is performed automatically (sets IsDeleted = true and issues an UPDATE). + /// Otherwise a physical DELETE is executed. + /// + public override async Task DeleteAsync(TAggregate entity, CancellationToken token = default) + { + if (SoftDeleteHelper.IsSoftDeletable()) + { + SoftDeleteHelper.MarkAsDeleted(entity); + await UpdateAsync(entity, token).ConfigureAwait(false); + return; + } + + await using (var db = DataStore.GetDbConnection()) + { + try + { + if (db.State == ConnectionState.Closed) + { + await db.OpenAsync(token).ConfigureAwait(false); + } + + EventTracker.AddEntity(entity); + await db.DeleteAsync(entity, cancellationToken: token).ConfigureAwait(false); + } + catch (ApplicationException exception) + { + Logger.LogError(exception, "Error in {0}.DeleteAsync while executing on the DbConnection.", GetType().FullName); + throw; + } + finally + { + if (db.State == ConnectionState.Open) + { + await db.CloseAsync().ConfigureAwait(false); + } + } + } + } + + /// + /// Deletes the aggregate using the explicitly specified delete mode. When + /// is true, the aggregate must implement ; its IsDeleted property + /// is set to true and an UPDATE is issued. When false, a physical DELETE is always + /// performed — even if the aggregate implements . + /// + /// + /// Thrown when is true but + /// does not implement . + /// + public override async Task DeleteAsync(TAggregate entity, bool isSoftDelete, CancellationToken token = default) + { + if (!isSoftDelete) + { + // Bypass auto-detection — force a physical delete + await using (var db = DataStore.GetDbConnection()) + { + try + { + if (db.State == ConnectionState.Closed) + { + await db.OpenAsync(token).ConfigureAwait(false); + } + + EventTracker.AddEntity(entity); + await db.DeleteAsync(entity, cancellationToken: token).ConfigureAwait(false); + } + catch (ApplicationException exception) + { + Logger.LogError(exception, "Error in {0}.DeleteAsync while executing on the DbConnection.", GetType().FullName); + throw; + } + finally + { + if (db.State == ConnectionState.Open) + { + await db.CloseAsync().ConfigureAwait(false); + } + } + } + return; + } + + SoftDeleteHelper.EnsureSoftDeletable(); + SoftDeleteHelper.MarkAsDeleted(entity); + await UpdateAsync(entity, token).ConfigureAwait(false); + } + + /// + /// Deletes aggregates matching the expression. If implements + /// , a soft delete is performed automatically. + /// + public override async Task DeleteManyAsync(Expression> expression, CancellationToken token = default) + { + if (SoftDeleteHelper.IsSoftDeletable()) + { + return await DeleteManyAsync(expression, isSoftDelete: true, token).ConfigureAwait(false); + } + + await using (var db = DataStore.GetDbConnection()) + { + try + { + if (db.State == ConnectionState.Closed) + { + await db.OpenAsync(token).ConfigureAwait(false); + } + + return await db.DeleteMultipleAsync(expression, cancellationToken: token).ConfigureAwait(false); + } + catch (ApplicationException exception) + { + Logger.LogError(exception, "Error in {0}.DeleteManyAsync while executing on the DbConnection.", GetType().FullName); + throw; + } + finally + { + if (db.State == ConnectionState.Open) + { + await db.CloseAsync().ConfigureAwait(false); + } + } + } + } + + /// + /// Deletes aggregates matching the expression. When is true, + /// each matching aggregate must implement — its IsDeleted property is + /// set to true and an UPDATE is issued instead of a DELETE. + /// + /// + /// The soft-delete path selects matching aggregates, marks each as deleted, then updates them + /// one by one via Dommel's UpdateAsync. This is consistent with Dapper/Dommel's + /// per-entity operation model (there is no bulk update-by-expression in Dommel). + /// + /// + /// Thrown when is true but + /// does not implement . + /// + public override async Task DeleteManyAsync(Expression> expression, bool isSoftDelete, CancellationToken token = default) + { + if (!isSoftDelete) + { + // Bypass auto-detection — force a physical delete + await using (var db = DataStore.GetDbConnection()) + { + try + { + if (db.State == ConnectionState.Closed) + { + await db.OpenAsync(token).ConfigureAwait(false); + } + + return await db.DeleteMultipleAsync(expression, cancellationToken: token).ConfigureAwait(false); + } + catch (ApplicationException exception) + { + Logger.LogError(exception, "Error in {0}.DeleteManyAsync while executing on the DbConnection.", GetType().FullName); + throw; + } + finally + { + if (db.State == ConnectionState.Open) + { + await db.CloseAsync().ConfigureAwait(false); + } + } + } + } + + SoftDeleteHelper.EnsureSoftDeletable(); + + await using (var db = DataStore.GetDbConnection()) + { + try + { + if (db.State == ConnectionState.Closed) + { + await db.OpenAsync(token).ConfigureAwait(false); + } + + var entities = (await db.SelectAsync(expression, cancellationToken: token).ConfigureAwait(false)).ToList(); + int count = 0; + foreach (var entity in entities) + { + SoftDeleteHelper.MarkAsDeleted(entity); + await db.UpdateAsync(entity, cancellationToken: token).ConfigureAwait(false); + count++; + } + return count; + } + catch (ApplicationException exception) + { + Logger.LogError(exception, "Error in {0}.DeleteManyAsync (soft delete) while executing on the DbConnection.", GetType().FullName); + throw; + } + finally + { + if (db.State == ConnectionState.Open) + { + await db.CloseAsync().ConfigureAwait(false); + } + } + } + } + + /// + /// Deletes aggregates matching the specification. If implements + /// , a soft delete is performed automatically. + /// + public override async Task DeleteManyAsync(ISpecification specification, CancellationToken token = default) + { + return await DeleteManyAsync(specification.Predicate, token).ConfigureAwait(false); + } + + /// + /// Deletes aggregates matching the specification. When is true, + /// each matching aggregate must implement — its IsDeleted property is + /// set to true and an UPDATE is issued instead of a DELETE. + /// + /// + /// Thrown when is true but + /// does not implement . + /// + public override async Task DeleteManyAsync(ISpecification specification, bool isSoftDelete, CancellationToken token = default) + { + return await DeleteManyAsync(specification.Predicate, isSoftDelete, token).ConfigureAwait(false); + } + + /// + public override async Task UpdateAsync(TAggregate entity, CancellationToken token = default) + { + await using (var db = DataStore.GetDbConnection()) + { + try + { + if (db.State == ConnectionState.Closed) + { + await db.OpenAsync(token).ConfigureAwait(false); + } + + EventTracker.AddEntity(entity); + await db.UpdateAsync(entity, cancellationToken: token).ConfigureAwait(false); + } + catch (ApplicationException exception) + { + Logger.LogError(exception, "Error in {0}.UpdateAsync while executing on the DbConnection.", GetType().FullName); + throw; + } + finally + { + if (db.State == ConnectionState.Open) + { + await db.CloseAsync().ConfigureAwait(false); + } + } + } + } + + /// + public override async Task> FindAsync(ISpecification specification, CancellationToken token = default) + { + return await FindAsync(specification.Predicate, token).ConfigureAwait(false); + } + + /// + public override async Task> FindAsync(Expression> expression, CancellationToken token = default) + { + await using (var db = DataStore.GetDbConnection()) + { + try + { + if (db.State == ConnectionState.Closed) + { + await db.OpenAsync(token).ConfigureAwait(false); + } + + var filteredExpression = SoftDeleteHelper.CombineWithNotDeletedFilter(expression); + filteredExpression = MultiTenantHelper.CombineWithTenantFilter(filteredExpression, _tenantIdAccessor.GetTenantId()); + var results = await db.SelectAsync(filteredExpression, cancellationToken: token).ConfigureAwait(false); + return results.ToList(); + } + catch (ApplicationException exception) + { + Logger.LogError(exception, "Error in {0}.FindAsync while executing on the DbConnection.", GetType().FullName); + throw; + } + finally + { + if (db.State == ConnectionState.Open) + { + await db.CloseAsync().ConfigureAwait(false); + } + } + } + } + + /// + public override async Task FindAsync(object primaryKey, CancellationToken token = default) + { + await using (var db = DataStore.GetDbConnection()) + { + try + { + if (db.State == ConnectionState.Closed) + { + await db.OpenAsync(token).ConfigureAwait(false); + } + + var result = await db.GetAsync(primaryKey, cancellationToken: token).ConfigureAwait(false); + + // Post-fetch soft-delete check: if the entity was soft-deleted, treat it as not found + if (result != null && SoftDeleteHelper.IsSoftDeletable() && ((ISoftDelete)result).IsDeleted) + { + return default!; + } + + // Post-fetch tenant check: if the entity belongs to a different tenant, treat it as not found + var currentTenantId = _tenantIdAccessor.GetTenantId(); + if (result != null && MultiTenantHelper.IsMultiTenant() + && !string.IsNullOrEmpty(currentTenantId) + && ((IMultiTenant)result).TenantId != currentTenantId) + { + return default!; + } + + return result!; + } + catch (ApplicationException exception) + { + Logger.LogError(exception, "Error in {0}.FindAsync while executing on the DbConnection.", GetType().FullName); + throw; + } + finally + { + if (db.State == ConnectionState.Open) + { + await db.CloseAsync().ConfigureAwait(false); + } + } + } + } + + /// + public override async Task GetCountAsync(ISpecification selectSpec, CancellationToken token = default) + { + await using (var db = DataStore.GetDbConnection()) + { + try + { + if (db.State == ConnectionState.Closed) + { + await db.OpenAsync(token).ConfigureAwait(false); + } + + var filteredPredicate = SoftDeleteHelper.CombineWithNotDeletedFilter(selectSpec.Predicate); + filteredPredicate = MultiTenantHelper.CombineWithTenantFilter(filteredPredicate, _tenantIdAccessor.GetTenantId()); + var results = await db.CountAsync(filteredPredicate).ConfigureAwait(false); + return results; + } + catch (ApplicationException exception) + { + Logger.LogError(exception, "Error in {0}.GetCountAsync while executing on the DbConnection.", GetType().FullName); + throw; + } + finally + { + if (db.State == ConnectionState.Open) + { + await db.CloseAsync().ConfigureAwait(false); + } + } + } + } + + /// + public override async Task GetCountAsync(Expression> expression, CancellationToken token = default) + { + await using (var db = DataStore.GetDbConnection()) + { + try + { + if (db.State == ConnectionState.Closed) + { + await db.OpenAsync(token).ConfigureAwait(false); + } + + var filteredExpression = SoftDeleteHelper.CombineWithNotDeletedFilter(expression); + filteredExpression = MultiTenantHelper.CombineWithTenantFilter(filteredExpression, _tenantIdAccessor.GetTenantId()); + var results = await db.CountAsync(filteredExpression).ConfigureAwait(false); + return results; + } + catch (ApplicationException exception) + { + Logger.LogError(exception, "Error in {0}.GetCountAsync while executing on the DbConnection.", GetType().FullName); + throw; + } + finally + { + if (db.State == ConnectionState.Open) + { + await db.CloseAsync().ConfigureAwait(false); + } + } + } + } + + /// + public override async Task FindSingleOrDefaultAsync(Expression> expression, CancellationToken token = default) + { + // Dommel lacks a native SingleOrDefault, so we retrieve all matches and apply SingleOrDefault in-memory + var result = await FindAsync(expression, token).ConfigureAwait(false); + return result.SingleOrDefault()!; + } + + /// + public override async Task FindSingleOrDefaultAsync(ISpecification specification, CancellationToken token = default) + { + return await FindSingleOrDefaultAsync(specification.Predicate, token).ConfigureAwait(false); + } + + /// + public override async Task AnyAsync(Expression> expression, CancellationToken token = default) + { + await using (var db = DataStore.GetDbConnection()) + { + try + { + if (db.State == ConnectionState.Closed) + { + await db.OpenAsync(token).ConfigureAwait(false); + } + + var filteredExpression = SoftDeleteHelper.CombineWithNotDeletedFilter(expression); + filteredExpression = MultiTenantHelper.CombineWithTenantFilter(filteredExpression, _tenantIdAccessor.GetTenantId()); + var results = await db.AnyAsync(filteredExpression).ConfigureAwait(false); + return results; + } + catch (ApplicationException exception) + { + Logger.LogError(exception, "Error in {0}.AnyAsync while executing on the DbConnection.", GetType().FullName); + throw; + } + finally + { + if (db.State == ConnectionState.Open) + { + await db.CloseAsync().ConfigureAwait(false); + } + } + } + } + + /// + public override async Task AnyAsync(ISpecification specification, CancellationToken token = default) + { + return await AnyAsync(specification.Predicate, token).ConfigureAwait(false); + } + + // ────────────────────────────────────────────────────────────────────── + // Explicit IAggregateRepository implementations + // ────────────────────────────────────────────────────────────────────── + + /// + /// Loads an aggregate root by its identity key. + /// + async Task IAggregateRepository.GetByIdAsync(TKey id, CancellationToken cancellationToken) + { + await using (var db = DataStore.GetDbConnection()) + { + try + { + if (db.State == ConnectionState.Closed) + { + await db.OpenAsync(cancellationToken).ConfigureAwait(false); + } + + var result = await db.GetAsync(id, cancellationToken: cancellationToken).ConfigureAwait(false); + + // Post-fetch soft-delete check: if the entity was soft-deleted, treat it as not found + if (result != null && SoftDeleteHelper.IsSoftDeletable() && ((ISoftDelete)result).IsDeleted) + { + return null; + } + + // Post-fetch tenant check: if the entity belongs to a different tenant, treat it as not found + var currentTenantId = _tenantIdAccessor.GetTenantId(); + if (result != null && MultiTenantHelper.IsMultiTenant() + && !string.IsNullOrEmpty(currentTenantId) + && ((IMultiTenant)result).TenantId != currentTenantId) + { + return null; + } + + return result; + } + catch (ApplicationException exception) + { + Logger.LogError(exception, "Error in {0}.GetByIdAsync while executing on the DbConnection.", GetType().FullName); + throw; + } + finally + { + if (db.State == ConnectionState.Open) + { + await db.CloseAsync().ConfigureAwait(false); + } + } + } + } + + /// + /// Finds a single aggregate matching the given specification. + /// + async Task IAggregateRepository.FindAsync(ISpecification specification, CancellationToken cancellationToken) + { + await using (var db = DataStore.GetDbConnection()) + { + try + { + if (db.State == ConnectionState.Closed) + { + await db.OpenAsync(cancellationToken).ConfigureAwait(false); + } + + var filteredExpression = SoftDeleteHelper.CombineWithNotDeletedFilter(specification.Predicate); + filteredExpression = MultiTenantHelper.CombineWithTenantFilter(filteredExpression, _tenantIdAccessor.GetTenantId()); + var results = await db.SelectAsync(filteredExpression, cancellationToken: cancellationToken).ConfigureAwait(false); + return results.FirstOrDefault(); + } + catch (ApplicationException exception) + { + Logger.LogError(exception, "Error in {0}.FindAsync while executing on the DbConnection.", GetType().FullName); + throw; + } + finally + { + if (db.State == ConnectionState.Open) + { + await db.CloseAsync().ConfigureAwait(false); + } + } + } + } + + /// + /// Checks whether an aggregate with the given identity key exists. + /// + async Task IAggregateRepository.ExistsAsync(TKey id, CancellationToken cancellationToken) + { + await using (var db = DataStore.GetDbConnection()) + { + try + { + if (db.State == ConnectionState.Closed) + { + await db.OpenAsync(cancellationToken).ConfigureAwait(false); + } + + var result = await db.GetAsync(id, cancellationToken: cancellationToken).ConfigureAwait(false); + return result != null; + } + catch (ApplicationException exception) + { + Logger.LogError(exception, "Error in {0}.ExistsAsync while executing on the DbConnection.", GetType().FullName); + throw; + } + finally + { + if (db.State == ConnectionState.Open) + { + await db.CloseAsync().ConfigureAwait(false); + } + } + } + } + + /// + /// Adds a new aggregate root to the repository and persists it. + /// + async Task IAggregateRepository.AddAsync(TAggregate aggregate, CancellationToken cancellationToken) + { + await AddAsync(aggregate, cancellationToken).ConfigureAwait(false); + } + + /// + /// Updates an existing aggregate root and persists the changes. + /// + async Task IAggregateRepository.UpdateAsync(TAggregate aggregate, CancellationToken cancellationToken) + { + await UpdateAsync(aggregate, cancellationToken).ConfigureAwait(false); + } + + /// + /// Deletes an aggregate root. If the aggregate implements , + /// a soft delete is performed automatically. + /// + async Task IAggregateRepository.DeleteAsync(TAggregate aggregate, CancellationToken cancellationToken) + { + await DeleteAsync(aggregate, cancellationToken).ConfigureAwait(false); + } + + /// + /// No-op: Dapper does not support eager loading. Returns this repository for fluent chaining. + /// + IAggregateRepository IAggregateRepository.Include(Expression> path) + { + // Dapper has no eager loading support — this is intentionally a no-op. + return this; + } + + /// + /// No-op: Dapper does not support eager loading. Returns this repository for fluent chaining. + /// + IAggregateRepository IAggregateRepository.ThenInclude(Expression> path) + { + // Dapper has no eager loading support — this is intentionally a no-op. + return this; + } + } +} diff --git a/Src/RCommon.Dapper/Crud/DapperReadModelRepository.cs b/Src/RCommon.Dapper/Crud/DapperReadModelRepository.cs new file mode 100644 index 00000000..2d30b53f --- /dev/null +++ b/Src/RCommon.Dapper/Crud/DapperReadModelRepository.cs @@ -0,0 +1,272 @@ +using System; +using System.Collections.Generic; +using System.Data; +using System.Linq; +using System.Linq.Expressions; +using System.Threading; +using System.Threading.Tasks; +using Dommel; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using RCommon.Models; +using RCommon.Persistence; +using RCommon.Persistence.Crud; +using RCommon.Persistence.Sql; + +namespace RCommon.Persistence.Dapper.Crud; + +/// +/// A read-model repository implementation using Dapper and the Dommel extension library for query operations. +/// +/// +/// The read-model/projection type. Must implement and be a class. +/// +/// +/// Each operation acquires a from the configured +/// , ensures it is open before executing, and closes it in a +/// finally block. This repository uses Dommel's extension methods for SQL generation. +/// +/// Read models do not participate in domain event tracking or soft-delete filtering. +/// is a no-op because Dapper does not support eager loading. +/// +public class DapperReadModelRepository : IReadModelRepository + where TReadModel : class, IReadModel +{ + private readonly IDataStoreFactory _dataStoreFactory; + private readonly ILogger _logger; + private string _dataStoreName; + + /// + /// Initializes a new instance of . + /// + /// Factory used to resolve the for the configured data store. + /// Factory for creating loggers scoped to this repository type. + /// Options specifying which data store to use when none is explicitly set. + /// Thrown when any required parameter is null. + public DapperReadModelRepository( + IDataStoreFactory dataStoreFactory, + ILoggerFactory loggerFactory, + IOptions defaultDataStoreOptions) + { + _dataStoreFactory = dataStoreFactory ?? throw new ArgumentNullException(nameof(dataStoreFactory)); + _logger = loggerFactory?.CreateLogger(GetType().Name) ?? throw new ArgumentNullException(nameof(loggerFactory)); + + if (defaultDataStoreOptions is null) + throw new ArgumentNullException(nameof(defaultDataStoreOptions)); + + _dataStoreName = defaultDataStoreOptions.Value?.DefaultDataStoreName ?? string.Empty; + } + + /// + /// Gets the resolved for this repository using the current . + /// + private RDbConnection DataStore + => _dataStoreFactory.Resolve(_dataStoreName); + + /// + public string DataStoreName + { + get => _dataStoreName; + set => _dataStoreName = value; + } + + /// + public async Task FindAsync( + ISpecification specification, + CancellationToken cancellationToken = default) + { + await using (var db = DataStore.GetDbConnection()) + { + try + { + if (db.State == ConnectionState.Closed) + { + await db.OpenAsync(cancellationToken).ConfigureAwait(false); + } + + var results = await db.SelectAsync( + specification.Predicate, + cancellationToken: cancellationToken).ConfigureAwait(false); + + return results.FirstOrDefault(); + } + catch (ApplicationException exception) + { + _logger.LogError(exception, + "Error in {RepositoryType}.FindAsync while executing on the DbConnection.", + GetType().FullName); + throw; + } + finally + { + if (db.State == ConnectionState.Open) + { + await db.CloseAsync().ConfigureAwait(false); + } + } + } + } + + /// + public async Task> FindAllAsync( + ISpecification specification, + CancellationToken cancellationToken = default) + { + await using (var db = DataStore.GetDbConnection()) + { + try + { + if (db.State == ConnectionState.Closed) + { + await db.OpenAsync(cancellationToken).ConfigureAwait(false); + } + + var results = await db.SelectAsync( + specification.Predicate, + cancellationToken: cancellationToken).ConfigureAwait(false); + + return results.ToList(); + } + catch (ApplicationException exception) + { + _logger.LogError(exception, + "Error in {RepositoryType}.FindAllAsync while executing on the DbConnection.", + GetType().FullName); + throw; + } + finally + { + if (db.State == ConnectionState.Open) + { + await db.CloseAsync().ConfigureAwait(false); + } + } + } + } + + /// + public async Task> GetPagedAsync( + IPagedSpecification specification, + CancellationToken cancellationToken = default) + { + await using (var db = DataStore.GetDbConnection()) + { + try + { + if (db.State == ConnectionState.Closed) + { + await db.OpenAsync(cancellationToken).ConfigureAwait(false); + } + + // Dommel does not support server-side paging with Skip/Take, so we fetch + // the full filtered result set and apply paging in-memory. + var allResults = (await db.SelectAsync( + specification.Predicate, + cancellationToken: cancellationToken).ConfigureAwait(false)).ToList(); + + var totalCount = (long)allResults.Count; + + var items = allResults + .Skip((specification.PageNumber - 1) * specification.PageSize) + .Take(specification.PageSize) + .ToList(); + + return new PagedResult(items, totalCount, specification.PageNumber, specification.PageSize); + } + catch (ApplicationException exception) + { + _logger.LogError(exception, + "Error in {RepositoryType}.GetPagedAsync while executing on the DbConnection.", + GetType().FullName); + throw; + } + finally + { + if (db.State == ConnectionState.Open) + { + await db.CloseAsync().ConfigureAwait(false); + } + } + } + } + + /// + public async Task GetCountAsync( + ISpecification specification, + CancellationToken cancellationToken = default) + { + await using (var db = DataStore.GetDbConnection()) + { + try + { + if (db.State == ConnectionState.Closed) + { + await db.OpenAsync(cancellationToken).ConfigureAwait(false); + } + + var count = await db.CountAsync(specification.Predicate).ConfigureAwait(false); + return count; + } + catch (ApplicationException exception) + { + _logger.LogError(exception, + "Error in {RepositoryType}.GetCountAsync while executing on the DbConnection.", + GetType().FullName); + throw; + } + finally + { + if (db.State == ConnectionState.Open) + { + await db.CloseAsync().ConfigureAwait(false); + } + } + } + } + + /// + public async Task AnyAsync( + ISpecification specification, + CancellationToken cancellationToken = default) + { + await using (var db = DataStore.GetDbConnection()) + { + try + { + if (db.State == ConnectionState.Closed) + { + await db.OpenAsync(cancellationToken).ConfigureAwait(false); + } + + var result = await db.AnyAsync(specification.Predicate).ConfigureAwait(false); + return result; + } + catch (ApplicationException exception) + { + _logger.LogError(exception, + "Error in {RepositoryType}.AnyAsync while executing on the DbConnection.", + GetType().FullName); + throw; + } + finally + { + if (db.State == ConnectionState.Open) + { + await db.CloseAsync().ConfigureAwait(false); + } + } + } + } + + /// + /// No-op: Dapper does not support eager loading. Returns this repository for fluent chaining. + /// + /// The navigation property expression (ignored). + /// This repository instance. + public IReadModelRepository Include( + Expression> path) + { + // Dapper has no eager loading support — this is intentionally a no-op. + return this; + } +} diff --git a/Src/RCommon.Dapper/Crud/DapperRepository.cs b/Src/RCommon.Dapper/Crud/DapperRepository.cs index 5294260d..540035e6 100644 --- a/Src/RCommon.Dapper/Crud/DapperRepository.cs +++ b/Src/RCommon.Dapper/Crud/DapperRepository.cs @@ -63,11 +63,11 @@ public override async Task AddAsync(TEntity entity, CancellationToken token = de { if (db.State == ConnectionState.Closed) { - await db.OpenAsync(); + await db.OpenAsync(token).ConfigureAwait(false); } EventTracker.AddEntity(entity); MultiTenantHelper.SetTenantIdIfApplicable(entity, _tenantIdAccessor.GetTenantId()); - await db.InsertAsync(entity, cancellationToken: token); + await db.InsertAsync(entity, cancellationToken: token).ConfigureAwait(false); } catch (ApplicationException exception) @@ -79,7 +79,7 @@ public override async Task AddAsync(TEntity entity, CancellationToken token = de { if (db.State == ConnectionState.Open) { - await db.CloseAsync(); + await db.CloseAsync().ConfigureAwait(false); } } @@ -97,7 +97,7 @@ public override async Task DeleteAsync(TEntity entity, CancellationToken token = if (SoftDeleteHelper.IsSoftDeletable()) { SoftDeleteHelper.MarkAsDeleted(entity); - await UpdateAsync(entity, token); + await UpdateAsync(entity, token).ConfigureAwait(false); return; } @@ -107,11 +107,11 @@ public override async Task DeleteAsync(TEntity entity, CancellationToken token = { if (db.State == ConnectionState.Closed) { - await db.OpenAsync(); + await db.OpenAsync(token).ConfigureAwait(false); } EventTracker.AddEntity(entity); - await db.DeleteAsync(entity, cancellationToken: token); + await db.DeleteAsync(entity, cancellationToken: token).ConfigureAwait(false); } catch (ApplicationException exception) { @@ -122,7 +122,7 @@ public override async Task DeleteAsync(TEntity entity, CancellationToken token = { if (db.State == ConnectionState.Open) { - await db.CloseAsync(); + await db.CloseAsync().ConfigureAwait(false); } } @@ -138,7 +138,7 @@ public async override Task DeleteManyAsync(Expression> { if (SoftDeleteHelper.IsSoftDeletable()) { - return await DeleteManyAsync(expression, isSoftDelete: true, token); + return await DeleteManyAsync(expression, isSoftDelete: true, token).ConfigureAwait(false); } await using (var db = DataStore.GetDbConnection()) @@ -147,10 +147,10 @@ public async override Task DeleteManyAsync(Expression> { if (db.State == ConnectionState.Closed) { - await db.OpenAsync(); + await db.OpenAsync(token).ConfigureAwait(false); } - return await db.DeleteMultipleAsync(expression, cancellationToken: token); + return await db.DeleteMultipleAsync(expression, cancellationToken: token).ConfigureAwait(false); } catch (ApplicationException exception) { @@ -161,7 +161,7 @@ public async override Task DeleteManyAsync(Expression> { if (db.State == ConnectionState.Open) { - await db.CloseAsync(); + await db.CloseAsync().ConfigureAwait(false); } } @@ -174,7 +174,7 @@ public async override Task DeleteManyAsync(Expression> /// public async override Task DeleteManyAsync(ISpecification specification, CancellationToken token = default) { - return await DeleteManyAsync(specification.Predicate, token); + return await DeleteManyAsync(specification.Predicate, token).ConfigureAwait(false); } /// @@ -198,11 +198,11 @@ public override async Task DeleteAsync(TEntity entity, bool isSoftDelete, Cancel { if (db.State == ConnectionState.Closed) { - await db.OpenAsync(); + await db.OpenAsync(token).ConfigureAwait(false); } EventTracker.AddEntity(entity); - await db.DeleteAsync(entity, cancellationToken: token); + await db.DeleteAsync(entity, cancellationToken: token).ConfigureAwait(false); } catch (ApplicationException exception) { @@ -213,7 +213,7 @@ public override async Task DeleteAsync(TEntity entity, bool isSoftDelete, Cancel { if (db.State == ConnectionState.Open) { - await db.CloseAsync(); + await db.CloseAsync().ConfigureAwait(false); } } } @@ -222,7 +222,7 @@ public override async Task DeleteAsync(TEntity entity, bool isSoftDelete, Cancel SoftDeleteHelper.EnsureSoftDeletable(); SoftDeleteHelper.MarkAsDeleted(entity); - await UpdateAsync(entity, token); + await UpdateAsync(entity, token).ConfigureAwait(false); } /// @@ -250,10 +250,10 @@ public async override Task DeleteManyAsync(Expression> { if (db.State == ConnectionState.Closed) { - await db.OpenAsync(); + await db.OpenAsync(token).ConfigureAwait(false); } - return await db.DeleteMultipleAsync(expression, cancellationToken: token); + return await db.DeleteMultipleAsync(expression, cancellationToken: token).ConfigureAwait(false); } catch (ApplicationException exception) { @@ -264,7 +264,7 @@ public async override Task DeleteManyAsync(Expression> { if (db.State == ConnectionState.Open) { - await db.CloseAsync(); + await db.CloseAsync().ConfigureAwait(false); } } } @@ -278,15 +278,15 @@ public async override Task DeleteManyAsync(Expression> { if (db.State == ConnectionState.Closed) { - await db.OpenAsync(); + await db.OpenAsync(token).ConfigureAwait(false); } - var entities = (await db.SelectAsync(expression, cancellationToken: token)).ToList(); + var entities = (await db.SelectAsync(expression, cancellationToken: token).ConfigureAwait(false)).ToList(); int count = 0; foreach (var entity in entities) { SoftDeleteHelper.MarkAsDeleted(entity); - await db.UpdateAsync(entity, cancellationToken: token); + await db.UpdateAsync(entity, cancellationToken: token).ConfigureAwait(false); count++; } return count; @@ -300,7 +300,7 @@ public async override Task DeleteManyAsync(Expression> { if (db.State == ConnectionState.Open) { - await db.CloseAsync(); + await db.CloseAsync().ConfigureAwait(false); } } } @@ -317,7 +317,7 @@ public async override Task DeleteManyAsync(Expression> /// public async override Task DeleteManyAsync(ISpecification specification, bool isSoftDelete, CancellationToken token = default) { - return await DeleteManyAsync(specification.Predicate, isSoftDelete, token); + return await DeleteManyAsync(specification.Predicate, isSoftDelete, token).ConfigureAwait(false); } @@ -331,11 +331,11 @@ public override async Task UpdateAsync(TEntity entity, CancellationToken token = { if (db.State == ConnectionState.Closed) { - await db.OpenAsync(); + await db.OpenAsync(token).ConfigureAwait(false); } EventTracker.AddEntity(entity); - await db.UpdateAsync(entity, cancellationToken: token); + await db.UpdateAsync(entity, cancellationToken: token).ConfigureAwait(false); } catch (ApplicationException exception) { @@ -346,7 +346,7 @@ public override async Task UpdateAsync(TEntity entity, CancellationToken token = { if (db.State == ConnectionState.Open) { - await db.CloseAsync(); + await db.CloseAsync().ConfigureAwait(false); } } } @@ -355,7 +355,7 @@ public override async Task UpdateAsync(TEntity entity, CancellationToken token = /// public override async Task> FindAsync(ISpecification specification, CancellationToken token = default) { - return await FindAsync(specification.Predicate, token); + return await FindAsync(specification.Predicate, token).ConfigureAwait(false); } /// @@ -367,12 +367,12 @@ public override async Task> FindAsync(Expression(expression); filteredExpression = MultiTenantHelper.CombineWithTenantFilter(filteredExpression, _tenantIdAccessor.GetTenantId()); - var results = await db.SelectAsync(filteredExpression, cancellationToken: token); + var results = await db.SelectAsync(filteredExpression, cancellationToken: token).ConfigureAwait(false); return results.ToList(); } catch (ApplicationException exception) @@ -384,7 +384,7 @@ public override async Task> FindAsync(Expression FindAsync(object primaryKey, CancellationTok { if (db.State == ConnectionState.Closed) { - await db.OpenAsync(); + await db.OpenAsync(token).ConfigureAwait(false); } - var result = await db.GetAsync(primaryKey, cancellationToken: token); + var result = await db.GetAsync(primaryKey, cancellationToken: token).ConfigureAwait(false); // Post-fetch soft-delete check: if the entity was soft-deleted, treat it as not found if (result != null && SoftDeleteHelper.IsSoftDeletable() && ((ISoftDelete)result).IsDeleted) @@ -430,7 +430,7 @@ public override async Task FindAsync(object primaryKey, CancellationTok { if (db.State == ConnectionState.Open) { - await db.CloseAsync(); + await db.CloseAsync().ConfigureAwait(false); } } } @@ -445,12 +445,12 @@ public override async Task GetCountAsync(ISpecification selectSpe { if (db.State == ConnectionState.Closed) { - await db.OpenAsync(); + await db.OpenAsync(token).ConfigureAwait(false); } var filteredPredicate = SoftDeleteHelper.CombineWithNotDeletedFilter(selectSpec.Predicate); filteredPredicate = MultiTenantHelper.CombineWithTenantFilter(filteredPredicate, _tenantIdAccessor.GetTenantId()); - var results = await db.CountAsync(filteredPredicate); + var results = await db.CountAsync(filteredPredicate).ConfigureAwait(false); return results; } catch (ApplicationException exception) @@ -462,7 +462,7 @@ public override async Task GetCountAsync(ISpecification selectSpe { if (db.State == ConnectionState.Open) { - await db.CloseAsync(); + await db.CloseAsync().ConfigureAwait(false); } } } @@ -477,12 +477,12 @@ public override async Task GetCountAsync(Expression> e { if (db.State == ConnectionState.Closed) { - await db.OpenAsync(); + await db.OpenAsync(token).ConfigureAwait(false); } var filteredExpression = SoftDeleteHelper.CombineWithNotDeletedFilter(expression); filteredExpression = MultiTenantHelper.CombineWithTenantFilter(filteredExpression, _tenantIdAccessor.GetTenantId()); - var results = await db.CountAsync(filteredExpression); + var results = await db.CountAsync(filteredExpression).ConfigureAwait(false); return results; } catch (ApplicationException exception) @@ -494,7 +494,7 @@ public override async Task GetCountAsync(Expression> e { if (db.State == ConnectionState.Open) { - await db.CloseAsync(); + await db.CloseAsync().ConfigureAwait(false); } } } @@ -511,7 +511,7 @@ public override async Task GetCountAsync(Expression> e public override async Task FindSingleOrDefaultAsync(Expression> expression, CancellationToken token = default) { // Dommel lacks a native SingleOrDefault, so we retrieve all matches and apply SingleOrDefault in-memory - var result = await FindAsync(expression, token); + var result = await FindAsync(expression, token).ConfigureAwait(false); return result.SingleOrDefault()!; } @@ -525,7 +525,7 @@ public override async Task FindSingleOrDefaultAsync(Expression public override async Task FindSingleOrDefaultAsync(ISpecification specification, CancellationToken token = default) { - return await FindSingleOrDefaultAsync(specification, token); + return await FindSingleOrDefaultAsync(specification.Predicate, token).ConfigureAwait(false); } /// @@ -537,12 +537,12 @@ public override async Task AnyAsync(Expression> expres { if (db.State == ConnectionState.Closed) { - await db.OpenAsync(); + await db.OpenAsync(token).ConfigureAwait(false); } var filteredExpression = SoftDeleteHelper.CombineWithNotDeletedFilter(expression); filteredExpression = MultiTenantHelper.CombineWithTenantFilter(filteredExpression, _tenantIdAccessor.GetTenantId()); - var results = await db.AnyAsync(filteredExpression); + var results = await db.AnyAsync(filteredExpression).ConfigureAwait(false); return results; } catch (ApplicationException exception) @@ -554,7 +554,7 @@ public override async Task AnyAsync(Expression> expres { if (db.State == ConnectionState.Open) { - await db.CloseAsync(); + await db.CloseAsync().ConfigureAwait(false); } } } @@ -563,7 +563,7 @@ public override async Task AnyAsync(Expression> expres /// public override async Task AnyAsync(ISpecification specification, CancellationToken token = default) { - return await AnyAsync(specification.Predicate, token); + return await AnyAsync(specification.Predicate, token).ConfigureAwait(false); } /// @@ -581,14 +581,14 @@ public override async Task AddRangeAsync(IEnumerable entities, Cancella { if (db.State == ConnectionState.Closed) { - await db.OpenAsync(); + await db.OpenAsync(token).ConfigureAwait(false); } foreach (var entity in entities) { EventTracker.AddEntity(entity); MultiTenantHelper.SetTenantIdIfApplicable(entity, _tenantIdAccessor.GetTenantId()); - await db.InsertAsync(entity, cancellationToken: token); + await db.InsertAsync(entity, cancellationToken: token).ConfigureAwait(false); } } catch (ApplicationException exception) @@ -600,7 +600,7 @@ public override async Task AddRangeAsync(IEnumerable entities, Cancella { if (db.State == ConnectionState.Open) { - await db.CloseAsync(); + await db.CloseAsync().ConfigureAwait(false); } } } diff --git a/Src/RCommon.Dapper/DapperPersistenceBuilder.cs b/Src/RCommon.Dapper/DapperPersistenceBuilder.cs index ea96fd5e..3ee26bd1 100644 --- a/Src/RCommon.Dapper/DapperPersistenceBuilder.cs +++ b/Src/RCommon.Dapper/DapperPersistenceBuilder.cs @@ -8,9 +8,12 @@ using RCommon.Persistence; using Microsoft.Extensions.DependencyInjection; using RCommon.Persistence.Dapper.Crud; +using RCommon.Persistence.Dapper.Sagas; using RCommon.Persistence.Crud; +using RCommon.Persistence.Sagas; using RCommon.Security.Claims; using Microsoft.Extensions.DependencyInjection.Extensions; +using RCommon.Persistence.Outbox; namespace RCommon { @@ -46,6 +49,9 @@ public DapperPersistenceBuilder(IServiceCollection services) services.AddTransient(typeof(ISqlMapperRepository<>), typeof(DapperRepository<>)); services.AddTransient(typeof(IWriteOnlyRepository<>), typeof(DapperRepository<>)); services.AddTransient(typeof(IReadOnlyRepository<>), typeof(DapperRepository<>)); + services.AddTransient(typeof(IAggregateRepository<,>), typeof(DapperAggregateRepository<,>)); + services.AddTransient(typeof(IReadModelRepository<>), typeof(DapperReadModelRepository<>)); + services.AddScoped(typeof(ISagaStore<,>), typeof(DapperSagaStore<,>)); } @@ -88,5 +94,17 @@ public IPersistenceBuilder SetDefaultDataStore(Action o this._services.Configure(options); return this; } + + /// + /// Registers the lock statement provider used for outbox claiming operations. + /// + /// The lock statement provider type. Must implement . + /// The builder instance for fluent chaining. + public IDapperBuilder UseLockStatementProvider() + where TProvider : class, ILockStatementProvider + { + this._services.AddSingleton(); + return this; + } } } diff --git a/Src/RCommon.Dapper/Inbox/DapperInboxStore.cs b/Src/RCommon.Dapper/Inbox/DapperInboxStore.cs new file mode 100644 index 00000000..d8040dc9 --- /dev/null +++ b/Src/RCommon.Dapper/Inbox/DapperInboxStore.cs @@ -0,0 +1,69 @@ +using Dapper; +using Microsoft.Extensions.Options; +using RCommon.Persistence.Inbox; +using RCommon.Persistence.Outbox; +using RCommon.Persistence.Sql; +using System; +using System.Data; +using System.Data.Common; +using System.Threading; +using System.Threading.Tasks; + +namespace RCommon.Persistence.Dapper.Inbox; + +public class DapperInboxStore : IInboxStore +{ + private readonly IDataStoreFactory _dataStoreFactory; + private readonly string _dataStoreName; + private readonly string _tableName; + + public DapperInboxStore( + IDataStoreFactory dataStoreFactory, + IOptions defaultDataStoreOptions, + IOptions outboxOptions) + { + _dataStoreFactory = dataStoreFactory ?? throw new ArgumentNullException(nameof(dataStoreFactory)); + _dataStoreName = defaultDataStoreOptions?.Value?.DefaultDataStoreName + ?? throw new ArgumentNullException(nameof(defaultDataStoreOptions)); + _tableName = outboxOptions?.Value?.InboxTableName ?? "__InboxMessages"; + } + + private async Task GetOpenConnectionAsync(CancellationToken cancellationToken) + { + var dataStore = _dataStoreFactory.Resolve(_dataStoreName); + var connection = dataStore.GetDbConnection(); + if (connection.State == ConnectionState.Closed) + { + await connection.OpenAsync(cancellationToken).ConfigureAwait(false); + } + return connection; + } + + public async Task ExistsAsync(Guid messageId, string? consumerType = null, CancellationToken cancellationToken = default) + { + await using var db = await GetOpenConnectionAsync(cancellationToken).ConfigureAwait(false); + var ct = consumerType ?? ""; + var sql = $"SELECT CASE WHEN EXISTS (SELECT 1 FROM [{_tableName}] WHERE MessageId = @MessageId AND ConsumerType = @ConsumerType) THEN 1 ELSE 0 END"; + return await db.ExecuteScalarAsync( + new CommandDefinition(sql, new { MessageId = messageId, ConsumerType = ct }, + cancellationToken: cancellationToken)).ConfigureAwait(false); + } + + public async Task RecordAsync(IInboxMessage message, CancellationToken cancellationToken = default) + { + await using var db = await GetOpenConnectionAsync(cancellationToken).ConfigureAwait(false); + var sql = $"INSERT INTO [{_tableName}] (MessageId, EventType, ConsumerType, ReceivedAtUtc) VALUES (@MessageId, @EventType, @ConsumerType, @ReceivedAtUtc)"; + await db.ExecuteAsync(new CommandDefinition(sql, + new { message.MessageId, message.EventType, ConsumerType = message.ConsumerType ?? "", message.ReceivedAtUtc }, + cancellationToken: cancellationToken)).ConfigureAwait(false); + } + + public async Task CleanupAsync(TimeSpan olderThan, CancellationToken cancellationToken = default) + { + await using var db = await GetOpenConnectionAsync(cancellationToken).ConfigureAwait(false); + var cutoff = DateTimeOffset.UtcNow - olderThan; + var sql = $"DELETE FROM [{_tableName}] WHERE ReceivedAtUtc < @Cutoff"; + await db.ExecuteAsync(new CommandDefinition(sql, new { Cutoff = cutoff }, + cancellationToken: cancellationToken)).ConfigureAwait(false); + } +} diff --git a/Src/RCommon.Dapper/Outbox/DapperOutboxStore.cs b/Src/RCommon.Dapper/Outbox/DapperOutboxStore.cs new file mode 100644 index 00000000..d6eb685a --- /dev/null +++ b/Src/RCommon.Dapper/Outbox/DapperOutboxStore.cs @@ -0,0 +1,187 @@ +using Dapper; +using Microsoft.Extensions.Options; +using RCommon.Persistence.Outbox; +using RCommon.Persistence.Sql; +using System; +using System.Collections.Generic; +using System.Data; +using System.Data.Common; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +namespace RCommon.Persistence.Dapper.Outbox; + +public class DapperOutboxStore : IOutboxStore +{ + private readonly IDataStoreFactory _dataStoreFactory; + private readonly string _dataStoreName; + private readonly string _tableName; + private readonly int _maxRetries; + private readonly ILockStatementProvider _lockProvider; + + public DapperOutboxStore( + IDataStoreFactory dataStoreFactory, + IOptions defaultDataStoreOptions, + IOptions outboxOptions, + ILockStatementProvider lockStatementProvider) + { + _dataStoreFactory = dataStoreFactory ?? throw new ArgumentNullException(nameof(dataStoreFactory)); + _dataStoreName = defaultDataStoreOptions?.Value?.DefaultDataStoreName + ?? throw new ArgumentNullException(nameof(defaultDataStoreOptions)); + _tableName = outboxOptions?.Value?.TableName ?? "__OutboxMessages"; + _maxRetries = outboxOptions?.Value?.MaxRetries ?? 5; + _lockProvider = lockStatementProvider ?? throw new ArgumentNullException(nameof(lockStatementProvider)); + } + + private async Task GetOpenConnectionAsync(CancellationToken cancellationToken) + { + var dataStore = _dataStoreFactory.Resolve(_dataStoreName); + var connection = dataStore.GetDbConnection(); + if (connection.State == ConnectionState.Closed) + { + await connection.OpenAsync(cancellationToken).ConfigureAwait(false); + } + return connection; + } + + public async Task SaveAsync(IOutboxMessage message, CancellationToken cancellationToken = default) + { + await using var db = await GetOpenConnectionAsync(cancellationToken).ConfigureAwait(false); + var sql = $@"INSERT INTO [{_tableName}] (Id, EventType, EventPayload, CreatedAtUtc, ProcessedAtUtc, DeadLetteredAtUtc, ErrorMessage, RetryCount, CorrelationId, TenantId, NextRetryAtUtc, LockedByInstanceId, LockedUntilUtc) + VALUES (@Id, @EventType, @EventPayload, @CreatedAtUtc, @ProcessedAtUtc, @DeadLetteredAtUtc, @ErrorMessage, @RetryCount, @CorrelationId, @TenantId, @NextRetryAtUtc, @LockedByInstanceId, @LockedUntilUtc)"; + await db.ExecuteAsync(new CommandDefinition(sql, message, cancellationToken: cancellationToken)).ConfigureAwait(false); + } + + public async Task MarkProcessedAsync(Guid messageId, CancellationToken cancellationToken = default) + { + await using var db = await GetOpenConnectionAsync(cancellationToken).ConfigureAwait(false); + var sql = $"UPDATE [{_tableName}] SET ProcessedAtUtc = @Now WHERE Id = @Id"; + await db.ExecuteAsync(new CommandDefinition(sql, + new { Id = messageId, Now = DateTimeOffset.UtcNow }, + cancellationToken: cancellationToken)).ConfigureAwait(false); + } + + public async Task MarkFailedAsync(Guid messageId, string error, DateTimeOffset nextRetryAtUtc, CancellationToken cancellationToken = default) + { + await using var db = await GetOpenConnectionAsync(cancellationToken).ConfigureAwait(false); + var sql = $"UPDATE [{_tableName}] SET ErrorMessage = @Error, RetryCount = RetryCount + 1, NextRetryAtUtc = @NextRetryAtUtc, LockedByInstanceId = NULL, LockedUntilUtc = NULL WHERE Id = @Id"; + await db.ExecuteAsync(new CommandDefinition(sql, + new { Id = messageId, Error = error, NextRetryAtUtc = nextRetryAtUtc }, + cancellationToken: cancellationToken)).ConfigureAwait(false); + } + + public async Task MarkDeadLetteredAsync(Guid messageId, CancellationToken cancellationToken = default) + { + await using var db = await GetOpenConnectionAsync(cancellationToken).ConfigureAwait(false); + var sql = $"UPDATE [{_tableName}] SET DeadLetteredAtUtc = @Now WHERE Id = @Id"; + await db.ExecuteAsync(new CommandDefinition(sql, + new { Id = messageId, Now = DateTimeOffset.UtcNow }, + cancellationToken: cancellationToken)).ConfigureAwait(false); + } + + public async Task DeleteProcessedAsync(TimeSpan olderThan, CancellationToken cancellationToken = default) + { + await using var db = await GetOpenConnectionAsync(cancellationToken).ConfigureAwait(false); + var cutoff = DateTimeOffset.UtcNow - olderThan; + var sql = $"DELETE FROM [{_tableName}] WHERE ProcessedAtUtc IS NOT NULL AND ProcessedAtUtc < @Cutoff"; + await db.ExecuteAsync(new CommandDefinition(sql, + new { Cutoff = cutoff }, + cancellationToken: cancellationToken)).ConfigureAwait(false); + } + + public async Task DeleteDeadLetteredAsync(TimeSpan olderThan, CancellationToken cancellationToken = default) + { + await using var db = await GetOpenConnectionAsync(cancellationToken).ConfigureAwait(false); + var cutoff = DateTimeOffset.UtcNow - olderThan; + var sql = $"DELETE FROM [{_tableName}] WHERE DeadLetteredAtUtc IS NOT NULL AND DeadLetteredAtUtc < @Cutoff"; + await db.ExecuteAsync(new CommandDefinition(sql, + new { Cutoff = cutoff }, + cancellationToken: cancellationToken)).ConfigureAwait(false); + } + + public async Task> ClaimAsync(string instanceId, int batchSize, TimeSpan lockDuration, CancellationToken cancellationToken = default) + { + await using var db = await GetOpenConnectionAsync(cancellationToken).ConfigureAwait(false); + var now = DateTimeOffset.UtcNow; + var lockUntil = now + lockDuration; + + string sql; + if (_lockProvider.ProviderName == "PostgreSql") + { + sql = $@" + UPDATE ""{_tableName}"" o + SET ""LockedByInstanceId"" = @InstanceId, ""LockedUntilUtc"" = @LockUntil + FROM ( + SELECT ""Id"" FROM ""{_tableName}"" + WHERE ""ProcessedAtUtc"" IS NULL + AND ""DeadLetteredAtUtc"" IS NULL + AND ""RetryCount"" < @MaxRetries + AND (""NextRetryAtUtc"" IS NULL OR ""NextRetryAtUtc"" <= @Now) + AND (""LockedUntilUtc"" IS NULL OR ""LockedUntilUtc"" <= @Now) + ORDER BY ""CreatedAtUtc"" + LIMIT @BatchSize + FOR UPDATE SKIP LOCKED + ) AS batch + WHERE o.""Id"" = batch.""Id"" + RETURNING o.*"; + } + else // Default: SQL Server + { + sql = $@" + WITH batch AS ( + SELECT TOP (@BatchSize) Id + FROM [{_tableName}] WITH (UPDLOCK, ROWLOCK, READPAST) + WHERE ProcessedAtUtc IS NULL + AND DeadLetteredAtUtc IS NULL + AND RetryCount < @MaxRetries + AND (NextRetryAtUtc IS NULL OR NextRetryAtUtc <= @Now) + AND (LockedUntilUtc IS NULL OR LockedUntilUtc <= @Now) + ORDER BY CreatedAtUtc + ) + UPDATE o + SET o.LockedByInstanceId = @InstanceId, o.LockedUntilUtc = @LockUntil + OUTPUT INSERTED.* + FROM [{_tableName}] o + INNER JOIN batch ON o.Id = batch.Id"; + } + + var result = await db.QueryAsync( + new CommandDefinition(sql, + new { BatchSize = batchSize, MaxRetries = _maxRetries, Now = now, InstanceId = instanceId, LockUntil = lockUntil }, + cancellationToken: cancellationToken)).ConfigureAwait(false); + return result.ToList(); + } + + public async Task> GetDeadLettersAsync(int batchSize, int offset = 0, CancellationToken cancellationToken = default) + { + await using var db = await GetOpenConnectionAsync(cancellationToken).ConfigureAwait(false); + string sql; + if (_lockProvider.ProviderName == "PostgreSql") + { + sql = $@"SELECT * FROM ""{_tableName}"" WHERE ""DeadLetteredAtUtc"" IS NOT NULL ORDER BY ""DeadLetteredAtUtc"" DESC LIMIT @BatchSize OFFSET @Offset"; + } + else + { + sql = $@"SELECT * FROM [{_tableName}] WHERE DeadLetteredAtUtc IS NOT NULL ORDER BY DeadLetteredAtUtc DESC OFFSET @Offset ROWS FETCH NEXT @BatchSize ROWS ONLY"; + } + var result = await db.QueryAsync( + new CommandDefinition(sql, new { BatchSize = batchSize, Offset = offset }, + cancellationToken: cancellationToken)).ConfigureAwait(false); + return result.ToList(); + } + + public async Task ReplayDeadLetterAsync(Guid messageId, CancellationToken cancellationToken = default) + { + await using var db = await GetOpenConnectionAsync(cancellationToken).ConfigureAwait(false); + var sql = $@"UPDATE [{_tableName}] SET DeadLetteredAtUtc = NULL, ProcessedAtUtc = NULL, ErrorMessage = NULL, RetryCount = 0, NextRetryAtUtc = NULL, LockedByInstanceId = NULL, LockedUntilUtc = NULL + WHERE Id = @Id AND DeadLetteredAtUtc IS NOT NULL"; + var rows = await db.ExecuteAsync(new CommandDefinition(sql, + new { Id = messageId }, + cancellationToken: cancellationToken)).ConfigureAwait(false); + if (rows == 0) + { + throw new InvalidOperationException($"Message {messageId} does not exist or is not dead-lettered."); + } + } +} diff --git a/Src/RCommon.Dapper/Sagas/DapperSagaStore.cs b/Src/RCommon.Dapper/Sagas/DapperSagaStore.cs new file mode 100644 index 00000000..4b9dea7d --- /dev/null +++ b/Src/RCommon.Dapper/Sagas/DapperSagaStore.cs @@ -0,0 +1,154 @@ +using System; +using System.Data; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Dommel; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using RCommon.Persistence.Sagas; +using RCommon.Persistence.Sql; + +namespace RCommon.Persistence.Dapper.Sagas; + +/// +/// A Dapper/Dommel implementation of that persists saga state +/// using a resolved through the . +/// +/// The saga state type. Must derive from . +/// The primary key type. Must implement . +public class DapperSagaStore : ISagaStore + where TState : SagaState + where TKey : IEquatable +{ + private readonly IDataStoreFactory _dataStoreFactory; + private readonly ILogger _logger; + private string _dataStoreName; + + /// + /// Initializes a new instance of . + /// + /// Factory used to resolve the for the configured data store. + /// Factory for creating loggers scoped to this store type. + /// Options specifying which data store to use when none is explicitly set. + /// Thrown when any required parameter is null. + public DapperSagaStore( + IDataStoreFactory dataStoreFactory, + ILoggerFactory loggerFactory, + IOptions defaultDataStoreOptions) + { + _dataStoreFactory = dataStoreFactory ?? throw new ArgumentNullException(nameof(dataStoreFactory)); + _logger = loggerFactory?.CreateLogger(GetType().Name) ?? throw new ArgumentNullException(nameof(loggerFactory)); + + if (defaultDataStoreOptions is null) + throw new ArgumentNullException(nameof(defaultDataStoreOptions)); + + _dataStoreName = defaultDataStoreOptions.Value?.DefaultDataStoreName ?? string.Empty; + } + + /// + /// Gets the resolved for this store using the current . + /// + private RDbConnection DataStore + => _dataStoreFactory.Resolve(_dataStoreName); + + /// + public async Task GetByIdAsync(TKey id, CancellationToken ct = default) + { + await using var db = DataStore.GetDbConnection(); + try + { + if (db.State == ConnectionState.Closed) + await db.OpenAsync(ct).ConfigureAwait(false); + + return await db.GetAsync(id, cancellationToken: ct).ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error in {StoreType}.GetByIdAsync while executing on the DbConnection.", GetType().FullName); + throw; + } + finally + { + if (db.State == ConnectionState.Open) + await db.CloseAsync().ConfigureAwait(false); + } + } + + /// + public async Task FindByCorrelationIdAsync(string correlationId, CancellationToken ct = default) + { + await using var db = DataStore.GetDbConnection(); + try + { + if (db.State == ConnectionState.Closed) + await db.OpenAsync(ct).ConfigureAwait(false); + + var results = await db.SelectAsync( + s => s.CorrelationId == correlationId, + cancellationToken: ct).ConfigureAwait(false); + + return results.FirstOrDefault(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error in {StoreType}.FindByCorrelationIdAsync while executing on the DbConnection.", GetType().FullName); + throw; + } + finally + { + if (db.State == ConnectionState.Open) + await db.CloseAsync().ConfigureAwait(false); + } + } + + /// + public async Task SaveAsync(TState state, CancellationToken ct = default) + { + await using var db = DataStore.GetDbConnection(); + try + { + if (db.State == ConnectionState.Closed) + await db.OpenAsync(ct).ConfigureAwait(false); + + var updated = await db.UpdateAsync(state, cancellationToken: ct).ConfigureAwait(false); + if (!updated) + { + await db.InsertAsync(state, cancellationToken: ct).ConfigureAwait(false); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error in {StoreType}.SaveAsync while executing on the DbConnection.", GetType().FullName); + throw; + } + finally + { + if (db.State == ConnectionState.Open) + await db.CloseAsync().ConfigureAwait(false); + } + } + + /// + public async Task DeleteAsync(TState state, CancellationToken ct = default) + { + await using var db = DataStore.GetDbConnection(); + try + { + if (db.State == ConnectionState.Closed) + await db.OpenAsync(ct).ConfigureAwait(false); + + await db.DeleteAsync(state, cancellationToken: ct).ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error in {StoreType}.DeleteAsync while executing on the DbConnection.", GetType().FullName); + throw; + } + finally + { + if (db.State == ConnectionState.Open) + await db.CloseAsync().ConfigureAwait(false); + } + } +} diff --git a/Src/RCommon.EfCore/Crud/EFCoreAggregateRepository.cs b/Src/RCommon.EfCore/Crud/EFCoreAggregateRepository.cs new file mode 100644 index 00000000..05a334f6 --- /dev/null +++ b/Src/RCommon.EfCore/Crud/EFCoreAggregateRepository.cs @@ -0,0 +1,632 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Query; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using RCommon; +using RCommon.Entities; +using RCommon.Security.Claims; +using RCommon.Collections; +using RCommon.Linq; +using System; +using System.Collections.Generic; +using System.Data; +using System.Diagnostics; +using System.Linq; +using System.Linq.Expressions; +using System.Reflection; +using System.Threading; +using System.Threading.Tasks; +using RCommon.Persistence.Crud; + +namespace RCommon.Persistence.EFCore.Crud +{ + + /// + /// A DDD-constrained repository for aggregate roots backed by Entity Framework Core. + /// Inherits full LINQ/graph repository infrastructure from + /// and exposes the narrow contract for + /// aggregate-appropriate operations only. + /// + /// The aggregate root type. Must implement . + /// The type of the aggregate's identity key. + public class EFCoreAggregateRepository : GraphRepositoryBase, IAggregateRepository + where TAggregate : class, IAggregateRoot + where TKey : IEquatable + { + private IQueryable? _repositoryQuery; + private bool _tracking; + private IIncludableQueryable? _includableQueryable; + private readonly IDataStoreFactory _dataStoreFactory; + + + + /// + /// Initializes a new instance of . + /// + /// Factory used to resolve the for the configured data store. + /// Factory for creating loggers scoped to this repository type. + /// Tracker used to register entities for domain event dispatching. + /// Options specifying which data store to use when none is explicitly set. + /// Accessor for the current tenant identifier. + /// Thrown when any parameter is null. + public EFCoreAggregateRepository(IDataStoreFactory dataStoreFactory, + ILoggerFactory logger, IEntityEventTracker eventTracker, + IOptions defaultDataStoreOptions, + ITenantIdAccessor tenantIdAccessor) + : base(dataStoreFactory, eventTracker, defaultDataStoreOptions, tenantIdAccessor) + { + if (logger is null) + { + throw new ArgumentNullException(nameof(logger)); + } + + if (eventTracker is null) + { + throw new ArgumentNullException(nameof(eventTracker)); + } + + if (defaultDataStoreOptions is null) + { + throw new ArgumentNullException(nameof(defaultDataStoreOptions)); + } + + Logger = logger.CreateLogger(GetType().Name); + _tracking = true; + _dataStoreFactory = dataStoreFactory ?? throw new ArgumentNullException(nameof(dataStoreFactory)); + } + + /// + /// Gets the from the current for direct entity set operations. + /// + protected DbSet ObjectSet + { + get + { + return ObjectContext.Set(); + } + } + + /// + /// Gets or sets whether EF Core change tracking is enabled for queries executed through this repository. + /// + public override bool Tracking + { + get => _tracking; + set + { + _tracking = value; + } + + } + + /// + /// Adds an eager-loading include path for the specified navigation property. + /// + /// An expression selecting the navigation property to include. + /// This repository instance for fluent chaining of additional includes. + public override IEagerLoadableQueryable Include(Expression> path) + { + if (_includableQueryable == null) + { + // Start from existing query state (preserves prior ThenInclude chains) or fresh DbSet + var source = _repositoryQuery ?? (IQueryable)ObjectContext.Set(); + _includableQueryable = source.Include(path); + } + else + { + _includableQueryable = _includableQueryable.Include(path); + } + + return this; + } + + /// + /// Adds a subsequent eager-loading path for a nested navigation property after a prior call. + /// + /// The type of the previously included navigation property. + /// The type of the nested navigation property to include. + /// An expression selecting the nested navigation property to include. + /// This repository instance for fluent chaining. + public override IEagerLoadableQueryable ThenInclude(Expression> path) + { + _repositoryQuery = _includableQueryable!.ThenInclude(path); + _includableQueryable = null; // Consumed — RepositoryQuery getter will use _repositoryQuery + return this; + } + + /// + /// Gets the base used for all query operations. + /// Applies eager-loading expressions if any have been configured via . + /// + protected override IQueryable RepositoryQuery + { + get + { + if (_repositoryQuery == null) + { + _repositoryQuery = ObjectSet.AsQueryable(); + } + + // Override the base query with the eager-loaded queryable if includes have been configured + if (_includableQueryable != null) + { + _repositoryQuery = _includableQueryable; + } + return _repositoryQuery; + } + } + + /// + public override async Task AddAsync(TAggregate entity, CancellationToken token = default) + { + EventTracker.AddEntity(entity); + MultiTenantHelper.SetTenantIdIfApplicable(entity, _tenantIdAccessor.GetTenantId()); + await ObjectSet.AddAsync(entity, token).ConfigureAwait(false); + await SaveAsync(token).ConfigureAwait(false); + } + + + /// + /// Deletes the entity. If implements , + /// a soft delete is performed automatically (sets IsDeleted = true and issues an UPDATE). + /// Otherwise a physical DELETE is executed. + /// + public async override Task DeleteAsync(TAggregate entity, CancellationToken token = default) + { + if (SoftDeleteHelper.IsSoftDeletable()) + { + SoftDeleteHelper.MarkAsDeleted(entity); + await UpdateAsync(entity, token).ConfigureAwait(false); + return; + } + + EventTracker.AddEntity(entity); + ObjectSet.Remove(entity); + await SaveAsync().ConfigureAwait(false); + } + + /// + /// Deletes the entity using the explicitly specified delete mode. When + /// is true, the entity must implement ; its IsDeleted property + /// is set to true and an UPDATE is issued. When false, a physical DELETE is always + /// performed — even if the entity implements . + /// + /// + /// Thrown when is true but + /// does not implement . + /// + public async override Task DeleteAsync(TAggregate entity, bool isSoftDelete, CancellationToken token = default) + { + if (!isSoftDelete) + { + // Bypass auto-detection — force a physical delete + EventTracker.AddEntity(entity); + ObjectSet.Remove(entity); + await SaveAsync().ConfigureAwait(false); + return; + } + + SoftDeleteHelper.EnsureSoftDeletable(); + SoftDeleteHelper.MarkAsDeleted(entity); + await UpdateAsync(entity, token).ConfigureAwait(false); + } + + /// + /// Deletes entities matching the specification. If implements + /// , a soft delete is performed automatically. + /// + public async override Task DeleteManyAsync(ISpecification specification, CancellationToken token = default) + { + return await this.DeleteManyAsync(specification.Predicate, token).ConfigureAwait(false); + } + + /// + /// Deletes entities matching the specification. When is true, + /// each matching entity must implement — its IsDeleted property is + /// set to true and an UPDATE is issued instead of a DELETE. + /// + /// + /// Thrown when is true but + /// does not implement . + /// + public async override Task DeleteManyAsync(ISpecification specification, bool isSoftDelete, CancellationToken token = default) + { + return await this.DeleteManyAsync(specification.Predicate, isSoftDelete, token).ConfigureAwait(false); + } + + /// + /// Deletes entities matching the expression. If implements + /// , a soft delete is performed automatically (marks each matching + /// entity as deleted and issues UPDATEs). Otherwise a physical DELETE is executed. + /// + public async override Task DeleteManyAsync(Expression> expression, CancellationToken token = default) + { + if (SoftDeleteHelper.IsSoftDeletable()) + { + return await DeleteManyAsync(expression, isSoftDelete: true, token).ConfigureAwait(false); + } + + return await RepositoryQuery.Where(expression).ExecuteDeleteAsync(token).ConfigureAwait(false); + } + + /// + /// Deletes entities matching the expression. When is true, + /// each matching entity must implement — its IsDeleted property is + /// set to true and an UPDATE is issued instead of a DELETE. + /// + /// + /// The soft-delete path fetches matching entities into memory, marks each as deleted, then saves + /// in a single round-trip. This approach is used instead of ExecuteUpdateAsync with a cast + /// expression to ensure compatibility across all EF Core database providers. + /// + /// + /// Thrown when is true but + /// does not implement . + /// + public async override Task DeleteManyAsync(Expression> expression, bool isSoftDelete, CancellationToken token = default) + { + if (!isSoftDelete) + { + // Bypass auto-detection and soft-delete filter — force a physical delete + return await RepositoryQuery.Where(expression).ExecuteDeleteAsync(token).ConfigureAwait(false); + } + + SoftDeleteHelper.EnsureSoftDeletable(); + + var entities = await this.FindQuery(expression).ToListAsync(token).ConfigureAwait(false); + foreach (var entity in entities) + { + SoftDeleteHelper.MarkAsDeleted(entity); + ObjectSet.Update(entity); + } + return await SaveAsync(token).ConfigureAwait(false); + } + + /// + public async override Task UpdateAsync(TAggregate entity, CancellationToken token = default) + { + EventTracker.AddEntity(entity); + ObjectSet.Update(entity); + await SaveAsync(token).ConfigureAwait(false); + } + + /// + /// Core query method that applies the given filter expression to the . + /// All find operations delegate to this method to build the filtered queryable. + /// + /// A predicate expression to filter entities. + /// An representing the filtered query. + /// Thrown when is null. + private IQueryable FindCore(Expression> expression) + { + IQueryable queryable; + try + { + Guard.Against(FilteredRepositoryQuery == null, "RepositoryQuery is null"); + queryable = FilteredRepositoryQuery.Where(expression); + } + catch (ApplicationException exception) + { + Logger.LogError(exception, "Error in {0}.FindCore while executing a query on the Context.", GetType().FullName); + throw; + } + return queryable; + } + + /// + public async override Task GetCountAsync(ISpecification selectSpec, CancellationToken token = default) + { + return await FindCore(selectSpec.Predicate).CountAsync(token).ConfigureAwait(false); + } + + /// + public async override Task GetCountAsync(Expression> expression, CancellationToken token = default) + { + return await FindCore(expression).CountAsync(token).ConfigureAwait(false); + } + + /// + public override IQueryable FindQuery(ISpecification specification) + { + return FindCore(specification.Predicate); + } + + /// + public override IQueryable FindQuery(Expression> expression) + { + return FindCore(expression); + } + + /// + public override async Task FindAsync(object primaryKey, CancellationToken token = default) + { + var entity = await ObjectSet.FindAsync(new object[] { primaryKey }, token).ConfigureAwait(false); + + // Post-fetch soft-delete check: if the entity was soft-deleted, treat it as not found + if (entity != null && SoftDeleteHelper.IsSoftDeletable() && ((ISoftDelete)entity).IsDeleted) + { + return default!; + } + + // Post-fetch tenant check: if the entity belongs to a different tenant, treat it as not found + var currentTenantId = _tenantIdAccessor.GetTenantId(); + if (entity != null && MultiTenantHelper.IsMultiTenant() + && !string.IsNullOrEmpty(currentTenantId) + && ((IMultiTenant)entity).TenantId != currentTenantId) + { + return default!; + } + + return entity!; + } + + /// + public async override Task> FindAsync(ISpecification specification, CancellationToken token = default) + { + return await FindCore(specification.Predicate).ToListAsync(token).ConfigureAwait(false); + } + + /// + public async override Task> FindAsync(Expression> expression, CancellationToken token = default) + { + return await FindCore(expression).ToListAsync(token).ConfigureAwait(false); + } + + /// + public async override Task> FindAsync(IPagedSpecification specification, CancellationToken token = default) + { + IQueryable query; + if (specification.OrderByAscending) + { + query = FindCore(specification.Predicate).OrderBy(specification.OrderByExpression); + } + else + { + query = FindCore(specification.Predicate).OrderByDescending(specification.OrderByExpression); + } + return await Task.FromResult(query.ToPaginatedList(specification.PageNumber, specification.PageSize)).ConfigureAwait(false); + } + + /// + public async override Task> FindAsync(Expression> expression, Expression> orderByExpression, + bool orderByAscending, int pageNumber = 1, int pageSize = 1, + CancellationToken token = default) + { + IQueryable query; + if (orderByAscending) + { + query = FindCore(expression).OrderBy(orderByExpression); + } + else + { + query = FindCore(expression).OrderByDescending(orderByExpression); + } + return await Task.FromResult(query.ToPaginatedList(pageNumber, pageSize)).ConfigureAwait(false); + } + + /// + public override IQueryable FindQuery(Expression> expression, Expression> orderByExpression, + bool orderByAscending, int pageNumber = 1, int pageSize = 0) + { + IQueryable query; + if (orderByAscending) + { + query = FindCore(expression).OrderBy(orderByExpression); + } + else + { + query = FindCore(expression).OrderByDescending(orderByExpression); + } + return query.Skip((pageNumber - 1) * pageSize).Take(pageSize); + } + + /// + public override IQueryable FindQuery(Expression> expression, Expression> orderByExpression, + bool orderByAscending) + { + IQueryable query; + if (orderByAscending) + { + query = FindCore(expression).OrderBy(orderByExpression); + } + else + { + query = FindCore(expression).OrderByDescending(orderByExpression); + } + return query; + } + + /// + public override IQueryable FindQuery(IPagedSpecification specification) + { + return this.FindQuery(specification.Predicate, specification.OrderByExpression, + specification.OrderByAscending, specification.PageNumber, specification.PageSize); + } + + /// + public override async Task FindSingleOrDefaultAsync(Expression> expression, CancellationToken token = default) + { + return (await FindCore(expression).SingleOrDefaultAsync(token).ConfigureAwait(false))!; + } + + /// + public override async Task FindSingleOrDefaultAsync(ISpecification specification, CancellationToken token = default) + { + return (await FindCore(specification.Predicate).SingleOrDefaultAsync(token).ConfigureAwait(false))!; + } + + /// + public async override Task AnyAsync(Expression> expression, CancellationToken token = default) + { + return await FindCore(expression).AnyAsync(token).ConfigureAwait(false); + } + + /// + public async override Task AnyAsync(ISpecification specification, CancellationToken token = default) + { + return await FindCore(specification.Predicate).AnyAsync(token).ConfigureAwait(false); + } + + /// + /// Gets the for the configured data store, resolved through the . + /// + protected internal RCommonDbContext ObjectContext + { + get + { + return this._dataStoreFactory.Resolve(this.DataStoreName); + } + } + + /// + /// Persists all pending changes in the to the database. + /// + /// A cancellation token to observe. + /// The number of rows affected by the save operation. + /// Thrown when the underlying save operation fails. + private async Task SaveAsync(CancellationToken token = default) + { + int affected = 0; + try + { + // acceptAllChangesOnSuccess is set to true so EF resets tracking after a successful save + affected = await ObjectContext.SaveChangesAsync(true, token).ConfigureAwait(false); + } + catch (ApplicationException ex) + { + var persistEx = new PersistenceException($"Error in {this.GetGenericTypeName()}.SaveAsync while executing on the Context.", ex); + throw persistEx; + } + + return affected; + } + /// + /// Adds a range of transient entities to be tracked and persisted by the repository. + /// + /// Collection of entities to persist. + /// Cancellation token. + public override async Task AddRangeAsync(IEnumerable entities, CancellationToken token = default) + { + if (entities == null) throw new ArgumentNullException(nameof(entities)); + + // track each entity and stamp tenant prior to adding + foreach (var entity in entities) + { + EventTracker.AddEntity(entity); + MultiTenantHelper.SetTenantIdIfApplicable(entity, _tenantIdAccessor.GetTenantId()); + } + + await ObjectSet.AddRangeAsync(entities, token).ConfigureAwait(false); + await SaveAsync(token).ConfigureAwait(false); + } + + // ────────────────────────────────────────────────────────────────────── + // Explicit IAggregateRepository implementations + // ────────────────────────────────────────────────────────────────────── + + /// + /// Loads an aggregate root by its identity key. + /// + async Task IAggregateRepository.GetByIdAsync(TKey id, CancellationToken cancellationToken) + { + return await FilteredRepositoryQuery.FirstOrDefaultAsync(e => e.Id.Equals(id), cancellationToken).ConfigureAwait(false); + } + + /// + /// Finds a single aggregate matching the given specification. + /// + async Task IAggregateRepository.FindAsync(ISpecification specification, CancellationToken cancellationToken) + { + return await FilteredRepositoryQuery.Where(specification.Predicate).FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false); + } + + /// + /// Checks whether an aggregate with the given identity key exists. + /// + async Task IAggregateRepository.ExistsAsync(TKey id, CancellationToken cancellationToken) + { + return await FilteredRepositoryQuery.AnyAsync(e => e.Id.Equals(id), cancellationToken).ConfigureAwait(false); + } + + /// + /// Adds a new aggregate root to the repository and persists it. + /// + async Task IAggregateRepository.AddAsync(TAggregate aggregate, CancellationToken cancellationToken) + { + EventTracker.AddEntity(aggregate); + MultiTenantHelper.SetTenantIdIfApplicable(aggregate, _tenantIdAccessor.GetTenantId()); + await ObjectSet.AddAsync(aggregate, cancellationToken).ConfigureAwait(false); + await SaveAsync(cancellationToken).ConfigureAwait(false); + } + + /// + /// Updates an existing aggregate root and persists the changes. + /// + async Task IAggregateRepository.UpdateAsync(TAggregate aggregate, CancellationToken cancellationToken) + { + EventTracker.AddEntity(aggregate); + ObjectSet.Update(aggregate); + await SaveAsync(cancellationToken).ConfigureAwait(false); + } + + /// + /// Deletes an aggregate root. If the aggregate implements , + /// a soft delete is performed automatically. + /// + async Task IAggregateRepository.DeleteAsync(TAggregate aggregate, CancellationToken cancellationToken) + { + if (SoftDeleteHelper.IsSoftDeletable()) + { + SoftDeleteHelper.MarkAsDeleted(aggregate); + EventTracker.AddEntity(aggregate); + ObjectSet.Update(aggregate); + await SaveAsync(cancellationToken).ConfigureAwait(false); + return; + } + + EventTracker.AddEntity(aggregate); + ObjectSet.Remove(aggregate); + await SaveAsync(cancellationToken).ConfigureAwait(false); + } + + /// + /// Adds an eager-loading include path and returns the aggregate repository for fluent chaining. + /// + IAggregateRepository IAggregateRepository.Include(Expression> path) + { + // Convert to Expression> so it is compatible with the + // IIncludableQueryable field used by the base Include logic. + var converted = Expression.Lambda>( + Expression.Convert(path.Body, typeof(object)), path.Parameters); + + if (_includableQueryable == null) + { + _includableQueryable = ObjectContext.Set().Include(converted); + } + else + { + _includableQueryable = _includableQueryable.Include(converted); + } + + return this; + } + + /// + /// Adds a subsequent eager-loading path for a nested navigation property and returns the aggregate repository for fluent chaining. + /// + IAggregateRepository IAggregateRepository.ThenInclude(Expression> path) + { + // Rewrite the expression from Func to Func + // to match the IIncludableQueryable field type. + var param = Expression.Parameter(typeof(object), path.Parameters[0].Name); + var castParam = Expression.Convert(param, typeof(TPreviousProperty)); + var body = ReplacingExpressionVisitor.Replace(path.Parameters[0], castParam, path.Body); + var converted = Expression.Lambda>(body, param); + + _repositoryQuery = _includableQueryable!.ThenInclude(converted); + + return this; + } + } +} diff --git a/Src/RCommon.EfCore/Crud/EFCoreReadModelRepository.cs b/Src/RCommon.EfCore/Crud/EFCoreReadModelRepository.cs new file mode 100644 index 00000000..a35af7bd --- /dev/null +++ b/Src/RCommon.EfCore/Crud/EFCoreReadModelRepository.cs @@ -0,0 +1,134 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Linq.Expressions; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using RCommon.Models; +using RCommon.Persistence; +using RCommon.Persistence.Crud; + +namespace RCommon.Persistence.EFCore.Crud; + +public class EFCoreReadModelRepository : IReadModelRepository + where TReadModel : class, IReadModel +{ + private readonly IDataStoreFactory _dataStoreFactory; + private readonly ILogger _logger; + private string _dataStoreName; + private IQueryable? _repositoryQuery; + + public EFCoreReadModelRepository( + IDataStoreFactory dataStoreFactory, + ILoggerFactory loggerFactory, + IOptions defaultDataStoreOptions) + { + _dataStoreFactory = dataStoreFactory ?? throw new ArgumentNullException(nameof(dataStoreFactory)); + _logger = loggerFactory?.CreateLogger(GetType().Name) ?? throw new ArgumentNullException(nameof(loggerFactory)); + + if (defaultDataStoreOptions is null) + throw new ArgumentNullException(nameof(defaultDataStoreOptions)); + + _dataStoreName = defaultDataStoreOptions.Value?.DefaultDataStoreName ?? string.Empty; + } + + private RCommonDbContext ObjectContext + => _dataStoreFactory.Resolve(_dataStoreName); + + private DbSet ObjectSet + => ObjectContext.Set(); + + /// + /// Gets the base queryable used for all read operations. Defaults to no-tracking since + /// read models do not participate in change tracking or domain events. + /// + private IQueryable RepositoryQuery + { + get + { + if (_repositoryQuery == null) + { + _repositoryQuery = ObjectSet.AsNoTracking(); + } + return _repositoryQuery; + } + } + + /// + public string DataStoreName + { + get => _dataStoreName; + set => _dataStoreName = value; + } + + /// + public async Task FindAsync( + ISpecification specification, + CancellationToken cancellationToken = default) + { + return await RepositoryQuery + .Where(specification.Predicate) + .FirstOrDefaultAsync(cancellationToken) + .ConfigureAwait(false); + } + + /// + public async Task> FindAllAsync( + ISpecification specification, + CancellationToken cancellationToken = default) + { + return await RepositoryQuery + .Where(specification.Predicate) + .ToListAsync(cancellationToken) + .ConfigureAwait(false); + } + + /// + public async Task> GetPagedAsync( + IPagedSpecification specification, + CancellationToken cancellationToken = default) + { + var query = RepositoryQuery.Where(specification.Predicate); + var totalCount = await query.LongCountAsync(cancellationToken).ConfigureAwait(false); + var items = await query + .Skip((specification.PageNumber - 1) * specification.PageSize) + .Take(specification.PageSize) + .ToListAsync(cancellationToken) + .ConfigureAwait(false); + + return new PagedResult(items, totalCount, specification.PageNumber, specification.PageSize); + } + + /// + public async Task GetCountAsync( + ISpecification specification, + CancellationToken cancellationToken = default) + { + return await RepositoryQuery + .Where(specification.Predicate) + .LongCountAsync(cancellationToken) + .ConfigureAwait(false); + } + + /// + public async Task AnyAsync( + ISpecification specification, + CancellationToken cancellationToken = default) + { + return await RepositoryQuery + .Where(specification.Predicate) + .AnyAsync(cancellationToken) + .ConfigureAwait(false); + } + + /// + public IReadModelRepository Include( + Expression> path) + { + _repositoryQuery = RepositoryQuery.Include(path); + return this; + } +} diff --git a/Src/RCommon.EfCore/Crud/EFCoreRepository.cs b/Src/RCommon.EfCore/Crud/EFCoreRepository.cs index d91f1449..4ad31821 100644 --- a/Src/RCommon.EfCore/Crud/EFCoreRepository.cs +++ b/Src/RCommon.EfCore/Crud/EFCoreRepository.cs @@ -108,10 +108,11 @@ public override bool Tracking /// This repository instance for fluent chaining of additional includes. public override IEagerLoadableQueryable Include(Expression> path) { - // On first call, start from the DbSet; on subsequent calls, chain from the existing includable query if (_includableQueryable == null) { - _includableQueryable = ObjectContext.Set().Include(path); + // Start from existing query state (preserves prior ThenInclude chains) or fresh DbSet + var source = _repositoryQuery ?? (IQueryable)ObjectContext.Set(); + _includableQueryable = source.Include(path); } else { @@ -130,8 +131,8 @@ public override IEagerLoadableQueryable Include(ExpressionThis repository instance for fluent chaining. public override IEagerLoadableQueryable ThenInclude(Expression> path) { - // TODO: This is likely a bug. The receiver is incorrect. _repositoryQuery = _includableQueryable!.ThenInclude(path); + _includableQueryable = null; // Consumed — RepositoryQuery getter will use _repositoryQuery return this; } @@ -162,8 +163,8 @@ public override async Task AddAsync(TEntity entity, CancellationToken token = de { EventTracker.AddEntity(entity); MultiTenantHelper.SetTenantIdIfApplicable(entity, _tenantIdAccessor.GetTenantId()); - await ObjectSet.AddAsync(entity, token); - await SaveAsync(token); + await ObjectSet.AddAsync(entity, token).ConfigureAwait(false); + await SaveAsync(token).ConfigureAwait(false); } @@ -177,13 +178,13 @@ public async override Task DeleteAsync(TEntity entity, CancellationToken token = if (SoftDeleteHelper.IsSoftDeletable()) { SoftDeleteHelper.MarkAsDeleted(entity); - await UpdateAsync(entity, token); + await UpdateAsync(entity, token).ConfigureAwait(false); return; } EventTracker.AddEntity(entity); ObjectSet.Remove(entity); - await SaveAsync(); + await SaveAsync().ConfigureAwait(false); } /// @@ -203,13 +204,13 @@ public async override Task DeleteAsync(TEntity entity, bool isSoftDelete, Cancel // Bypass auto-detection — force a physical delete EventTracker.AddEntity(entity); ObjectSet.Remove(entity); - await SaveAsync(); + await SaveAsync().ConfigureAwait(false); return; } SoftDeleteHelper.EnsureSoftDeletable(); SoftDeleteHelper.MarkAsDeleted(entity); - await UpdateAsync(entity, token); + await UpdateAsync(entity, token).ConfigureAwait(false); } /// @@ -218,7 +219,7 @@ public async override Task DeleteAsync(TEntity entity, bool isSoftDelete, Cancel /// public async override Task DeleteManyAsync(ISpecification specification, CancellationToken token = default) { - return await this.DeleteManyAsync(specification.Predicate, token); + return await this.DeleteManyAsync(specification.Predicate, token).ConfigureAwait(false); } /// @@ -232,7 +233,7 @@ public async override Task DeleteManyAsync(ISpecification specific /// public async override Task DeleteManyAsync(ISpecification specification, bool isSoftDelete, CancellationToken token = default) { - return await this.DeleteManyAsync(specification.Predicate, isSoftDelete, token); + return await this.DeleteManyAsync(specification.Predicate, isSoftDelete, token).ConfigureAwait(false); } /// @@ -244,10 +245,10 @@ public async override Task DeleteManyAsync(Expression> { if (SoftDeleteHelper.IsSoftDeletable()) { - return await DeleteManyAsync(expression, isSoftDelete: true, token); + return await DeleteManyAsync(expression, isSoftDelete: true, token).ConfigureAwait(false); } - return await RepositoryQuery.Where(expression).ExecuteDeleteAsync(token); + return await RepositoryQuery.Where(expression).ExecuteDeleteAsync(token).ConfigureAwait(false); } /// @@ -269,18 +270,18 @@ public async override Task DeleteManyAsync(Expression> if (!isSoftDelete) { // Bypass auto-detection and soft-delete filter — force a physical delete - return await RepositoryQuery.Where(expression).ExecuteDeleteAsync(token); + return await RepositoryQuery.Where(expression).ExecuteDeleteAsync(token).ConfigureAwait(false); } SoftDeleteHelper.EnsureSoftDeletable(); - var entities = await this.FindQuery(expression).ToListAsync(token); + var entities = await this.FindQuery(expression).ToListAsync(token).ConfigureAwait(false); foreach (var entity in entities) { SoftDeleteHelper.MarkAsDeleted(entity); ObjectSet.Update(entity); } - return await SaveAsync(token); + return await SaveAsync(token).ConfigureAwait(false); } /// @@ -288,7 +289,7 @@ public async override Task UpdateAsync(TEntity entity, CancellationToken token = { EventTracker.AddEntity(entity); ObjectSet.Update(entity); - await SaveAsync(token); + await SaveAsync(token).ConfigureAwait(false); } /// @@ -317,13 +318,13 @@ private IQueryable FindCore(Expression> expression) /// public async override Task GetCountAsync(ISpecification selectSpec, CancellationToken token = default) { - return await FindCore(selectSpec.Predicate).CountAsync(token); + return await FindCore(selectSpec.Predicate).CountAsync(token).ConfigureAwait(false); } /// public async override Task GetCountAsync(Expression> expression, CancellationToken token = default) { - return await FindCore(expression).CountAsync(token); + return await FindCore(expression).CountAsync(token).ConfigureAwait(false); } /// @@ -341,7 +342,7 @@ public override IQueryable FindQuery(Expression> ex /// public override async Task FindAsync(object primaryKey, CancellationToken token = default) { - var entity = await ObjectSet.FindAsync(new object[] { primaryKey }, token); + var entity = await ObjectSet.FindAsync(new object[] { primaryKey }, token).ConfigureAwait(false); // Post-fetch soft-delete check: if the entity was soft-deleted, treat it as not found if (entity != null && SoftDeleteHelper.IsSoftDeletable() && ((ISoftDelete)entity).IsDeleted) @@ -364,13 +365,13 @@ public override async Task FindAsync(object primaryKey, CancellationTok /// public async override Task> FindAsync(ISpecification specification, CancellationToken token = default) { - return await FindCore(specification.Predicate).ToListAsync(token); + return await FindCore(specification.Predicate).ToListAsync(token).ConfigureAwait(false); } /// public async override Task> FindAsync(Expression> expression, CancellationToken token = default) { - return await FindCore(expression).ToListAsync(token); + return await FindCore(expression).ToListAsync(token).ConfigureAwait(false); } /// @@ -385,7 +386,7 @@ public async override Task> FindAsync(IPagedSpecificatio { query = FindCore(specification.Predicate).OrderByDescending(specification.OrderByExpression); } - return await Task.FromResult(query.ToPaginatedList(specification.PageNumber, specification.PageSize)); + return await Task.FromResult(query.ToPaginatedList(specification.PageNumber, specification.PageSize)).ConfigureAwait(false); } /// @@ -402,7 +403,7 @@ public async override Task> FindAsync(Expression @@ -447,25 +448,25 @@ public override IQueryable FindQuery(IPagedSpecification speci /// public override async Task FindSingleOrDefaultAsync(Expression> expression, CancellationToken token = default) { - return (await FindCore(expression).SingleOrDefaultAsync(token))!; + return (await FindCore(expression).SingleOrDefaultAsync(token).ConfigureAwait(false))!; } /// public override async Task FindSingleOrDefaultAsync(ISpecification specification, CancellationToken token = default) { - return (await FindCore(specification.Predicate).SingleOrDefaultAsync(token))!; + return (await FindCore(specification.Predicate).SingleOrDefaultAsync(token).ConfigureAwait(false))!; } /// public async override Task AnyAsync(Expression> expression, CancellationToken token = default) { - return await FindCore(expression).AnyAsync(token); + return await FindCore(expression).AnyAsync(token).ConfigureAwait(false); } /// public async override Task AnyAsync(ISpecification specification, CancellationToken token = default) { - return await FindCore(specification.Predicate).AnyAsync(token); + return await FindCore(specification.Predicate).AnyAsync(token).ConfigureAwait(false); } /// @@ -491,7 +492,7 @@ private async Task SaveAsync(CancellationToken token = default) try { // acceptAllChangesOnSuccess is set to true so EF resets tracking after a successful save - affected = await ObjectContext.SaveChangesAsync(true, token); + affected = await ObjectContext.SaveChangesAsync(true, token).ConfigureAwait(false); } catch (ApplicationException ex) { @@ -517,8 +518,8 @@ public override async Task AddRangeAsync(IEnumerable entities, Cancella MultiTenantHelper.SetTenantIdIfApplicable(entity, _tenantIdAccessor.GetTenantId()); } - await ObjectSet.AddRangeAsync(entities, token); - await SaveAsync(token); + await ObjectSet.AddRangeAsync(entities, token).ConfigureAwait(false); + await SaveAsync(token).ConfigureAwait(false); } } } diff --git a/Src/RCommon.EfCore/EFCorePerisistenceBuilder.cs b/Src/RCommon.EfCore/EFCorePerisistenceBuilder.cs index 01c2fe12..b7aabf68 100644 --- a/Src/RCommon.EfCore/EFCorePerisistenceBuilder.cs +++ b/Src/RCommon.EfCore/EFCorePerisistenceBuilder.cs @@ -11,6 +11,8 @@ using RCommon.Persistence.Crud; using RCommon.Persistence.EFCore; using RCommon.Persistence.EFCore.Crud; +using RCommon.Persistence.EFCore.Sagas; +using RCommon.Persistence.Sagas; using RCommon.Security.Claims; namespace RCommon @@ -46,6 +48,9 @@ public EFCorePerisistenceBuilder(IServiceCollection services) services.AddTransient(typeof(IWriteOnlyRepository<>), typeof(EFCoreRepository<>)); services.AddTransient(typeof(ILinqRepository<>), typeof(EFCoreRepository<>)); services.AddTransient(typeof(IGraphRepository<>), typeof(EFCoreRepository<>)); + services.AddTransient(typeof(IAggregateRepository<,>), typeof(EFCoreAggregateRepository<,>)); + services.AddTransient(typeof(IReadModelRepository<>), typeof(EFCoreReadModelRepository<>)); + services.AddScoped(typeof(ISagaStore<,>), typeof(EFCoreSagaStore<,>)); } /// diff --git a/Src/RCommon.EfCore/Inbox/EFCoreInboxStore.cs b/Src/RCommon.EfCore/Inbox/EFCoreInboxStore.cs new file mode 100644 index 00000000..5f3fecad --- /dev/null +++ b/Src/RCommon.EfCore/Inbox/EFCoreInboxStore.cs @@ -0,0 +1,65 @@ +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; +using RCommon.Persistence.EFCore; +using RCommon.Persistence.Inbox; +using RCommon.Persistence.Outbox; + +namespace RCommon.Persistence.EFCore.Inbox; + +public class EFCoreInboxStore : IInboxStore +{ + private readonly IDataStoreFactory _dataStoreFactory; + private readonly string _dataStoreName; + + public EFCoreInboxStore( + IDataStoreFactory dataStoreFactory, + IOptions defaultDataStoreOptions, + IOptions outboxOptions) + { + _dataStoreFactory = dataStoreFactory ?? throw new ArgumentNullException(nameof(dataStoreFactory)); + _dataStoreName = defaultDataStoreOptions?.Value?.DefaultDataStoreName + ?? throw new ArgumentNullException(nameof(defaultDataStoreOptions)); + } + + private RCommonDbContext DbContext => _dataStoreFactory.Resolve(_dataStoreName); + + public async Task ExistsAsync(Guid messageId, string? consumerType = null, CancellationToken cancellationToken = default) + { + var ct = consumerType ?? ""; + return await DbContext.Set() + .AnyAsync(m => m.MessageId == messageId && m.ConsumerType == ct, cancellationToken) + .ConfigureAwait(false); + } + + public async Task RecordAsync(IInboxMessage message, CancellationToken cancellationToken = default) + { + var entity = new InboxMessage + { + MessageId = message.MessageId, + EventType = message.EventType, + ConsumerType = message.ConsumerType ?? "", + ReceivedAtUtc = message.ReceivedAtUtc + }; + + DbContext.Set().Add(entity); + await DbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + } + + public async Task CleanupAsync(TimeSpan olderThan, CancellationToken cancellationToken = default) + { + var cutoff = DateTimeOffset.UtcNow - olderThan; + // DateTimeOffset comparisons cannot be translated by all EF Core providers (e.g. SQLite). + // Load all candidates and apply the filter client-side. + var all = await DbContext.Set() + .ToListAsync(cancellationToken).ConfigureAwait(false); + + var old = all.Where(m => m.ReceivedAtUtc < cutoff).ToList(); + + DbContext.Set().RemoveRange(old); + await DbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + } +} diff --git a/Src/RCommon.EfCore/Inbox/InboxMessageConfiguration.cs b/Src/RCommon.EfCore/Inbox/InboxMessageConfiguration.cs new file mode 100644 index 00000000..ad9c8324 --- /dev/null +++ b/Src/RCommon.EfCore/Inbox/InboxMessageConfiguration.cs @@ -0,0 +1,30 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using RCommon.Persistence.Inbox; + +namespace RCommon.Persistence.EFCore.Inbox; + +public class InboxMessageConfiguration : IEntityTypeConfiguration +{ + private readonly string _tableName; + + public InboxMessageConfiguration(string tableName = "__InboxMessages") + { + _tableName = tableName; + } + + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable(_tableName); + builder.HasKey(x => new { x.MessageId, x.ConsumerType }); + builder.Property(x => x.ConsumerType) + .HasMaxLength(512) + .HasDefaultValue("") + .IsRequired(); + builder.Property(x => x.EventType).HasMaxLength(1024).IsRequired(); + builder.Property(x => x.ReceivedAtUtc).IsRequired(); + + builder.HasIndex(x => x.ReceivedAtUtc) + .HasDatabaseName("IX_InboxMessages_Cleanup"); + } +} diff --git a/Src/RCommon.EfCore/Outbox/EFCoreOutboxStore.cs b/Src/RCommon.EfCore/Outbox/EFCoreOutboxStore.cs new file mode 100644 index 00000000..6c62e37c --- /dev/null +++ b/Src/RCommon.EfCore/Outbox/EFCoreOutboxStore.cs @@ -0,0 +1,216 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; +using RCommon.Persistence.Outbox; + +namespace RCommon.Persistence.EFCore.Outbox; + +/// +/// An EF Core implementation of that persists outbox messages +/// using a resolved through the . +/// +public class EFCoreOutboxStore : IOutboxStore +{ + private readonly IDataStoreFactory _dataStoreFactory; + private readonly string _dataStoreName; + private readonly int _maxRetries; + private readonly string _tableName; + + /// + /// Initializes a new instance of . + /// + /// Factory used to resolve the for the configured data store. + /// Options specifying which data store to use when none is explicitly set. + /// Options configuring outbox behavior such as maximum retries. + /// Thrown when any required parameter is null. + public EFCoreOutboxStore( + IDataStoreFactory dataStoreFactory, + IOptions defaultDataStoreOptions, + IOptions outboxOptions) + { + _dataStoreFactory = dataStoreFactory ?? throw new ArgumentNullException(nameof(dataStoreFactory)); + _dataStoreName = defaultDataStoreOptions?.Value?.DefaultDataStoreName + ?? throw new ArgumentNullException(nameof(defaultDataStoreOptions)); + _maxRetries = outboxOptions?.Value?.MaxRetries ?? 5; + _tableName = outboxOptions?.Value?.TableName ?? "__OutboxMessages"; + } + + /// + /// Gets the for the configured data store, resolved through the . + /// + private RCommonDbContext DbContext => _dataStoreFactory.Resolve(_dataStoreName); + + /// + public async Task SaveAsync(IOutboxMessage message, CancellationToken cancellationToken = default) + { + var dbContext = DbContext; + if (message is OutboxMessage entity) + { + dbContext.Set().Add(entity); + } + else + { + dbContext.Set().Add(new OutboxMessage + { + Id = message.Id, + EventType = message.EventType, + EventPayload = message.EventPayload, + CreatedAtUtc = message.CreatedAtUtc, + ProcessedAtUtc = message.ProcessedAtUtc, + DeadLetteredAtUtc = message.DeadLetteredAtUtc, + ErrorMessage = message.ErrorMessage, + RetryCount = message.RetryCount, + CorrelationId = message.CorrelationId, + TenantId = message.TenantId, + NextRetryAtUtc = message.NextRetryAtUtc, + LockedByInstanceId = message.LockedByInstanceId, + LockedUntilUtc = message.LockedUntilUtc + }); + } + await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + } + + /// + public async Task> ClaimAsync(string instanceId, int batchSize, TimeSpan lockDuration, CancellationToken cancellationToken = default) + { + var dbContext = DbContext; + var now = DateTimeOffset.UtcNow; + var lockUntil = now + lockDuration; + + // For SQL Server and PostgreSQL, raw SQL would be used here (CTE + OUTPUT / FOR UPDATE SKIP LOCKED). + // For SQLite and other providers, use a LINQ-based fallback (not safe for concurrent production use). + var maxRetries = _maxRetries; + // Broad server-side filter on non-nullable fields + simple nullable null checks. + // Nullable DateTimeOffset comparisons (e.g. <= now) are evaluated client-side + // since SQLite EF Core provider cannot translate Nullable comparisons. + var candidates = await dbContext.Set() + .Where(m => m.ProcessedAtUtc == null + && m.DeadLetteredAtUtc == null + && m.RetryCount < maxRetries) + .ToListAsync(cancellationToken).ConfigureAwait(false); + + var pending = candidates + .Where(m => (m.NextRetryAtUtc == null || m.NextRetryAtUtc <= now) + && (m.LockedUntilUtc == null || m.LockedUntilUtc <= now)) + .OrderBy(m => m.CreatedAtUtc) + .Take(batchSize) + .ToList(); + + foreach (var m in pending) + { + m.LockedByInstanceId = instanceId; + m.LockedUntilUtc = lockUntil; + } + await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + + return pending; + } + + /// + public async Task MarkProcessedAsync(Guid messageId, CancellationToken cancellationToken = default) + { + var dbContext = DbContext; + var message = await dbContext.Set() + .FindAsync(new object[] { messageId }, cancellationToken).ConfigureAwait(false); + if (message != null) + { + message.ProcessedAtUtc = DateTimeOffset.UtcNow; + await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + } + } + + /// + public async Task MarkFailedAsync(Guid messageId, string error, DateTimeOffset nextRetryAtUtc, CancellationToken cancellationToken = default) + { + var dbContext = DbContext; + var message = await dbContext.Set() + .FindAsync(new object[] { messageId }, cancellationToken).ConfigureAwait(false); + if (message != null) + { + message.ErrorMessage = error; + message.RetryCount++; + message.NextRetryAtUtc = nextRetryAtUtc; + message.LockedByInstanceId = null; + message.LockedUntilUtc = null; + await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + } + } + + /// + public async Task MarkDeadLetteredAsync(Guid messageId, CancellationToken cancellationToken = default) + { + var dbContext = DbContext; + var message = await dbContext.Set() + .FindAsync(new object[] { messageId }, cancellationToken).ConfigureAwait(false); + if (message != null) + { + message.DeadLetteredAtUtc = DateTimeOffset.UtcNow; + await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + } + } + + /// + public async Task> GetDeadLettersAsync(int batchSize, int offset = 0, CancellationToken cancellationToken = default) + { + var results = await DbContext.Set() + .Where(m => m.DeadLetteredAtUtc != null) + .ToListAsync(cancellationToken).ConfigureAwait(false); + + return results + .OrderByDescending(m => m.DeadLetteredAtUtc) + .Skip(offset) + .Take(batchSize) + .ToList(); + } + + /// + public async Task ReplayDeadLetterAsync(Guid messageId, CancellationToken cancellationToken = default) + { + var dbContext = DbContext; + var message = await dbContext.Set() + .FirstOrDefaultAsync(m => m.Id == messageId, cancellationToken).ConfigureAwait(false); + + if (message == null || message.DeadLetteredAtUtc == null) + { + throw new InvalidOperationException($"Message {messageId} does not exist or is not dead-lettered."); + } + + message.DeadLetteredAtUtc = null; + message.ProcessedAtUtc = null; + message.ErrorMessage = null; + message.RetryCount = 0; + message.NextRetryAtUtc = null; + message.LockedByInstanceId = null; + message.LockedUntilUtc = null; + + await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + } + + /// + public async Task DeleteProcessedAsync(TimeSpan olderThan, CancellationToken cancellationToken = default) + { + var dbContext = DbContext; + var cutoff = DateTimeOffset.UtcNow - olderThan; + var old = await dbContext.Set() + .Where(m => m.ProcessedAtUtc != null && m.ProcessedAtUtc < cutoff) + .ToListAsync(cancellationToken).ConfigureAwait(false); + dbContext.Set().RemoveRange(old); + await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + } + + /// + public async Task DeleteDeadLetteredAsync(TimeSpan olderThan, CancellationToken cancellationToken = default) + { + var dbContext = DbContext; + var cutoff = DateTimeOffset.UtcNow - olderThan; + var old = await dbContext.Set() + .Where(m => m.DeadLetteredAtUtc != null && m.DeadLetteredAtUtc < cutoff) + .ToListAsync(cancellationToken).ConfigureAwait(false); + dbContext.Set().RemoveRange(old); + await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + } +} diff --git a/Src/RCommon.EfCore/Outbox/ModelBuilderExtensions.cs b/Src/RCommon.EfCore/Outbox/ModelBuilderExtensions.cs new file mode 100644 index 00000000..7d1ed713 --- /dev/null +++ b/Src/RCommon.EfCore/Outbox/ModelBuilderExtensions.cs @@ -0,0 +1,18 @@ +using Microsoft.EntityFrameworkCore; + +namespace RCommon.Persistence.EFCore.Outbox; + +public static class ModelBuilderExtensions +{ + public static ModelBuilder AddOutboxMessages(this ModelBuilder modelBuilder, string tableName = "__OutboxMessages") + { + modelBuilder.ApplyConfiguration(new OutboxMessageConfiguration(tableName)); + return modelBuilder; + } + + public static ModelBuilder AddInboxMessages(this ModelBuilder modelBuilder, string tableName = "__InboxMessages") + { + modelBuilder.ApplyConfiguration(new RCommon.Persistence.EFCore.Inbox.InboxMessageConfiguration(tableName)); + return modelBuilder; + } +} diff --git a/Src/RCommon.EfCore/Outbox/OutboxMessageConfiguration.cs b/Src/RCommon.EfCore/Outbox/OutboxMessageConfiguration.cs new file mode 100644 index 00000000..54f54ae5 --- /dev/null +++ b/Src/RCommon.EfCore/Outbox/OutboxMessageConfiguration.cs @@ -0,0 +1,36 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using RCommon.Persistence.Outbox; + +namespace RCommon.Persistence.EFCore.Outbox; + +public class OutboxMessageConfiguration : IEntityTypeConfiguration +{ + private readonly string _tableName; + + public OutboxMessageConfiguration(string tableName = "__OutboxMessages") + { + _tableName = tableName; + } + + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable(_tableName); + builder.HasKey(x => x.Id); + builder.Property(x => x.EventType).HasMaxLength(1024).IsRequired(); + builder.Property(x => x.EventPayload).IsRequired(); + builder.Property(x => x.CreatedAtUtc).IsRequired(); + builder.Property(x => x.CorrelationId).HasMaxLength(256); + builder.Property(x => x.TenantId).HasMaxLength(256); + builder.Property(x => x.NextRetryAtUtc); + builder.Property(x => x.LockedByInstanceId).HasMaxLength(64); + builder.Property(x => x.LockedUntilUtc); + + builder.HasIndex(x => new { x.ProcessedAtUtc, x.DeadLetteredAtUtc, x.NextRetryAtUtc, x.LockedUntilUtc, x.CreatedAtUtc }) + .HasDatabaseName("IX_OutboxMessages_Pending"); + + builder.HasIndex(x => x.DeadLetteredAtUtc) + .HasDatabaseName("IX_OutboxMessages_DeadLettered") + .HasFilter("[DeadLetteredAtUtc] IS NOT NULL"); + } +} diff --git a/Src/RCommon.EfCore/Sagas/EFCoreSagaStore.cs b/Src/RCommon.EfCore/Sagas/EFCoreSagaStore.cs new file mode 100644 index 00000000..4122fa7d --- /dev/null +++ b/Src/RCommon.EfCore/Sagas/EFCoreSagaStore.cs @@ -0,0 +1,129 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using RCommon.Persistence.Sagas; + +namespace RCommon.Persistence.EFCore.Sagas; + +/// +/// An EF Core implementation of that persists saga state +/// using a resolved through the . +/// +/// The saga state type. Must derive from . +/// The primary key type. Must implement . +public class EFCoreSagaStore : ISagaStore + where TState : SagaState + where TKey : IEquatable +{ + private readonly IDataStoreFactory _dataStoreFactory; + private readonly ILogger _logger; + private string _dataStoreName; + + /// + /// Initializes a new instance of . + /// + /// Factory used to resolve the for the configured data store. + /// Factory for creating loggers scoped to this store type. + /// Options specifying which data store to use when none is explicitly set. + /// Thrown when any required parameter is null. + public EFCoreSagaStore( + IDataStoreFactory dataStoreFactory, + ILoggerFactory loggerFactory, + IOptions defaultDataStoreOptions) + { + _dataStoreFactory = dataStoreFactory ?? throw new ArgumentNullException(nameof(dataStoreFactory)); + _logger = loggerFactory?.CreateLogger(GetType().Name) ?? throw new ArgumentNullException(nameof(loggerFactory)); + + if (defaultDataStoreOptions is null) + throw new ArgumentNullException(nameof(defaultDataStoreOptions)); + + _dataStoreName = defaultDataStoreOptions.Value?.DefaultDataStoreName ?? string.Empty; + } + + /// + /// Gets the for the configured data store, resolved through the . + /// + private RCommonDbContext ObjectContext + => _dataStoreFactory.Resolve(_dataStoreName); + + /// + /// Gets the from the current for direct entity set operations. + /// + private DbSet ObjectSet + => ObjectContext.Set(); + + /// + public async Task GetByIdAsync(TKey id, CancellationToken ct = default) + { + try + { + return await ObjectSet.FindAsync(new object[] { id }, ct).ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error in {StoreType}.GetByIdAsync while executing on the DbContext.", GetType().FullName); + throw; + } + } + + /// + public async Task FindByCorrelationIdAsync(string correlationId, CancellationToken ct = default) + { + try + { + return await ObjectSet + .FirstOrDefaultAsync(s => s.CorrelationId == correlationId, ct) + .ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error in {StoreType}.FindByCorrelationIdAsync while executing on the DbContext.", GetType().FullName); + throw; + } + } + + /// + public async Task SaveAsync(TState state, CancellationToken ct = default) + { + try + { + var context = ObjectContext; + var existing = await context.Set().FindAsync(new object[] { state.Id }, ct).ConfigureAwait(false); + + if (existing == null) + { + await context.Set().AddAsync(state, ct).ConfigureAwait(false); + } + else + { + context.Entry(existing).CurrentValues.SetValues(state); + } + + await context.SaveChangesAsync(true, ct).ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error in {StoreType}.SaveAsync while executing on the DbContext.", GetType().FullName); + throw; + } + } + + /// + public async Task DeleteAsync(TState state, CancellationToken ct = default) + { + try + { + var context = ObjectContext; + context.Set().Remove(state); + await context.SaveChangesAsync(true, ct).ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error in {StoreType}.DeleteAsync while executing on the DbContext.", GetType().FullName); + throw; + } + } +} diff --git a/Src/RCommon.Emailing/IEmailService.cs b/Src/RCommon.Emailing/IEmailService.cs index 3ffe059a..436f4048 100644 --- a/Src/RCommon.Emailing/IEmailService.cs +++ b/Src/RCommon.Emailing/IEmailService.cs @@ -1,5 +1,6 @@ using System; using System.Net.Mail; +using System.Threading; using System.Threading.Tasks; namespace RCommon.Emailing @@ -24,7 +25,8 @@ public interface IEmailService /// Sends the specified asynchronously. /// /// The to send. + /// Optional cancellation token. /// A representing the asynchronous send operation. - Task SendEmailAsync(MailMessage message); + Task SendEmailAsync(MailMessage message, CancellationToken cancellationToken = default); } } diff --git a/Src/RCommon.Emailing/Smtp/SmtpEmailService.cs b/Src/RCommon.Emailing/Smtp/SmtpEmailService.cs index 96fa8342..e4b7a9ce 100644 --- a/Src/RCommon.Emailing/Smtp/SmtpEmailService.cs +++ b/Src/RCommon.Emailing/Smtp/SmtpEmailService.cs @@ -64,10 +64,10 @@ public void SendEmail(MailMessage message) /// Sends the mail message asynchronously in another thread. /// /// The message to send. - public async Task SendEmailAsync(MailMessage message) + public async Task SendEmailAsync(MailMessage message, CancellationToken cancellationToken = default) { - - await Task.Run(() => SendEmail(message)); + + await Task.Run(() => SendEmail(message)).ConfigureAwait(false); } /// diff --git a/Src/RCommon.Entities/AggregateRoot.cs b/Src/RCommon.Entities/AggregateRoot.cs new file mode 100644 index 00000000..287f664c --- /dev/null +++ b/Src/RCommon.Entities/AggregateRoot.cs @@ -0,0 +1,81 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace RCommon.Entities +{ + /// + /// Abstract base class for aggregate roots. Extends BusinessEntity to reuse event tracking, + /// key support, and entity equality. Adds versioning for optimistic concurrency and typed + /// domain event methods. + /// + /// The type of the aggregate's identity. + [Serializable] + public abstract class AggregateRoot : BusinessEntity, IAggregateRoot + where TKey : IEquatable + { + private readonly List _domainEvents = new(); + + /// + /// Initializes a new instance of with a default key. + /// + protected AggregateRoot() : base() { } + + /// + /// Initializes a new instance of with the specified key. + /// + /// The primary key value for this aggregate root. + protected AggregateRoot(TKey id) : base(id) { } + + /// + /// Version number for optimistic concurrency control. Incremented via . + /// Decorated with [ConcurrencyCheck] to signal ORM-level concurrency checking. + /// + [ConcurrencyCheck] + public virtual int Version { get; protected set; } + + /// + /// Returns the domain events that have been raised by this aggregate but not yet dispatched. + /// + [NotMapped] + public IReadOnlyCollection DomainEvents + => _domainEvents.AsReadOnly(); + + /// + /// Raises a domain event on this aggregate. The event is added to both the DomainEvents + /// collection and the base LocalEvents collection for dispatch via the event tracking pipeline. + /// + protected void AddDomainEvent(IDomainEvent domainEvent) + { + _domainEvents.Add(domainEvent); + AddLocalEvent(domainEvent); + } + + /// + /// Removes a previously raised domain event before it has been dispatched. + /// + protected void RemoveDomainEvent(IDomainEvent domainEvent) + { + _domainEvents.Remove(domainEvent); + RemoveLocalEvent(domainEvent); + } + + /// + /// Clears all pending domain events from this aggregate. + /// + public void ClearDomainEvents() + { + _domainEvents.Clear(); + ClearLocalEvents(); + } + + /// + /// Increments the version number for optimistic concurrency control. + /// Call this when the aggregate's state changes. + /// Note: This is not thread-safe. Aggregates are designed for single-threaded access. + /// + protected void IncrementVersion() + => Version++; + } +} diff --git a/Src/RCommon.Entities/DomainEntity.cs b/Src/RCommon.Entities/DomainEntity.cs new file mode 100644 index 00000000..7b4a97cf --- /dev/null +++ b/Src/RCommon.Entities/DomainEntity.cs @@ -0,0 +1,68 @@ +using System; + +namespace RCommon.Entities +{ + /// + /// Abstract base class for domain entities within an aggregate. Provides identity-based equality + /// but no event tracking — entities within an aggregate raise events through their aggregate root. + /// Because DomainEntity does not implement IBusinessEntity, the ObjectGraphWalker in + /// InMemoryEntityEventTracker will not traverse it. All domain events must be raised on the + /// aggregate root. + /// + /// The type of the entity's identity. + [Serializable] + public abstract class DomainEntity : IEquatable> + where TKey : IEquatable + { + /// + /// The unique identity of this entity. + /// + public virtual TKey Id { get; protected set; } = default!; + + public bool Equals(DomainEntity? other) + { + if (other is null) + return false; + + if (ReferenceEquals(this, other)) + return true; + + if (GetType() != other.GetType()) + return false; + + if (IsTransient() || other.IsTransient()) + return false; + + return Id.Equals(other.Id); + } + + public override bool Equals(object? obj) + => Equals(obj as DomainEntity); + + public override int GetHashCode() + { + var id = Id; + if (id is null || id.Equals(default(TKey))) + return base.GetHashCode(); + return id.GetHashCode(); + } + + /// + /// Returns true if this entity has not yet been assigned a persistent identity. + /// + public bool IsTransient() + => Id is null || Id.Equals(default); + + public static bool operator ==(DomainEntity? left, DomainEntity? right) + { + if (left is null && right is null) + return true; + if (left is null || right is null) + return false; + return left.Equals(right); + } + + public static bool operator !=(DomainEntity? left, DomainEntity? right) + => !(left == right); + } +} diff --git a/Src/RCommon.Entities/DomainEvent.cs b/Src/RCommon.Entities/DomainEvent.cs new file mode 100644 index 00000000..1823a637 --- /dev/null +++ b/Src/RCommon.Entities/DomainEvent.cs @@ -0,0 +1,14 @@ +using System; + +namespace RCommon.Entities +{ + /// + /// Abstract base record for domain events. Provides default values for EventId and OccurredOn. + /// Use as a base for all concrete domain events. + /// + public abstract record DomainEvent : IDomainEvent + { + public Guid EventId { get; init; } = Guid.NewGuid(); + public DateTimeOffset OccurredOn { get; init; } = DateTimeOffset.UtcNow; + } +} diff --git a/Src/RCommon.Entities/IAggregateRoot.cs b/Src/RCommon.Entities/IAggregateRoot.cs new file mode 100644 index 00000000..a4922c78 --- /dev/null +++ b/Src/RCommon.Entities/IAggregateRoot.cs @@ -0,0 +1,33 @@ +using System; +using System.Collections.Generic; + +namespace RCommon.Entities +{ + /// + /// Non-generic marker interface for aggregate roots. + /// Useful for infrastructure scenarios such as repository filtering, middleware, and generic constraints. + /// + public interface IAggregateRoot : IBusinessEntity + { + /// + /// The version number used for optimistic concurrency control. + /// + int Version { get; } + + /// + /// The collection of domain events raised by this aggregate that have not yet been dispatched. + /// + IReadOnlyCollection DomainEvents { get; } + } + + /// + /// Generic interface for aggregate roots in the domain model. + /// Extends IBusinessEntity to maintain compatibility with existing repository and event tracking infrastructure. + /// Note: The IEquatable constraint is stricter than IBusinessEntity<TKey> — this is intentional + /// because aggregate roots require identity equality for consistency guarantees. + /// + public interface IAggregateRoot : IAggregateRoot, IBusinessEntity + where TKey : IEquatable + { + } +} diff --git a/Src/RCommon.Entities/IDomainEvent.cs b/Src/RCommon.Entities/IDomainEvent.cs new file mode 100644 index 00000000..3fc71e1b --- /dev/null +++ b/Src/RCommon.Entities/IDomainEvent.cs @@ -0,0 +1,22 @@ +using System; +using RCommon.Models.Events; + +namespace RCommon.Entities +{ + /// + /// Represents a domain event raised by an aggregate root. + /// Extends ISerializableEvent for compatibility with the existing event routing pipeline. + /// + public interface IDomainEvent : ISerializableEvent + { + /// + /// Unique identifier for this event instance. + /// + Guid EventId { get; } + + /// + /// The date and time when this event occurred. + /// + DateTimeOffset OccurredOn { get; } + } +} diff --git a/Src/RCommon.Entities/IEntityEventTracker.cs b/Src/RCommon.Entities/IEntityEventTracker.cs index a8e822b8..6f4357b6 100644 --- a/Src/RCommon.Entities/IEntityEventTracker.cs +++ b/Src/RCommon.Entities/IEntityEventTracker.cs @@ -1,5 +1,6 @@ using RCommon.Entities; using System.Collections.Generic; +using System.Threading; using System.Threading.Tasks; namespace RCommon.Entities @@ -15,17 +16,25 @@ public interface IEntityEventTracker /// The collection of entities that each may store a collection of events. /// ICollection TrackedEntities { get; } - + /// /// Adds an entity that can be tracked for any new events associated with it. /// /// The business entity to track for transactional events. void AddEntity(IBusinessEntity entity); + /// + /// Persists domain events to the outbox (or equivalent durable store) within the active + /// transaction, before the transaction is committed. The in-memory implementation is a no-op. + /// + /// A token to observe for cancellation requests. + Task PersistEventsAsync(CancellationToken cancellationToken = default); + /// /// Publishes the events associated with each entity being tracked. /// + /// A token to observe for cancellation requests. /// True if successful - Task EmitTransactionalEventsAsync(); + Task EmitTransactionalEventsAsync(CancellationToken cancellationToken = default); } } diff --git a/Src/RCommon.Entities/InMemoryEntityEventTracker.cs b/Src/RCommon.Entities/InMemoryEntityEventTracker.cs index 9cd1db0d..b87501fa 100644 --- a/Src/RCommon.Entities/InMemoryEntityEventTracker.cs +++ b/Src/RCommon.Entities/InMemoryEntityEventTracker.cs @@ -4,6 +4,7 @@ using System.Data.SqlTypes; using System.Linq; using System.Text; +using System.Threading; using System.Threading.Tasks; namespace RCommon.Entities @@ -49,12 +50,20 @@ public void AddEntity(IBusinessEntity entity) /// public ICollection TrackedEntities { get => _businessEntities; } + /// + /// + /// The in-memory implementation is a no-op. The transactional outbox decorator + /// (OutboxEntityEventTracker) overrides this to persist events within the active + /// transaction before it is committed. + /// + public Task PersistEventsAsync(CancellationToken cancellationToken = default) => Task.CompletedTask; + /// /// /// Traverses the object graph of each tracked entity to discover nested /// instances, collects their local events, and routes all events through the . /// - public async Task EmitTransactionalEventsAsync() + public async Task EmitTransactionalEventsAsync(CancellationToken cancellationToken = default) { // Walk each tracked root entity and traverse its object graph for nested IBusinessEntity instances foreach (var entity in this._businessEntities) @@ -67,8 +76,8 @@ public async Task EmitTransactionalEventsAsync() _eventRouter.AddTransactionalEvents(graphEntity.LocalEvents); } } - await _eventRouter.RouteEventsAsync(); - return await Task.FromResult(true); + await _eventRouter.RouteEventsAsync(cancellationToken).ConfigureAwait(false); + return true; } } } diff --git a/Src/RCommon.Entities/ValueObject.cs b/Src/RCommon.Entities/ValueObject.cs new file mode 100644 index 00000000..8411b6a3 --- /dev/null +++ b/Src/RCommon.Entities/ValueObject.cs @@ -0,0 +1,41 @@ +namespace RCommon.Entities +{ + /// + /// Abstract base record for value objects. Leverages C# record semantics for automatic + /// structural equality, immutability, and with-expression support. + /// + /// Derive concrete value objects from this type: + /// + /// public record Money(decimal Amount, string Currency) : ValueObject; + /// public record Address(string Street, string City, string ZipCode) : ValueObject; + /// + /// + public abstract record ValueObject; + + /// + /// Abstract base record for single-value wrapper value objects. Provides a typed + /// property and implicit conversions to/from . + /// + /// The type of the wrapped value. + /// + /// + /// public record EmailAddress(string Value) : ValueObject<string>(Value); + /// public record CustomerId(Guid Value) : ValueObject<Guid>(Value); + /// + /// EmailAddress email = "user@example.com"; // implicit from string + /// string raw = email; // implicit to string + /// + /// + public abstract record ValueObject(T Value) : ValueObject + where T : notnull + { + /// + /// Implicitly converts a to its underlying value. + /// + public static implicit operator T(ValueObject valueObject) + => valueObject.Value; + + /// + public sealed override string ToString() => Value.ToString() ?? string.Empty; + } +} diff --git a/Src/RCommon.FluentValidation/FluentValidationProvider.cs b/Src/RCommon.FluentValidation/FluentValidationProvider.cs index 364820d2..44625cd7 100644 --- a/Src/RCommon.FluentValidation/FluentValidationProvider.cs +++ b/Src/RCommon.FluentValidation/FluentValidationProvider.cs @@ -65,7 +65,7 @@ public async Task ValidateAsync(T target, bool throwOnFaul Guard.IsNotNull(untypedValidators, nameof(untypedValidators)); - var validationResults = await ExecuteValidationAsync(target, untypedValidators!, cancellationToken); // TODO: Need a better way than passing in object[] + var validationResults = await ExecuteValidationAsync(target, untypedValidators!, cancellationToken).ConfigureAwait(false); // TODO: Need a better way than passing in object[] // Flatten all validation errors from all validators into a single list var failures = validationResults.SelectMany(r => r.Errors).Where(f => f != null).ToList(); @@ -110,7 +110,7 @@ private async Task ExecuteValidationAsync(T target, IEnum var context = new ValidationContext(target); // Run all validators in parallel via Task.WhenAll, casting each to the non-generic IValidator interface - var validationResults = await Task.WhenAll(validators.Select(v => ((IValidator)v).ValidateAsync(context, cancellationToken))); + var validationResults = await Task.WhenAll(validators.Select(v => ((IValidator)v).ValidateAsync(context, cancellationToken))).ConfigureAwait(false); return validationResults; } else diff --git a/Src/RCommon.Linq2Db/Crud/Linq2DbAggregateRepository.cs b/Src/RCommon.Linq2Db/Crud/Linq2DbAggregateRepository.cs new file mode 100644 index 00000000..83d2f67e --- /dev/null +++ b/Src/RCommon.Linq2Db/Crud/Linq2DbAggregateRepository.cs @@ -0,0 +1,574 @@ +using LinqToDB; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using RCommon.Entities; +using RCommon.Security.Claims; +using RCommon.Collections; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Linq.Expressions; +using System.Threading; +using System.Threading.Tasks; +using LinqToDB.Tools; +using LinqToDB.Data; +using RCommon; +using RCommon.Persistence.Crud; +using RCommon.Persistence.Transactions; +using LinqToDB.Linq; +using LinqToDB.Async; + +namespace RCommon.Persistence.Linq2Db.Crud +{ + /// + /// A DDD-constrained repository for aggregate roots backed by Linq2Db. + /// Inherits full LINQ repository infrastructure from + /// and exposes the narrow contract for + /// aggregate-appropriate operations only. + /// + /// The aggregate root type. Must implement . + /// The type of the aggregate's identity key. + public class Linq2DbAggregateRepository : LinqRepositoryBase, IAggregateRepository + where TAggregate : class, IAggregateRoot + where TKey : IEquatable + { + private IQueryable? _repositoryQuery; + private ILoadWithQueryable? _includableQueryable; + private readonly IDataStoreFactory _dataStoreFactory; + + /// + /// Initializes a new instance of . + /// + /// Factory used to resolve the for the configured data store. + /// Factory for creating loggers scoped to this repository type. + /// Tracker used to register entities for domain event dispatching. + /// Options specifying which data store to use when none is explicitly set. + /// Accessor for the current tenant identifier. + /// Thrown when any parameter is null. + public Linq2DbAggregateRepository(IDataStoreFactory dataStoreFactory, + ILoggerFactory logger, IEntityEventTracker eventTracker, + IOptions defaultDataStoreOptions, + ITenantIdAccessor tenantIdAccessor) + : base(dataStoreFactory, eventTracker, defaultDataStoreOptions, tenantIdAccessor) + { + if (logger is null) + { + throw new ArgumentNullException(nameof(logger)); + } + + if (eventTracker is null) + { + throw new ArgumentNullException(nameof(eventTracker)); + } + + if (defaultDataStoreOptions is null) + { + throw new ArgumentNullException(nameof(defaultDataStoreOptions)); + } + + Logger = logger.CreateLogger(GetType().Name); + _repositoryQuery = null; + _includableQueryable = null; + _dataStoreFactory = dataStoreFactory ?? throw new ArgumentNullException(nameof(dataStoreFactory)); + } + + /// + /// Gets the for the configured data store, resolved through the . + /// + protected internal RCommonDataConnection DataConnection + { + get + { + return this._dataStoreFactory.Resolve(this.DataStoreName); + } + } + + /// + /// Gets the Linq2Db from the current for direct table operations. + /// + protected ITable Table + { + get + { + return DataConnection.GetTable(); + } + } + + /// + /// Adds an eager-loading path for the specified navigation property using Linq2Db's LoadWith API. + /// + /// An expression selecting the navigation property to include. + /// This repository instance for fluent chaining of additional includes. + public override IEagerLoadableQueryable Include(Expression> path) + { + _includableQueryable = RepositoryQuery.LoadWith(path!); + return this; + } + + /// + /// Adds a subsequent eager-loading path for a nested navigation property after a prior call, + /// using Linq2Db's ThenLoad API. + /// + /// The type of the previously included navigation property. + /// The type of the nested navigation property to include. + /// An expression selecting the nested navigation property to include. + /// This repository instance for fluent chaining. + public override IEagerLoadableQueryable ThenInclude(Expression> path) + { + _repositoryQuery = _includableQueryable!.ThenLoad(path!); + return this; + } + + /// + /// Gets the base used for all query operations. + /// Applies eager-loading expressions if any have been configured via . + /// + protected override IQueryable RepositoryQuery + { + get + { + if (_repositoryQuery == null) + { + _repositoryQuery = Table.AsQueryable(); + } + + // Override the base query with the eager-loaded queryable if includes have been configured + if (_includableQueryable != null) + { + _repositoryQuery = _includableQueryable; + } + return _repositoryQuery; + } + } + + /// + /// Core query method that applies the given filter expression to the . + /// All find operations delegate to this method to build the filtered queryable. + /// + /// A predicate expression to filter entities. + /// An representing the filtered query. + /// Thrown when is null. + private IQueryable FindCore(Expression> expression) + { + IQueryable queryable; + try + { + Guard.Against(FilteredRepositoryQuery == null, "RepositoryQuery is null"); + queryable = FilteredRepositoryQuery.Where(expression); + } + catch (ApplicationException exception) + { + Logger.LogError(exception, "Error in {0}.FindCore while executing a query on the Context.", GetType().FullName); + throw; + } + return queryable; + } + + /// + public async override Task AddAsync(TAggregate entity, CancellationToken token = default) + { + EventTracker.AddEntity(entity); + MultiTenantHelper.SetTenantIdIfApplicable(entity, _tenantIdAccessor.GetTenantId()); + await DataConnection.InsertAsync(entity, token: token).ConfigureAwait(false); + } + + /// + public async override Task AnyAsync(Expression> expression, CancellationToken token = default) + { + return await FilteredRepositoryQuery.AnyAsync(expression, token: token).ConfigureAwait(false); + } + + /// + public async override Task AnyAsync(ISpecification specification, CancellationToken token = default) + { + return await AnyAsync(specification.Predicate, token: token).ConfigureAwait(false); + } + + /// + /// Deletes the aggregate. If implements , + /// a soft delete is performed automatically (sets IsDeleted = true and issues an UPDATE). + /// Otherwise a physical DELETE is executed. + /// + public async override Task DeleteAsync(TAggregate entity, CancellationToken token = default) + { + if (SoftDeleteHelper.IsSoftDeletable()) + { + SoftDeleteHelper.MarkAsDeleted(entity); + await UpdateAsync(entity, token).ConfigureAwait(false); + return; + } + + EventTracker.AddEntity(entity); + await DataConnection.DeleteAsync(entity, token: token).ConfigureAwait(false); + } + + /// + /// Deletes the aggregate using the explicitly specified delete mode. When + /// is true, the aggregate must implement ; its IsDeleted property + /// is set to true and an UPDATE is issued. When false, a physical DELETE is always + /// performed — even if the aggregate implements . + /// + /// + /// Thrown when is true but + /// does not implement . + /// + public async override Task DeleteAsync(TAggregate entity, bool isSoftDelete, CancellationToken token = default) + { + if (!isSoftDelete) + { + // Bypass auto-detection — force a physical delete + EventTracker.AddEntity(entity); + await DataConnection.DeleteAsync(entity, token: token).ConfigureAwait(false); + return; + } + + SoftDeleteHelper.EnsureSoftDeletable(); + SoftDeleteHelper.MarkAsDeleted(entity); + await UpdateAsync(entity, token).ConfigureAwait(false); + } + + /// + /// Deletes aggregates matching the expression. If implements + /// , a soft delete is performed automatically (marks each matching + /// entity as deleted and issues UPDATEs). Otherwise a physical DELETE is executed. + /// + public async override Task DeleteManyAsync(Expression> expression, CancellationToken token = default) + { + if (SoftDeleteHelper.IsSoftDeletable()) + { + return await DeleteManyAsync(expression, isSoftDelete: true, token).ConfigureAwait(false); + } + + return await RepositoryQuery.Where(expression).DeleteAsync(token).ConfigureAwait(false); + } + + /// + /// Deletes aggregates matching the expression. When is true, + /// each matching aggregate must implement — its IsDeleted property is + /// set to true and an UPDATE is issued instead of a DELETE. + /// + /// + /// The soft-delete path fetches matching entities into memory, marks each as deleted, then updates + /// them one by one via Linq2Db's UpdateAsync. + /// + /// + /// Thrown when is true but + /// does not implement . + /// + public async override Task DeleteManyAsync(Expression> expression, bool isSoftDelete, CancellationToken token = default) + { + if (!isSoftDelete) + { + // Bypass auto-detection and soft-delete filter — force a physical delete + return await RepositoryQuery.Where(expression).DeleteAsync(token).ConfigureAwait(false); + } + + SoftDeleteHelper.EnsureSoftDeletable(); + + var entities = await FindQuery(expression).ToListAsync(token).ConfigureAwait(false); + int count = 0; + foreach (var entity in entities) + { + SoftDeleteHelper.MarkAsDeleted(entity); + await DataConnection.UpdateAsync(entity, token: token).ConfigureAwait(false); + count++; + } + return count; + } + + /// + public async override Task DeleteManyAsync(ISpecification specification, CancellationToken token = default) + { + return await DeleteManyAsync(specification.Predicate, token).ConfigureAwait(false); + } + + /// + /// Deletes aggregates matching the specification. When is true, + /// each matching aggregate must implement — its IsDeleted property is + /// set to true and an UPDATE is issued instead of a DELETE. + /// + /// + /// Thrown when is true but + /// does not implement . + /// + public async override Task DeleteManyAsync(ISpecification specification, bool isSoftDelete, CancellationToken token = default) + { + return await DeleteManyAsync(specification.Predicate, isSoftDelete, token).ConfigureAwait(false); + } + + /// + public override IQueryable FindQuery(ISpecification specification) + { + return FindCore(specification.Predicate); + } + + /// + public override IQueryable FindQuery(Expression> expression) + { + return FindCore(expression); + } + + /// + /// This is not yet implemented due to Linq2Db's inability to find primary key or array of primary key. + /// + /// Value of Primary Key + /// Cancellation Token + /// + /// + public override async Task FindAsync(object primaryKey, CancellationToken token = default) + { + //TODO: implement FindAsync(object primaryKey) + throw new NotImplementedException(); + } + + /// + public async override Task> FindAsync(ISpecification specification, CancellationToken token = default) + { + return await FindCore(specification.Predicate).ToListAsync(token).ConfigureAwait(false); + } + + /// + public async override Task> FindAsync(Expression> expression, CancellationToken token = default) + { + return await FindCore(expression).ToListAsync(token).ConfigureAwait(false); + } + + /// + public async override Task> FindAsync(IPagedSpecification specification, CancellationToken token = default) + { + IQueryable query; + if (specification.OrderByAscending) + { + query = FindCore(specification.Predicate).OrderBy(specification.OrderByExpression); + } + else + { + query = FindCore(specification.Predicate).OrderByDescending(specification.OrderByExpression); + } + return await Task.FromResult(query.ToPaginatedList(specification.PageNumber, specification.PageSize)).ConfigureAwait(false); + } + + /// + public async override Task> FindAsync(Expression> expression, Expression> orderByExpression, + bool orderByAscending, int pageNumber = 1, int pageSize = 1, + CancellationToken token = default) + { + IQueryable query; + if (orderByAscending) + { + query = FindCore(expression).OrderBy(orderByExpression); + } + else + { + query = FindCore(expression).OrderByDescending(orderByExpression); + } + return await Task.FromResult(query.ToPaginatedList(pageNumber, pageSize)).ConfigureAwait(false); + } + + /// + public override IQueryable FindQuery(Expression> expression, Expression> orderByExpression, + bool orderByAscending, int pageNumber = 1, int pageSize = 0) + { + IQueryable query; + if (orderByAscending) + { + query = FindCore(expression).OrderBy(orderByExpression); + } + else + { + query = FindCore(expression).OrderByDescending(orderByExpression); + } + return query.Skip((pageNumber - 1) * pageSize).Take(pageSize); + } + + /// + public override IQueryable FindQuery(IPagedSpecification specification) + { + return this.FindQuery(specification.Predicate, specification.OrderByExpression, + specification.OrderByAscending, specification.PageNumber, specification.PageSize); + } + + /// + public override IQueryable FindQuery(Expression> expression, Expression> orderByExpression, + bool orderByAscending) + { + IQueryable query; + if (orderByAscending) + { + query = FindCore(expression).OrderBy(orderByExpression); + } + else + { + query = FindCore(expression).OrderByDescending(orderByExpression); + } + return query; + } + + /// + public async override Task FindSingleOrDefaultAsync(Expression> expression, CancellationToken token = default) + { + return (await FilteredRepositoryQuery.SingleOrDefaultAsync(expression, token).ConfigureAwait(false))!; + } + + /// + public async override Task FindSingleOrDefaultAsync(ISpecification specification, CancellationToken token = default) + { + return await FindSingleOrDefaultAsync(specification.Predicate, token).ConfigureAwait(false); + } + + /// + public async override Task GetCountAsync(ISpecification selectSpec, CancellationToken token = default) + { + return await GetCountAsync(selectSpec.Predicate, token).ConfigureAwait(false); + } + + /// + public async override Task GetCountAsync(Expression> expression, CancellationToken token = default) + { + return await FilteredRepositoryQuery.CountAsync(expression, token).ConfigureAwait(false); + } + + /// + public async override Task UpdateAsync(TAggregate entity, CancellationToken token = default) + { + EventTracker.AddEntity(entity); + await DataConnection.UpdateAsync(entity, token: token).ConfigureAwait(false); + } + + /// + /// Adds a range of transient aggregates to be persisted using Linq2Db. + /// Loops through the records and inserts them one by one. + /// + /// Collection of aggregates to persist. + /// Cancellation token. + public override async Task AddRangeAsync(IEnumerable entities, CancellationToken token = default) + { + if (entities == null) throw new ArgumentNullException(nameof(entities)); + + foreach (var entity in entities) + { + EventTracker.AddEntity(entity); + MultiTenantHelper.SetTenantIdIfApplicable(entity, _tenantIdAccessor.GetTenantId()); + await DataConnection.InsertAsync(entity, token: token).ConfigureAwait(false); + } + } + + // ────────────────────────────────────────────────────────────────────── + // Explicit IAggregateRepository implementations + // ────────────────────────────────────────────────────────────────────── + + /// + /// Loads an aggregate root by its identity key. + /// + async Task IAggregateRepository.GetByIdAsync(TKey id, CancellationToken cancellationToken) + { + return await FilteredRepositoryQuery.FirstOrDefaultAsync(e => e.Id.Equals(id), token: cancellationToken).ConfigureAwait(false); + } + + /// + /// Finds a single aggregate matching the given specification. + /// + async Task IAggregateRepository.FindAsync(ISpecification specification, CancellationToken cancellationToken) + { + return await FilteredRepositoryQuery.Where(specification.Predicate).FirstOrDefaultAsync(token: cancellationToken).ConfigureAwait(false); + } + + /// + /// Checks whether an aggregate with the given identity key exists. + /// + async Task IAggregateRepository.ExistsAsync(TKey id, CancellationToken cancellationToken) + { + return await FilteredRepositoryQuery.AnyAsync(e => e.Id.Equals(id), token: cancellationToken).ConfigureAwait(false); + } + + /// + /// Adds a new aggregate root to the repository and persists it. + /// + async Task IAggregateRepository.AddAsync(TAggregate aggregate, CancellationToken cancellationToken) + { + EventTracker.AddEntity(aggregate); + MultiTenantHelper.SetTenantIdIfApplicable(aggregate, _tenantIdAccessor.GetTenantId()); + await DataConnection.InsertAsync(aggregate, token: cancellationToken).ConfigureAwait(false); + } + + /// + /// Updates an existing aggregate root and persists the changes. + /// + async Task IAggregateRepository.UpdateAsync(TAggregate aggregate, CancellationToken cancellationToken) + { + EventTracker.AddEntity(aggregate); + await DataConnection.UpdateAsync(aggregate, token: cancellationToken).ConfigureAwait(false); + } + + /// + /// Deletes an aggregate root. If the aggregate implements , + /// a soft delete is performed automatically. + /// + async Task IAggregateRepository.DeleteAsync(TAggregate aggregate, CancellationToken cancellationToken) + { + if (SoftDeleteHelper.IsSoftDeletable()) + { + SoftDeleteHelper.MarkAsDeleted(aggregate); + EventTracker.AddEntity(aggregate); + await DataConnection.UpdateAsync(aggregate, token: cancellationToken).ConfigureAwait(false); + return; + } + + EventTracker.AddEntity(aggregate); + await DataConnection.DeleteAsync(aggregate, token: cancellationToken).ConfigureAwait(false); + } + + /// + /// Adds an eager-loading include path and returns the aggregate repository for fluent chaining. + /// + IAggregateRepository IAggregateRepository.Include(Expression> path) + { + // Convert to Expression> so it is compatible with the + // ILoadWithQueryable field used by the base Include logic. + var converted = Expression.Lambda>( + Expression.Convert(path.Body, typeof(object)), path.Parameters); + + _includableQueryable = RepositoryQuery.LoadWith(converted!); + + return this; + } + + /// + /// Adds a subsequent eager-loading path for a nested navigation property and returns the aggregate repository for fluent chaining. + /// + IAggregateRepository IAggregateRepository.ThenInclude(Expression> path) + { + // Rewrite the expression from Func to Func + // to match the ILoadWithQueryable field type. + var param = Expression.Parameter(typeof(object), path.Parameters[0].Name); + var castParam = Expression.Convert(param, typeof(TPreviousProperty)); + var body = new ParameterReplacingVisitor(path.Parameters[0], castParam).Visit(path.Body); + var converted = Expression.Lambda>(body, param); + + _repositoryQuery = _includableQueryable!.ThenLoad(converted!); + + return this; + } + + /// + /// A simple expression visitor that replaces a specific parameter expression with another expression. + /// Used to rewrite ThenInclude expressions for Linq2Db's ThenLoad API. + /// + private sealed class ParameterReplacingVisitor : ExpressionVisitor + { + private readonly ParameterExpression _oldParam; + private readonly Expression _newExpr; + + public ParameterReplacingVisitor(ParameterExpression oldParam, Expression newExpr) + { + _oldParam = oldParam; + _newExpr = newExpr; + } + + protected override Expression VisitParameter(ParameterExpression node) + { + return node == _oldParam ? _newExpr : base.VisitParameter(node); + } + } + } +} diff --git a/Src/RCommon.Linq2Db/Crud/Linq2DbReadModelRepository.cs b/Src/RCommon.Linq2Db/Crud/Linq2DbReadModelRepository.cs new file mode 100644 index 00000000..8ddbfe72 --- /dev/null +++ b/Src/RCommon.Linq2Db/Crud/Linq2DbReadModelRepository.cs @@ -0,0 +1,182 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Linq.Expressions; +using System.Threading; +using System.Threading.Tasks; +using LinqToDB; +using LinqToDB.Async; +using LinqToDB.Linq; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using RCommon.Models; +using RCommon.Persistence; +using RCommon.Persistence.Crud; + +namespace RCommon.Persistence.Linq2Db.Crud; + +/// +/// A read-model repository implementation using Linq2Db for query operations. +/// +/// +/// The read-model/projection type. Must implement and be a class. +/// +/// +/// Queries are built against from the underlying +/// . Read models do not participate in domain event tracking, +/// change tracking, soft-delete filtering, or multi-tenancy filtering. +/// +/// Eager loading via is supported through Linq2Db's +/// LoadWith API. +/// +public class Linq2DbReadModelRepository : IReadModelRepository + where TReadModel : class, IReadModel +{ + private readonly IDataStoreFactory _dataStoreFactory; + private readonly ILogger _logger; + private string _dataStoreName; + private IQueryable? _repositoryQuery; + private ILoadWithQueryable? _includableQueryable; + + /// + /// Initializes a new instance of . + /// + /// Factory used to resolve the for the configured data store. + /// Factory for creating loggers scoped to this repository type. + /// Options specifying which data store to use when none is explicitly set. + /// Thrown when any required parameter is null. + public Linq2DbReadModelRepository( + IDataStoreFactory dataStoreFactory, + ILoggerFactory loggerFactory, + IOptions defaultDataStoreOptions) + { + _dataStoreFactory = dataStoreFactory ?? throw new ArgumentNullException(nameof(dataStoreFactory)); + _logger = loggerFactory?.CreateLogger(GetType().Name) ?? throw new ArgumentNullException(nameof(loggerFactory)); + + if (defaultDataStoreOptions is null) + throw new ArgumentNullException(nameof(defaultDataStoreOptions)); + + _dataStoreName = defaultDataStoreOptions.Value?.DefaultDataStoreName ?? string.Empty; + } + + /// + /// Gets the for the configured data store, resolved through the . + /// + private RCommonDataConnection DataConnection + => _dataStoreFactory.Resolve(_dataStoreName); + + /// + /// Gets the Linq2Db from the current for direct table operations. + /// + private ITable ObjectSet + => DataConnection.GetTable(); + + /// + /// Gets the base used for all query operations. + /// Applies eager-loading expressions if any have been configured via . + /// + private IQueryable RepositoryQuery + { + get + { + if (_repositoryQuery == null) + { + _repositoryQuery = ObjectSet.AsQueryable(); + } + + // Override the base query with the eager-loaded queryable if includes have been configured + if (_includableQueryable != null) + { + _repositoryQuery = _includableQueryable; + } + + return _repositoryQuery; + } + } + + /// + public string DataStoreName + { + get => _dataStoreName; + set => _dataStoreName = value; + } + + /// + public async Task FindAsync( + ISpecification specification, + CancellationToken cancellationToken = default) + { + return await RepositoryQuery + .Where(specification.Predicate) + .FirstOrDefaultAsync(token: cancellationToken) + .ConfigureAwait(false); + } + + /// + public async Task> FindAllAsync( + ISpecification specification, + CancellationToken cancellationToken = default) + { + return await RepositoryQuery + .Where(specification.Predicate) + .ToListAsync(cancellationToken) + .ConfigureAwait(false); + } + + /// + public async Task> GetPagedAsync( + IPagedSpecification specification, + CancellationToken cancellationToken = default) + { + var query = RepositoryQuery.Where(specification.Predicate); + var totalCount = await query.LongCountAsync(cancellationToken).ConfigureAwait(false); + var items = await query + .Skip((specification.PageNumber - 1) * specification.PageSize) + .Take(specification.PageSize) + .ToListAsync(cancellationToken) + .ConfigureAwait(false); + + return new PagedResult(items, totalCount, specification.PageNumber, specification.PageSize); + } + + /// + public async Task GetCountAsync( + ISpecification specification, + CancellationToken cancellationToken = default) + { + return await RepositoryQuery + .Where(specification.Predicate) + .LongCountAsync(cancellationToken) + .ConfigureAwait(false); + } + + /// + public async Task AnyAsync( + ISpecification specification, + CancellationToken cancellationToken = default) + { + return await RepositoryQuery + .Where(specification.Predicate) + .AnyAsync(token: cancellationToken) + .ConfigureAwait(false); + } + + /// + /// Adds an eager-loading path for the specified navigation property using Linq2Db's LoadWith API. + /// + /// The type of the navigation property to include. + /// An expression selecting the navigation property to include. + /// This repository instance for fluent chaining of additional includes. + public IReadModelRepository Include( + Expression> path) + { + // Convert to Expression> so it is compatible with + // the ILoadWithQueryable field used by LoadWith. + var converted = Expression.Lambda>( + Expression.Convert(path.Body, typeof(object)), + path.Parameters); + + _includableQueryable = RepositoryQuery.LoadWith(converted!); + return this; + } +} diff --git a/Src/RCommon.Linq2Db/Crud/Linq2DbRepository.cs b/Src/RCommon.Linq2Db/Crud/Linq2DbRepository.cs index fdcc70a4..73b426ae 100644 --- a/Src/RCommon.Linq2Db/Crud/Linq2DbRepository.cs +++ b/Src/RCommon.Linq2Db/Crud/Linq2DbRepository.cs @@ -172,19 +172,19 @@ public async override Task AddAsync(TEntity entity, CancellationToken token = de { EventTracker.AddEntity(entity); MultiTenantHelper.SetTenantIdIfApplicable(entity, _tenantIdAccessor.GetTenantId()); - await DataConnection.InsertAsync(entity, token: token); + await DataConnection.InsertAsync(entity, token: token).ConfigureAwait(false); } /// public async override Task AnyAsync(Expression> expression, CancellationToken token = default) { - return await FilteredRepositoryQuery.AnyAsync(expression, token: token); + return await FilteredRepositoryQuery.AnyAsync(expression, token: token).ConfigureAwait(false); } /// public async override Task AnyAsync(ISpecification specification, CancellationToken token = default) { - return await AnyAsync(specification.Predicate, token: token); + return await AnyAsync(specification.Predicate, token: token).ConfigureAwait(false); } /// @@ -197,12 +197,12 @@ public async override Task DeleteAsync(TEntity entity, CancellationToken token = if (SoftDeleteHelper.IsSoftDeletable()) { SoftDeleteHelper.MarkAsDeleted(entity); - await UpdateAsync(entity, token); + await UpdateAsync(entity, token).ConfigureAwait(false); return; } EventTracker.AddEntity(entity); - await DataConnection.DeleteAsync(entity); + await DataConnection.DeleteAsync(entity, token: token).ConfigureAwait(false); } /// @@ -221,13 +221,13 @@ public async override Task DeleteAsync(TEntity entity, bool isSoftDelete, Cancel { // Bypass auto-detection — force a physical delete EventTracker.AddEntity(entity); - await DataConnection.DeleteAsync(entity); + await DataConnection.DeleteAsync(entity, token: token).ConfigureAwait(false); return; } SoftDeleteHelper.EnsureSoftDeletable(); SoftDeleteHelper.MarkAsDeleted(entity); - await UpdateAsync(entity, token); + await UpdateAsync(entity, token).ConfigureAwait(false); } /// @@ -239,10 +239,10 @@ public async override Task DeleteManyAsync(Expression> { if (SoftDeleteHelper.IsSoftDeletable()) { - return await DeleteManyAsync(expression, isSoftDelete: true, token); + return await DeleteManyAsync(expression, isSoftDelete: true, token).ConfigureAwait(false); } - return await RepositoryQuery.Where(expression).DeleteAsync(token); + return await RepositoryQuery.Where(expression).DeleteAsync(token).ConfigureAwait(false); } /// @@ -263,17 +263,17 @@ public async override Task DeleteManyAsync(Expression> if (!isSoftDelete) { // Bypass auto-detection and soft-delete filter — force a physical delete - return await RepositoryQuery.Where(expression).DeleteAsync(token); + return await RepositoryQuery.Where(expression).DeleteAsync(token).ConfigureAwait(false); } SoftDeleteHelper.EnsureSoftDeletable(); - var entities = await FindQuery(expression).ToListAsync(token); + var entities = await FindQuery(expression).ToListAsync(token).ConfigureAwait(false); int count = 0; foreach (var entity in entities) { SoftDeleteHelper.MarkAsDeleted(entity); - await DataConnection.UpdateAsync(entity, token: token); + await DataConnection.UpdateAsync(entity, token: token).ConfigureAwait(false); count++; } return count; @@ -282,7 +282,7 @@ public async override Task DeleteManyAsync(Expression> /// public async override Task DeleteManyAsync(ISpecification specification, CancellationToken token = default) { - return await DeleteManyAsync(specification.Predicate, token); + return await DeleteManyAsync(specification.Predicate, token).ConfigureAwait(false); } /// @@ -296,7 +296,7 @@ public async override Task DeleteManyAsync(ISpecification specific /// public async override Task DeleteManyAsync(ISpecification specification, bool isSoftDelete, CancellationToken token = default) { - return await DeleteManyAsync(specification.Predicate, isSoftDelete, token); + return await DeleteManyAsync(specification.Predicate, isSoftDelete, token).ConfigureAwait(false); } /// @@ -328,13 +328,13 @@ public override async Task FindAsync(object primaryKey, CancellationTok /// public async override Task> FindAsync(ISpecification specification, CancellationToken token = default) { - return await FindCore(specification.Predicate).ToListAsync(token); + return await FindCore(specification.Predicate).ToListAsync(token).ConfigureAwait(false); } /// public async override Task> FindAsync(Expression> expression, CancellationToken token = default) { - return await FindCore(expression).ToListAsync(token); + return await FindCore(expression).ToListAsync(token).ConfigureAwait(false); } /// @@ -349,7 +349,7 @@ public async override Task> FindAsync(IPagedSpecificatio { query = FindCore(specification.Predicate).OrderByDescending(specification.OrderByExpression); } - return await Task.FromResult(query.ToPaginatedList(specification.PageNumber, specification.PageSize)); + return await Task.FromResult(query.ToPaginatedList(specification.PageNumber, specification.PageSize)).ConfigureAwait(false); } /// @@ -366,7 +366,7 @@ public async override Task> FindAsync(Expression @@ -411,32 +411,32 @@ public override IQueryable FindQuery(Expression> ex /// public async override Task FindSingleOrDefaultAsync(Expression> expression, CancellationToken token = default) { - return (await FilteredRepositoryQuery.SingleOrDefaultAsync(expression, token))!; + return (await FilteredRepositoryQuery.SingleOrDefaultAsync(expression, token).ConfigureAwait(false))!; } /// public async override Task FindSingleOrDefaultAsync(ISpecification specification, CancellationToken token = default) { - return await FindSingleOrDefaultAsync(specification.Predicate, token); + return await FindSingleOrDefaultAsync(specification.Predicate, token).ConfigureAwait(false); } /// public async override Task GetCountAsync(ISpecification selectSpec, CancellationToken token = default) { - return await GetCountAsync(selectSpec.Predicate, token); + return await GetCountAsync(selectSpec.Predicate, token).ConfigureAwait(false); } /// public async override Task GetCountAsync(Expression> expression, CancellationToken token = default) { - return await FilteredRepositoryQuery.CountAsync(expression, token); + return await FilteredRepositoryQuery.CountAsync(expression, token).ConfigureAwait(false); } /// public async override Task UpdateAsync(TEntity entity, CancellationToken token = default) { EventTracker.AddEntity(entity); - await DataConnection.UpdateAsync(entity, token: token); + await DataConnection.UpdateAsync(entity, token: token).ConfigureAwait(false); } /// @@ -454,7 +454,7 @@ public override async Task AddRangeAsync(IEnumerable entities, Cancella { EventTracker.AddEntity(entity); MultiTenantHelper.SetTenantIdIfApplicable(entity, _tenantIdAccessor.GetTenantId()); - await DataConnection.InsertAsync(entity, token: token); + await DataConnection.InsertAsync(entity, token: token).ConfigureAwait(false); } } diff --git a/Src/RCommon.Linq2Db/ILinq2DbPersistenceBuilder.cs b/Src/RCommon.Linq2Db/ILinq2DbPersistenceBuilder.cs index 66d5dd17..a5dd440d 100644 --- a/Src/RCommon.Linq2Db/ILinq2DbPersistenceBuilder.cs +++ b/Src/RCommon.Linq2Db/ILinq2DbPersistenceBuilder.cs @@ -20,5 +20,14 @@ public interface ILinq2DbPersistenceBuilder: IPersistenceBuilder /// A factory function that receives the and existing , returning configured . /// The builder instance for fluent chaining. ILinq2DbPersistenceBuilder AddDataConnection(string dataStoreName, Func options) where TDataConnection : RCommonDataConnection; + + /// + /// Registers a singleton implementation used by + /// to select the correct SQL locking dialect for ClaimAsync. + /// + /// The implementation to register. + /// The builder instance for fluent chaining. + ILinq2DbPersistenceBuilder UseLockStatementProvider() + where TProvider : class, RCommon.Persistence.Outbox.ILockStatementProvider; } } diff --git a/Src/RCommon.Linq2Db/Inbox/Linq2DbInboxStore.cs b/Src/RCommon.Linq2Db/Inbox/Linq2DbInboxStore.cs new file mode 100644 index 00000000..bf5681cc --- /dev/null +++ b/Src/RCommon.Linq2Db/Inbox/Linq2DbInboxStore.cs @@ -0,0 +1,90 @@ +using LinqToDB; +using LinqToDB.Async; +using Microsoft.Extensions.Options; +using RCommon.Persistence.Inbox; +using RCommon.Persistence.Outbox; +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace RCommon.Persistence.Linq2Db.Inbox; + +/// +/// A Linq2Db implementation of that persists inbox messages +/// using a resolved through the . +/// +public class Linq2DbInboxStore : IInboxStore +{ + private readonly IDataStoreFactory _dataStoreFactory; + private readonly string _dataStoreName; + private readonly string _tableName; + + /// + /// Initializes a new instance of . + /// + /// Factory used to resolve the for the configured data store. + /// Options specifying which data store to use when none is explicitly set. + /// Options for outbox/inbox behaviour such as table name. + /// Thrown when any required parameter is null or yields a null value. + public Linq2DbInboxStore( + IDataStoreFactory dataStoreFactory, + IOptions defaultDataStoreOptions, + IOptions outboxOptions) + { + _dataStoreFactory = dataStoreFactory ?? throw new ArgumentNullException(nameof(dataStoreFactory)); + _dataStoreName = defaultDataStoreOptions?.Value?.DefaultDataStoreName + ?? throw new ArgumentNullException(nameof(defaultDataStoreOptions)); + _tableName = outboxOptions?.Value?.InboxTableName ?? "__InboxMessages"; + } + + /// + /// Gets the for the configured data store, resolved through the . + /// + private RCommonDataConnection DataConnection + => _dataStoreFactory.Resolve(_dataStoreName); + + /// + /// Gets the Linq2Db scoped to the configured table name. + /// + private ITable Table + => DataConnection.GetTable().TableName(_tableName); + + /// + public async Task ExistsAsync(Guid messageId, string? consumerType = null, CancellationToken cancellationToken = default) + { + var ct = consumerType ?? ""; + return await Table + .AnyAsync(m => m.MessageId == messageId && m.ConsumerType == ct, cancellationToken) + .ConfigureAwait(false); + } + + /// + public async Task RecordAsync(IInboxMessage message, CancellationToken cancellationToken = default) + { + var entity = message as InboxMessage ?? new InboxMessage + { + MessageId = message.MessageId, + EventType = message.EventType, + ConsumerType = message.ConsumerType ?? "", + ReceivedAtUtc = message.ReceivedAtUtc + }; + + // Coalesce ConsumerType even when we reuse the original entity + if (entity.ConsumerType is null) + { + entity.ConsumerType = ""; + } + + await DataConnection.InsertAsync(entity, _tableName, token: cancellationToken).ConfigureAwait(false); + } + + /// + public async Task CleanupAsync(TimeSpan olderThan, CancellationToken cancellationToken = default) + { + var cutoff = DateTimeOffset.UtcNow - olderThan; + await Table + .Where(m => m.ReceivedAtUtc < cutoff) + .DeleteAsync(cancellationToken) + .ConfigureAwait(false); + } +} diff --git a/Src/RCommon.Linq2Db/Linq2DbPersistenceBuilder.cs b/Src/RCommon.Linq2Db/Linq2DbPersistenceBuilder.cs index 414e8b39..3faaa3d5 100644 --- a/Src/RCommon.Linq2Db/Linq2DbPersistenceBuilder.cs +++ b/Src/RCommon.Linq2Db/Linq2DbPersistenceBuilder.cs @@ -10,10 +10,13 @@ using System.Text; using System.Threading.Tasks; using RCommon.Persistence.Linq2Db.Crud; +using RCommon.Persistence.Linq2Db.Sagas; using RCommon.Persistence.Crud; +using RCommon.Persistence.Sagas; using RCommon.Security.Claims; using Microsoft.Extensions.DependencyInjection.Extensions; using LinqToDB.Extensions.DependencyInjection; +using RCommon.Persistence.Outbox; namespace RCommon.Persistence.Linq2Db { @@ -48,6 +51,9 @@ public Linq2DbPersistenceBuilder(IServiceCollection services) services.AddTransient(typeof(IReadOnlyRepository<>), typeof(Linq2DbRepository<>)); services.AddTransient(typeof(IWriteOnlyRepository<>), typeof(Linq2DbRepository<>)); services.AddTransient(typeof(ILinqRepository<>), typeof(Linq2DbRepository<>)); + services.AddTransient(typeof(IAggregateRepository<,>), typeof(Linq2DbAggregateRepository<,>)); + services.AddTransient(typeof(IReadModelRepository<>), typeof(Linq2DbReadModelRepository<>)); + services.AddScoped(typeof(ISagaStore<,>), typeof(Linq2DbSagaStore<,>)); } /// @@ -84,5 +90,13 @@ public IPersistenceBuilder SetDefaultDataStore(Action o this._services.Configure(options); return this; } + + /// + public ILinq2DbPersistenceBuilder UseLockStatementProvider() + where TProvider : class, ILockStatementProvider + { + this._services.AddSingleton(); + return this; + } } } diff --git a/Src/RCommon.Linq2Db/Outbox/Linq2DbOutboxStore.cs b/Src/RCommon.Linq2Db/Outbox/Linq2DbOutboxStore.cs new file mode 100644 index 00000000..f6bdfd05 --- /dev/null +++ b/Src/RCommon.Linq2Db/Outbox/Linq2DbOutboxStore.cs @@ -0,0 +1,221 @@ +using LinqToDB; +using LinqToDB.Async; +using LinqToDB.Data; +using Microsoft.Extensions.Options; +using RCommon.Persistence.Outbox; +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace RCommon.Persistence.Linq2Db.Outbox; + +/// +/// A Linq2Db implementation of that persists outbox messages +/// using a resolved through the . +/// +public class Linq2DbOutboxStore : IOutboxStore +{ + private readonly IDataStoreFactory _dataStoreFactory; + private readonly string _dataStoreName; + private readonly string _tableName; + private readonly int _maxRetries; + private readonly ILockStatementProvider _lockProvider; + + /// + /// Initializes a new instance of . + /// + /// Factory used to resolve the for the configured data store. + /// Options specifying which data store to use when none is explicitly set. + /// Options for outbox behaviour such as table name and max retries. + /// Provider that determines the SQL locking dialect to use for . + /// Thrown when any required parameter is null or yields a null value. + public Linq2DbOutboxStore( + IDataStoreFactory dataStoreFactory, + IOptions defaultDataStoreOptions, + IOptions outboxOptions, + ILockStatementProvider lockStatementProvider) + { + _dataStoreFactory = dataStoreFactory ?? throw new ArgumentNullException(nameof(dataStoreFactory)); + _dataStoreName = defaultDataStoreOptions?.Value?.DefaultDataStoreName + ?? throw new ArgumentNullException(nameof(defaultDataStoreOptions)); + _tableName = outboxOptions?.Value?.TableName ?? "__OutboxMessages"; + _maxRetries = outboxOptions?.Value?.MaxRetries ?? 5; + _lockProvider = lockStatementProvider ?? throw new ArgumentNullException(nameof(lockStatementProvider)); + } + + /// + /// Gets the for the configured data store, resolved through the . + /// + private RCommonDataConnection DataConnection + => _dataStoreFactory.Resolve(_dataStoreName); + + /// + /// Gets the Linq2Db scoped to the configured table name. + /// + private ITable Table + => DataConnection.GetTable().TableName(_tableName); + + /// + public async Task SaveAsync(IOutboxMessage message, CancellationToken cancellationToken = default) + { + var entity = message as OutboxMessage ?? new OutboxMessage + { + Id = message.Id, + EventType = message.EventType, + EventPayload = message.EventPayload, + CreatedAtUtc = message.CreatedAtUtc, + ProcessedAtUtc = message.ProcessedAtUtc, + DeadLetteredAtUtc = message.DeadLetteredAtUtc, + ErrorMessage = message.ErrorMessage, + RetryCount = message.RetryCount, + CorrelationId = message.CorrelationId, + TenantId = message.TenantId, + NextRetryAtUtc = message.NextRetryAtUtc, + LockedByInstanceId = message.LockedByInstanceId, + LockedUntilUtc = message.LockedUntilUtc + }; + await DataConnection.InsertAsync(entity, _tableName, token: cancellationToken).ConfigureAwait(false); + } + + /// + public async Task MarkProcessedAsync(Guid messageId, CancellationToken cancellationToken = default) + { + await Table + .Where(m => m.Id == messageId) + .Set(m => m.ProcessedAtUtc, DateTimeOffset.UtcNow) + .UpdateAsync(cancellationToken) + .ConfigureAwait(false); + } + + /// + public async Task MarkFailedAsync(Guid messageId, string error, DateTimeOffset nextRetryAtUtc, CancellationToken cancellationToken = default) + { + await Table + .Where(m => m.Id == messageId) + .Set(m => m.ErrorMessage, error) + .Set(m => m.RetryCount, m => m.RetryCount + 1) + .Set(m => m.NextRetryAtUtc, nextRetryAtUtc) + .Set(m => m.LockedByInstanceId, (string?)null) + .Set(m => m.LockedUntilUtc, (DateTimeOffset?)null) + .UpdateAsync(cancellationToken) + .ConfigureAwait(false); + } + + /// + public async Task MarkDeadLetteredAsync(Guid messageId, CancellationToken cancellationToken = default) + { + await Table + .Where(m => m.Id == messageId) + .Set(m => m.DeadLetteredAtUtc, DateTimeOffset.UtcNow) + .UpdateAsync(cancellationToken) + .ConfigureAwait(false); + } + + /// + public async Task DeleteProcessedAsync(TimeSpan olderThan, CancellationToken cancellationToken = default) + { + var cutoff = DateTimeOffset.UtcNow - olderThan; + await Table + .Where(m => m.ProcessedAtUtc != null && m.ProcessedAtUtc < cutoff) + .DeleteAsync(cancellationToken) + .ConfigureAwait(false); + } + + /// + public async Task DeleteDeadLetteredAsync(TimeSpan olderThan, CancellationToken cancellationToken = default) + { + var cutoff = DateTimeOffset.UtcNow - olderThan; + await Table + .Where(m => m.DeadLetteredAtUtc != null && m.DeadLetteredAtUtc < cutoff) + .DeleteAsync(cancellationToken) + .ConfigureAwait(false); + } + + /// + public async Task> ClaimAsync(string instanceId, int batchSize, TimeSpan lockDuration, CancellationToken cancellationToken = default) + { + var now = DateTimeOffset.UtcNow; + var lockUntil = now + lockDuration; + var dc = DataConnection; + + string sql; + if (_lockProvider.ProviderName == "PostgreSql") + { + sql = $@" + UPDATE ""{_tableName}"" o + SET ""LockedByInstanceId"" = @InstanceId, ""LockedUntilUtc"" = @LockUntil + FROM ( + SELECT ""Id"" FROM ""{_tableName}"" + WHERE ""ProcessedAtUtc"" IS NULL + AND ""DeadLetteredAtUtc"" IS NULL + AND ""RetryCount"" < @MaxRetries + AND (""NextRetryAtUtc"" IS NULL OR ""NextRetryAtUtc"" <= @Now) + AND (""LockedUntilUtc"" IS NULL OR ""LockedUntilUtc"" <= @Now) + ORDER BY ""CreatedAtUtc"" + LIMIT @BatchSize + FOR UPDATE SKIP LOCKED + ) AS batch + WHERE o.""Id"" = batch.""Id"" + RETURNING o.*"; + } + else // SQL Server + { + sql = $@" + WITH batch AS ( + SELECT TOP (@BatchSize) Id + FROM [{_tableName}] WITH (UPDLOCK, ROWLOCK, READPAST) + WHERE ProcessedAtUtc IS NULL + AND DeadLetteredAtUtc IS NULL + AND RetryCount < @MaxRetries + AND (NextRetryAtUtc IS NULL OR NextRetryAtUtc <= @Now) + AND (LockedUntilUtc IS NULL OR LockedUntilUtc <= @Now) + ORDER BY CreatedAtUtc + ) + UPDATE o + SET o.LockedByInstanceId = @InstanceId, o.LockedUntilUtc = @LockUntil + OUTPUT INSERTED.* + FROM [{_tableName}] o + INNER JOIN batch ON o.Id = batch.Id"; + } + + var result = await dc.QueryToListAsync( + sql, + new { BatchSize = batchSize, MaxRetries = _maxRetries, Now = now, InstanceId = instanceId, LockUntil = lockUntil }, + cancellationToken).ConfigureAwait(false); + return result; + } + + /// + public async Task> GetDeadLettersAsync(int batchSize, int offset = 0, CancellationToken cancellationToken = default) + { + return await Table + .Where(m => m.DeadLetteredAtUtc != null) + .OrderByDescending(m => m.DeadLetteredAtUtc) + .Skip(offset) + .Take(batchSize) + .ToListAsync(cancellationToken) + .ConfigureAwait(false); + } + + /// + public async Task ReplayDeadLetterAsync(Guid messageId, CancellationToken cancellationToken = default) + { + var rows = await Table + .Where(m => m.Id == messageId && m.DeadLetteredAtUtc != null) + .Set(m => m.DeadLetteredAtUtc, (DateTimeOffset?)null) + .Set(m => m.ProcessedAtUtc, (DateTimeOffset?)null) + .Set(m => m.ErrorMessage, (string?)null) + .Set(m => m.RetryCount, 0) + .Set(m => m.NextRetryAtUtc, (DateTimeOffset?)null) + .Set(m => m.LockedByInstanceId, (string?)null) + .Set(m => m.LockedUntilUtc, (DateTimeOffset?)null) + .UpdateAsync(cancellationToken) + .ConfigureAwait(false); + + if (rows == 0) + { + throw new InvalidOperationException($"Message {messageId} does not exist or is not dead-lettered."); + } + } +} diff --git a/Src/RCommon.Linq2Db/Sagas/Linq2DbSagaStore.cs b/Src/RCommon.Linq2Db/Sagas/Linq2DbSagaStore.cs new file mode 100644 index 00000000..d40c19a9 --- /dev/null +++ b/Src/RCommon.Linq2Db/Sagas/Linq2DbSagaStore.cs @@ -0,0 +1,115 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using LinqToDB; +using LinqToDB.Async; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using RCommon.Persistence.Sagas; + +namespace RCommon.Persistence.Linq2Db.Sagas; + +/// +/// A Linq2Db implementation of that persists saga state +/// using a resolved through the . +/// +/// The saga state type. Must derive from . +/// The primary key type. Must implement . +public class Linq2DbSagaStore : ISagaStore + where TState : SagaState + where TKey : IEquatable +{ + private readonly IDataStoreFactory _dataStoreFactory; + private readonly ILogger _logger; + private string _dataStoreName; + + /// + /// Initializes a new instance of . + /// + /// Factory used to resolve the for the configured data store. + /// Factory for creating loggers scoped to this store type. + /// Options specifying which data store to use when none is explicitly set. + /// Thrown when any required parameter is null. + public Linq2DbSagaStore( + IDataStoreFactory dataStoreFactory, + ILoggerFactory loggerFactory, + IOptions defaultDataStoreOptions) + { + _dataStoreFactory = dataStoreFactory ?? throw new ArgumentNullException(nameof(dataStoreFactory)); + _logger = loggerFactory?.CreateLogger(GetType().Name) ?? throw new ArgumentNullException(nameof(loggerFactory)); + + if (defaultDataStoreOptions is null) + throw new ArgumentNullException(nameof(defaultDataStoreOptions)); + + _dataStoreName = defaultDataStoreOptions.Value?.DefaultDataStoreName ?? string.Empty; + } + + /// + /// Gets the for the configured data store, resolved through the . + /// + private RCommonDataConnection DataConnection + => _dataStoreFactory.Resolve(_dataStoreName); + + /// + public async Task GetByIdAsync(TKey id, CancellationToken ct = default) + { + try + { + return await DataConnection.GetTable() + .FirstOrDefaultAsync(s => s.Id.Equals(id), token: ct) + .ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error in {StoreType}.GetByIdAsync while executing on the DataConnection.", GetType().FullName); + throw; + } + } + + /// + public async Task FindByCorrelationIdAsync(string correlationId, CancellationToken ct = default) + { + try + { + return await DataConnection.GetTable() + .FirstOrDefaultAsync(s => s.CorrelationId == correlationId, token: ct) + .ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error in {StoreType}.FindByCorrelationIdAsync while executing on the DataConnection.", GetType().FullName); + throw; + } + } + + /// + public async Task SaveAsync(TState state, CancellationToken ct = default) + { + try + { + await DataConnection.InsertOrReplaceAsync(state, token: ct).ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error in {StoreType}.SaveAsync while executing on the DataConnection.", GetType().FullName); + throw; + } + } + + /// + public async Task DeleteAsync(TState state, CancellationToken ct = default) + { + try + { + await DataConnection.GetTable() + .Where(s => s.Id.Equals(state.Id)) + .DeleteAsync(ct) + .ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error in {StoreType}.DeleteAsync while executing on the DataConnection.", GetType().FullName); + throw; + } + } +} diff --git a/Src/RCommon.MassTransit.Outbox/IMassTransitOutboxBuilder.cs b/Src/RCommon.MassTransit.Outbox/IMassTransitOutboxBuilder.cs new file mode 100644 index 00000000..c0b7ab9e --- /dev/null +++ b/Src/RCommon.MassTransit.Outbox/IMassTransitOutboxBuilder.cs @@ -0,0 +1,10 @@ +using MassTransit; + +namespace RCommon.MassTransit.Outbox; + +public interface IMassTransitOutboxBuilder +{ + IMassTransitOutboxBuilder UsePostgres(); + IMassTransitOutboxBuilder UseSqlServer(); + IMassTransitOutboxBuilder UseBusOutbox(Action? configure = null); +} diff --git a/Src/RCommon.MassTransit.Outbox/MassTransitOutboxBuilder.cs b/Src/RCommon.MassTransit.Outbox/MassTransitOutboxBuilder.cs new file mode 100644 index 00000000..40baee63 --- /dev/null +++ b/Src/RCommon.MassTransit.Outbox/MassTransitOutboxBuilder.cs @@ -0,0 +1,31 @@ +using MassTransit; + +namespace RCommon.MassTransit.Outbox; + +public class MassTransitOutboxBuilder : IMassTransitOutboxBuilder +{ + private readonly IEntityFrameworkOutboxConfigurator _configurator; + + public MassTransitOutboxBuilder(IEntityFrameworkOutboxConfigurator configurator) + { + _configurator = configurator ?? throw new ArgumentNullException(nameof(configurator)); + } + + public IMassTransitOutboxBuilder UsePostgres() + { + _configurator.UsePostgres(); + return this; + } + + public IMassTransitOutboxBuilder UseSqlServer() + { + _configurator.UseSqlServer(); + return this; + } + + public IMassTransitOutboxBuilder UseBusOutbox(Action? configure = null) + { + _configurator.UseBusOutbox(configure); + return this; + } +} diff --git a/Src/RCommon.MassTransit.Outbox/MassTransitOutboxBuilderExtensions.cs b/Src/RCommon.MassTransit.Outbox/MassTransitOutboxBuilderExtensions.cs new file mode 100644 index 00000000..866f986b --- /dev/null +++ b/Src/RCommon.MassTransit.Outbox/MassTransitOutboxBuilderExtensions.cs @@ -0,0 +1,22 @@ +using MassTransit; +using Microsoft.EntityFrameworkCore; +using RCommon.MassTransit; +using RCommon.MassTransit.Outbox; + +namespace RCommon; + +public static class MassTransitOutboxBuilderExtensions +{ + public static IMassTransitEventHandlingBuilder AddOutbox( + this IMassTransitEventHandlingBuilder builder, + Action? configure = null) + where TDbContext : DbContext + { + builder.AddEntityFrameworkOutbox(o => + { + var outboxBuilder = new MassTransitOutboxBuilder(o); + configure?.Invoke(outboxBuilder); + }); + return builder; + } +} diff --git a/Src/RCommon.MassTransit.Outbox/RCommon.MassTransit.Outbox.csproj b/Src/RCommon.MassTransit.Outbox/RCommon.MassTransit.Outbox.csproj new file mode 100644 index 00000000..f9614852 --- /dev/null +++ b/Src/RCommon.MassTransit.Outbox/RCommon.MassTransit.Outbox.csproj @@ -0,0 +1,48 @@ + + + + net8.0;net9.0;net10.0 + True + RCommon.MassTransit.Outbox + https://rcommon.com + RCommon; MassTransit; Outbox; Transactional Outbox; Event Bus + Apache-2.0 + True + A cohesive set of infrastructure libraries for dotnet that utilizes abstractions for event handling, persistence, unit of work, mediator, distributed messaging, event bus, CQRS, email, and more + https://github.com/RCommon-Team/RCommon + RCommon + Jason Webb + RCommon-Icon.jpg + README.md + enable + enable + + + + + + + + + + + + + + True + \ + + + + + + True + \ + + + + + + + + diff --git a/Src/RCommon.MassTransit.Outbox/README.md b/Src/RCommon.MassTransit.Outbox/README.md new file mode 100644 index 00000000..63c7fb86 --- /dev/null +++ b/Src/RCommon.MassTransit.Outbox/README.md @@ -0,0 +1,3 @@ +# RCommon.MassTransit.Outbox + +MassTransit Entity Framework Core outbox integration for RCommon. diff --git a/Src/RCommon.MassTransit.StateMachines/MassTransitStateConfigurator.cs b/Src/RCommon.MassTransit.StateMachines/MassTransitStateConfigurator.cs new file mode 100644 index 00000000..5c39ce5f --- /dev/null +++ b/Src/RCommon.MassTransit.StateMachines/MassTransitStateConfigurator.cs @@ -0,0 +1,62 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using RCommon.StateMachines; + +namespace RCommon.MassTransit.StateMachines; + +/// +/// Stores per-state configuration for a MassTransit-based state machine, including +/// unconditional transitions, guarded transitions, and entry/exit actions. +/// +/// The enum type representing states. +/// The enum type representing triggers. +public class MassTransitStateConfigurator : IStateConfigurator + where TState : struct, Enum + where TTrigger : struct, Enum +{ + internal TState State { get; } + internal Dictionary Transitions { get; } = new(); + internal Dictionary Guard, TState Destination)>> GuardedTransitions { get; } = new(); + internal List> EntryActions { get; } = new(); + internal List> ExitActions { get; } = new(); + + public MassTransitStateConfigurator(TState state) + { + State = state; + } + + /// + public IStateConfigurator Permit(TTrigger trigger, TState destinationState) + { + Transitions[trigger] = destinationState; + return this; + } + + /// + public IStateConfigurator PermitIf(TTrigger trigger, TState destinationState, Func guard) + { + if (!GuardedTransitions.TryGetValue(trigger, out var guards)) + { + guards = new List<(Func Guard, TState Destination)>(); + GuardedTransitions[trigger] = guards; + } + guards.Add((guard, destinationState)); + return this; + } + + /// + public IStateConfigurator OnEntry(Func action) + { + EntryActions.Add(action); + return this; + } + + /// + public IStateConfigurator OnExit(Func action) + { + ExitActions.Add(action); + return this; + } +} diff --git a/Src/RCommon.MassTransit.StateMachines/MassTransitStateMachine.cs b/Src/RCommon.MassTransit.StateMachines/MassTransitStateMachine.cs new file mode 100644 index 00000000..73171f4b --- /dev/null +++ b/Src/RCommon.MassTransit.StateMachines/MassTransitStateMachine.cs @@ -0,0 +1,136 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using RCommon.StateMachines; + +namespace RCommon.MassTransit.StateMachines; + +/// +/// A lightweight dictionary-based finite state machine implementing . +/// Each instance is independent and tracks its own current state. +/// +/// The enum type representing states. +/// The enum type representing triggers. +public class MassTransitStateMachine : IStateMachine + where TState : struct, Enum + where TTrigger : struct, Enum +{ + private readonly Dictionary> _stateConfigs; + private TState _currentState; + + public MassTransitStateMachine( + TState initialState, + Dictionary> stateConfigs) + { + _currentState = initialState; + _stateConfigs = stateConfigs ?? throw new ArgumentNullException(nameof(stateConfigs)); + } + + /// + public TState CurrentState => _currentState; + + /// + public bool CanFire(TTrigger trigger) + { + if (!_stateConfigs.TryGetValue(_currentState, out var config)) + { + return false; + } + + if (config.Transitions.ContainsKey(trigger)) + { + return true; + } + + if (config.GuardedTransitions.TryGetValue(trigger, out var guards)) + { + return guards.Any(g => g.Guard()); + } + + return false; + } + + /// + public IEnumerable PermittedTriggers + { + get + { + if (!_stateConfigs.TryGetValue(_currentState, out var config)) + { + return Enumerable.Empty(); + } + + var unconditional = config.Transitions.Keys; + + var guarded = config.GuardedTransitions + .Where(kvp => kvp.Value.Any(g => g.Guard())) + .Select(kvp => kvp.Key); + + return unconditional.Union(guarded); + } + } + + /// + public async Task FireAsync(TTrigger trigger, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (!_stateConfigs.TryGetValue(_currentState, out var config)) + { + throw new InvalidOperationException( + $"No configuration found for state '{_currentState}'."); + } + + // Resolve destination: check unconditional first, then first passing guard. + TState destination; + if (config.Transitions.TryGetValue(trigger, out var unconditionalDest)) + { + destination = unconditionalDest; + } + else if (config.GuardedTransitions.TryGetValue(trigger, out var guards)) + { + var match = guards.FirstOrDefault(g => g.Guard()); + if (match.Guard is null) + { + throw new InvalidOperationException( + $"Trigger '{trigger}' has guarded transitions from state '{_currentState}', but no guard condition is satisfied."); + } + destination = match.Destination; + } + else + { + throw new InvalidOperationException( + $"No valid transition for trigger '{trigger}' from state '{_currentState}'."); + } + + // Execute exit actions for current state. + foreach (var exitAction in config.ExitActions) + { + await exitAction(cancellationToken).ConfigureAwait(false); + } + + // Update state. + _currentState = destination; + + // Execute entry actions for new state. + if (_stateConfigs.TryGetValue(_currentState, out var newConfig)) + { + foreach (var entryAction in newConfig.EntryActions) + { + await entryAction(cancellationToken).ConfigureAwait(false); + } + } + } + + /// + /// Fires a trigger with associated data. In this implementation the data parameter is ignored + /// and the transition is handled identically to . + /// This is a documented limitation of the dictionary-based FSM adapter. + /// + public Task FireAsync(TTrigger trigger, TData data, CancellationToken cancellationToken = default) + { + return FireAsync(trigger, cancellationToken); + } +} diff --git a/Src/RCommon.MassTransit.StateMachines/MassTransitStateMachineBuilderExtensions.cs b/Src/RCommon.MassTransit.StateMachines/MassTransitStateMachineBuilderExtensions.cs new file mode 100644 index 00000000..06875360 --- /dev/null +++ b/Src/RCommon.MassTransit.StateMachines/MassTransitStateMachineBuilderExtensions.cs @@ -0,0 +1,24 @@ +using Microsoft.Extensions.DependencyInjection; +using RCommon.MassTransit.StateMachines; +using RCommon.StateMachines; + +namespace RCommon; + +/// +/// Extension methods for registering the MassTransit state machine adapter +/// with the RCommon builder pipeline. +/// +public static class MassTransitStateMachineBuilderExtensions +{ + /// + /// Registers the MassTransit dictionary-based state machine as the implementation + /// for . + /// + /// The RCommon builder to register services against. + /// The for further chaining. + public static IRCommonBuilder WithMassTransitStateMachine(this IRCommonBuilder builder) + { + builder.Services.AddTransient(typeof(IStateMachineConfigurator<,>), typeof(MassTransitStateMachineConfigurator<,>)); + return builder; + } +} diff --git a/Src/RCommon.MassTransit.StateMachines/MassTransitStateMachineConfigurator.cs b/Src/RCommon.MassTransit.StateMachines/MassTransitStateMachineConfigurator.cs new file mode 100644 index 00000000..233e80dd --- /dev/null +++ b/Src/RCommon.MassTransit.StateMachines/MassTransitStateMachineConfigurator.cs @@ -0,0 +1,38 @@ +using System; +using System.Collections.Generic; +using RCommon.StateMachines; + +namespace RCommon.MassTransit.StateMachines; + +/// +/// Configures state machine transitions, guards, and actions, then builds independent +/// instances. Configuration is performed once +/// via calls, while can be called many times +/// with different initial states to produce independent machine instances that share the +/// same transition configuration. +/// +/// The enum type representing states. +/// The enum type representing triggers. +public class MassTransitStateMachineConfigurator : IStateMachineConfigurator + where TState : struct, Enum + where TTrigger : struct, Enum +{ + private readonly Dictionary> _stateConfigs = new(); + + /// + public IStateConfigurator ForState(TState state) + { + if (!_stateConfigs.TryGetValue(state, out var config)) + { + config = new MassTransitStateConfigurator(state); + _stateConfigs[state] = config; + } + return config; + } + + /// + public IStateMachine Build(TState initialState) + { + return new MassTransitStateMachine(initialState, _stateConfigs); + } +} diff --git a/Src/RCommon.MassTransit.StateMachines/RCommon.MassTransit.StateMachines.csproj b/Src/RCommon.MassTransit.StateMachines/RCommon.MassTransit.StateMachines.csproj new file mode 100644 index 00000000..2530d2ba --- /dev/null +++ b/Src/RCommon.MassTransit.StateMachines/RCommon.MassTransit.StateMachines.csproj @@ -0,0 +1,43 @@ + + + + net8.0;net9.0;net10.0 + True + RCommon.MassTransit.StateMachines + https://rcommon.com + RCommon; MassTransit; State Machine; FSM; Workflow + Apache-2.0 + True + A cohesive set of infrastructure libraries for dotnet that utilizes abstractions for event handling, persistence, unit of work, mediator, distributed messaging, event bus, CQRS, email, and more + https://github.com/RCommon-Team/RCommon + RCommon + Jason Webb + RCommon-Icon.jpg + README.md + enable + enable + + + + + + + + + True + \ + + + + + + True + \ + + + + + + + + diff --git a/Src/RCommon.MassTransit.StateMachines/README.md b/Src/RCommon.MassTransit.StateMachines/README.md new file mode 100644 index 00000000..32d32f45 --- /dev/null +++ b/Src/RCommon.MassTransit.StateMachines/README.md @@ -0,0 +1,3 @@ +# RCommon.MassTransit.StateMachines + +Lightweight dictionary-based state machine adapter for the RCommon framework. Implements `IStateMachine` and related interfaces using enum-based states and triggers. diff --git a/Src/RCommon.MassTransit/Producers/PublishWithMassTransitEventProducer.cs b/Src/RCommon.MassTransit/Producers/PublishWithMassTransitEventProducer.cs index fea4de6e..4c81b001 100644 --- a/Src/RCommon.MassTransit/Producers/PublishWithMassTransitEventProducer.cs +++ b/Src/RCommon.MassTransit/Producers/PublishWithMassTransitEventProducer.cs @@ -68,7 +68,7 @@ public async Task ProduceEventAsync(T @event, CancellationToken cancellationT { _logger.LogDebug("{0} publishing event: {1}", new object[] { this.GetGenericTypeName(), @event }); } - await _bus.Publish(@event, cancellationToken); + await _bus.Publish(@event, cancellationToken).ConfigureAwait(false); } } catch (Exception ex) diff --git a/Src/RCommon.MassTransit/Producers/SendWithMassTransitEventProducer.cs b/Src/RCommon.MassTransit/Producers/SendWithMassTransitEventProducer.cs index 1e75e23d..c105217b 100644 --- a/Src/RCommon.MassTransit/Producers/SendWithMassTransitEventProducer.cs +++ b/Src/RCommon.MassTransit/Producers/SendWithMassTransitEventProducer.cs @@ -68,7 +68,7 @@ public async Task ProduceEventAsync(T @event, CancellationToken cancellationT { _logger.LogDebug("{0} sending event: {1}", new object[] { this.GetGenericTypeName(), @event }); } - await _bus.Send(@event, cancellationToken); + await _bus.Send(@event, cancellationToken).ConfigureAwait(false); } } catch (Exception ex) diff --git a/Src/RCommon.MassTransit/RCommon.MassTransit.csproj b/Src/RCommon.MassTransit/RCommon.MassTransit.csproj index 8b7ab307..bc0e2245 100644 --- a/Src/RCommon.MassTransit/RCommon.MassTransit.csproj +++ b/Src/RCommon.MassTransit/RCommon.MassTransit.csproj @@ -19,7 +19,7 @@ - + diff --git a/Src/RCommon.MassTransit/Subscribers/MassTransitEventHandler.cs b/Src/RCommon.MassTransit/Subscribers/MassTransitEventHandler.cs index 92434105..a705e331 100644 --- a/Src/RCommon.MassTransit/Subscribers/MassTransitEventHandler.cs +++ b/Src/RCommon.MassTransit/Subscribers/MassTransitEventHandler.cs @@ -39,7 +39,7 @@ public MassTransitEventHandler(ILogger> logger, public async Task Consume(ConsumeContext context) { _logger.LogDebug("{0} handling event {1}", new object[] { this.GetGenericTypeName(), context.Message }); - await _subscriber.HandleAsync(context.Message); + await _subscriber.HandleAsync(context.Message, context.CancellationToken).ConfigureAwait(false); } } } diff --git a/Src/RCommon.Mediator/MediatorService.cs b/Src/RCommon.Mediator/MediatorService.cs index 8e2bc4c5..3f66b872 100644 --- a/Src/RCommon.Mediator/MediatorService.cs +++ b/Src/RCommon.Mediator/MediatorService.cs @@ -37,13 +37,13 @@ public Task Publish(TNotification notification, CancellationToken /// public async Task Send(TRequest request, CancellationToken cancellationToken = default) { - await _mediatorAdapter.Send(request, cancellationToken); + await _mediatorAdapter.Send(request, cancellationToken).ConfigureAwait(false); } /// public async Task Send(TRequest request, CancellationToken cancellationToken = default) { - return await _mediatorAdapter.Send(request, cancellationToken); + return await _mediatorAdapter.Send(request, cancellationToken).ConfigureAwait(false); } } diff --git a/Src/RCommon.Mediatr/Behaviors/LoggingBehavior.cs b/Src/RCommon.Mediatr/Behaviors/LoggingBehavior.cs index ce12a325..5d215406 100644 --- a/Src/RCommon.Mediatr/Behaviors/LoggingBehavior.cs +++ b/Src/RCommon.Mediatr/Behaviors/LoggingBehavior.cs @@ -32,7 +32,7 @@ public class LoggingRequestBehavior : IPipelineBehavior Handle(TRequest request, RequestHandlerDelegate next, CancellationToken cancellationToken) { _logger.LogInformation("----- Handling command {CommandName} ({@Command})", request.GetGenericTypeName(), request); - var response = await next(); + var response = await next().ConfigureAwait(false); _logger.LogInformation("----- Command {CommandName} handled - response: {@Response}", request.GetGenericTypeName(), response); return response; @@ -61,7 +61,7 @@ public class LoggingRequestWithResponseBehavior : IPipeline public async Task Handle(TRequest request, RequestHandlerDelegate next, CancellationToken cancellationToken) { _logger.LogInformation("----- Handling command {CommandName} ({@Command})", request.GetGenericTypeName(), request); - var response = await next(); + var response = await next().ConfigureAwait(false); _logger.LogInformation("----- Command {CommandName} handled - response: {@Response}", request.GetGenericTypeName(), response); return response; diff --git a/Src/RCommon.Mediatr/Behaviors/UnitOfWorkBehavior.cs b/Src/RCommon.Mediatr/Behaviors/UnitOfWorkBehavior.cs index 962085bb..345b7180 100644 --- a/Src/RCommon.Mediatr/Behaviors/UnitOfWorkBehavior.cs +++ b/Src/RCommon.Mediatr/Behaviors/UnitOfWorkBehavior.cs @@ -46,12 +46,12 @@ public async Task Handle(TRequest request, RequestHandlerDelegate Handle(TRequest request, RequestHandlerDelegate Handle(TRequest request, RequestHandlerDelegate(request, true, cancellationToken); - return await next(); + await _validationService.ValidateAsync(request, true, cancellationToken).ConfigureAwait(false); + return await next().ConfigureAwait(false); } } @@ -78,8 +78,8 @@ public async Task Handle(TRequest request, RequestHandlerDelegate(request, true, cancellationToken); - return await next(); + await _validationService.ValidateAsync(request, true, cancellationToken).ConfigureAwait(false); + return await next().ConfigureAwait(false); } } diff --git a/Src/RCommon.Mediatr/MediatRAdapter.cs b/Src/RCommon.Mediatr/MediatRAdapter.cs index 953d0be5..6af42dae 100644 --- a/Src/RCommon.Mediatr/MediatRAdapter.cs +++ b/Src/RCommon.Mediatr/MediatRAdapter.cs @@ -48,7 +48,7 @@ public Task Publish(TNotification notification, CancellationToken /// Optional cancellation token. public async Task Send(TRequest request, CancellationToken cancellationToken = default) { - await _mediator.Send(new MediatRRequest(request), cancellationToken); + await _mediator.Send(new MediatRRequest(request), cancellationToken).ConfigureAwait(false); } /// @@ -62,7 +62,7 @@ public async Task Send(TRequest request, CancellationToken cancellatio /// The response produced by the request handler. public async Task Send(TRequest request, CancellationToken cancellationToken = default) { - return await _mediator.Send(new MediatRRequest(request), cancellationToken); + return await _mediator.Send(new MediatRRequest(request), cancellationToken).ConfigureAwait(false); } } } diff --git a/Src/RCommon.Mediatr/Producers/PublishWithMediatREventProducer.cs b/Src/RCommon.Mediatr/Producers/PublishWithMediatREventProducer.cs index 06df8241..184b878f 100644 --- a/Src/RCommon.Mediatr/Producers/PublishWithMediatREventProducer.cs +++ b/Src/RCommon.Mediatr/Producers/PublishWithMediatREventProducer.cs @@ -73,7 +73,7 @@ public async Task ProduceEventAsync(TEvent @event, CancellationToken can } - await _mediatorService.Publish(@event, cancellationToken); + await _mediatorService.Publish(@event, cancellationToken).ConfigureAwait(false); } } catch (Exception ex) diff --git a/Src/RCommon.Mediatr/Producers/SendWithMediatREventProducer.cs b/Src/RCommon.Mediatr/Producers/SendWithMediatREventProducer.cs index 877fe43b..ff403baa 100644 --- a/Src/RCommon.Mediatr/Producers/SendWithMediatREventProducer.cs +++ b/Src/RCommon.Mediatr/Producers/SendWithMediatREventProducer.cs @@ -71,7 +71,7 @@ public async Task ProduceEventAsync(TEvent @event, CancellationToken can { _logger.LogDebug("{0} sending event: {1}", new object[] { this.GetGenericTypeName(), @event }); } - await _mediatorService.Send(@event, cancellationToken); + await _mediatorService.Send(@event, cancellationToken).ConfigureAwait(false); } } catch (Exception ex) diff --git a/Src/RCommon.Mediatr/Subscribers/MediatREventHandler.cs b/Src/RCommon.Mediatr/Subscribers/MediatREventHandler.cs index 975fe30d..5ad0d198 100644 --- a/Src/RCommon.Mediatr/Subscribers/MediatREventHandler.cs +++ b/Src/RCommon.Mediatr/Subscribers/MediatREventHandler.cs @@ -54,7 +54,7 @@ public async Task Handle(TNotification notification, CancellationToken cancellat "ISubscriber of type: " + typeof(TEvent).GetGenericTypeName() + " could not be resolved by IServiceProvider"); // Handle the event using the event handler we resolved - await subscriber!.HandleAsync(notification.Notification); + await subscriber!.HandleAsync(notification.Notification, cancellationToken).ConfigureAwait(false); } } } diff --git a/Src/RCommon.Mediatr/Subscribers/MediatRNotificationHandler.cs b/Src/RCommon.Mediatr/Subscribers/MediatRNotificationHandler.cs index 58460ebf..4c3c17ab 100644 --- a/Src/RCommon.Mediatr/Subscribers/MediatRNotificationHandler.cs +++ b/Src/RCommon.Mediatr/Subscribers/MediatRNotificationHandler.cs @@ -50,7 +50,7 @@ public async Task Handle(TNotification notification, CancellationToken cancellat "ISubscriber of type: " + typeof(T).GetGenericTypeName() + " could not be resolved by IServiceProvider"); // Handle the event using the event handler we resolved - await subscriber!.HandleAsync(notification.Notification); + await subscriber!.HandleAsync(notification.Notification, cancellationToken).ConfigureAwait(false); } } } diff --git a/Src/RCommon.Mediatr/Subscribers/MediatRRequestHandler.cs b/Src/RCommon.Mediatr/Subscribers/MediatRRequestHandler.cs index e95f0876..ed24ec1d 100644 --- a/Src/RCommon.Mediatr/Subscribers/MediatRRequestHandler.cs +++ b/Src/RCommon.Mediatr/Subscribers/MediatRRequestHandler.cs @@ -50,7 +50,7 @@ public async Task Handle(TRequest request, CancellationToken cancellationToken) "IAppRequestHandler of type: " + typeof(T).GetGenericTypeName() + " could not be resolved by IServiceProvider"); // Handle the event using the event handler we resolved - await handler!.HandleAsync(request.Request); + await handler!.HandleAsync(request.Request, cancellationToken).ConfigureAwait(false); } } @@ -93,7 +93,7 @@ public async Task Handle(TRequest request, CancellationToken cancella "IAppRequestHandler of type: " + typeof(T).GetGenericTypeName() + " could not be resolved by IServiceProvider"); // Handle the event using the event handler we resolved - return await handler!.HandleAsync(request.Request); + return await handler!.HandleAsync(request.Request, cancellationToken).ConfigureAwait(false); } } } diff --git a/Src/RCommon.Models/IPagedResult.cs b/Src/RCommon.Models/IPagedResult.cs new file mode 100644 index 00000000..8d15d6dd --- /dev/null +++ b/Src/RCommon.Models/IPagedResult.cs @@ -0,0 +1,14 @@ +using System.Collections.Generic; + +namespace RCommon.Models; + +public interface IPagedResult +{ + IReadOnlyList Items { get; } + long TotalCount { get; } + int PageNumber { get; } + int PageSize { get; } + int TotalPages { get; } + bool HasNextPage { get; } + bool HasPreviousPage { get; } +} diff --git a/Src/RCommon.Models/PagedResult.cs b/Src/RCommon.Models/PagedResult.cs new file mode 100644 index 00000000..c7442e48 --- /dev/null +++ b/Src/RCommon.Models/PagedResult.cs @@ -0,0 +1,25 @@ +using System; +using System.Collections.Generic; + +namespace RCommon.Models; + +public class PagedResult : IPagedResult +{ + public IReadOnlyList Items { get; } + public long TotalCount { get; } + public int PageNumber { get; } + public int PageSize { get; } + public int TotalPages => PageSize > 0 ? (int)Math.Ceiling((double)TotalCount / PageSize) : 0; + public bool HasNextPage => PageNumber < TotalPages; + public bool HasPreviousPage => PageNumber > 1; + + public PagedResult(IReadOnlyList items, long totalCount, int pageNumber, int pageSize) + { + if (pageSize <= 0) + throw new ArgumentOutOfRangeException(nameof(pageSize), "PageSize must be greater than zero."); + Items = items; + TotalCount = totalCount; + PageNumber = pageNumber; + PageSize = pageSize; + } +} diff --git a/Src/RCommon.Persistence.Caching/Crud/CachingGraphRepository.cs b/Src/RCommon.Persistence.Caching/Crud/CachingGraphRepository.cs index 5374408a..5399847f 100644 --- a/Src/RCommon.Persistence.Caching/Crud/CachingGraphRepository.cs +++ b/Src/RCommon.Persistence.Caching/Crud/CachingGraphRepository.cs @@ -59,37 +59,37 @@ public CachingGraphRepository(IGraphRepository repository, ICommonFacto /// public async Task AddAsync(TEntity entity, CancellationToken token = default) { - await _repository.AddAsync(entity, token); + await _repository.AddAsync(entity, token).ConfigureAwait(false); } /// public async Task AnyAsync(Expression> expression, CancellationToken token = default) { - return await _repository.AnyAsync(expression, token); + return await _repository.AnyAsync(expression, token).ConfigureAwait(false); } /// public async Task AnyAsync(ISpecification specification, CancellationToken token = default) { - return await _repository.AnyAsync(specification, token); + return await _repository.AnyAsync(specification, token).ConfigureAwait(false); } /// public async Task DeleteAsync(TEntity entity, CancellationToken token = default) { - await _repository.DeleteAsync(entity, token); + await _repository.DeleteAsync(entity, token).ConfigureAwait(false); } /// public async Task DeleteAsync(TEntity entity, bool isSoftDelete, CancellationToken token = default) { - await _repository.DeleteAsync(entity, isSoftDelete, token); + await _repository.DeleteAsync(entity, isSoftDelete, token).ConfigureAwait(false); } /// public async Task FindAsync(object primaryKey, CancellationToken token = default) { - return await _repository.FindAsync(primaryKey, token); + return await _repository.FindAsync(primaryKey, token).ConfigureAwait(false); } /// @@ -125,25 +125,25 @@ public IQueryable FindQuery(IPagedSpecification specification) /// public async Task FindSingleOrDefaultAsync(Expression> expression, CancellationToken token = default) { - return await _repository.FindSingleOrDefaultAsync(expression, token); + return await _repository.FindSingleOrDefaultAsync(expression, token).ConfigureAwait(false); } /// public async Task FindSingleOrDefaultAsync(ISpecification specification, CancellationToken token = default) { - return await _repository.FindSingleOrDefaultAsync(specification, token); + return await _repository.FindSingleOrDefaultAsync(specification, token).ConfigureAwait(false); } /// public async Task GetCountAsync(ISpecification selectSpec, CancellationToken token = default) { - return await _repository.GetCountAsync(selectSpec, token); + return await _repository.GetCountAsync(selectSpec, token).ConfigureAwait(false); } /// public async Task GetCountAsync(Expression> expression, CancellationToken token = default) { - return await _repository.GetCountAsync(expression, token); + return await _repository.GetCountAsync(expression, token).ConfigureAwait(false); } /// @@ -167,7 +167,7 @@ public IEagerLoadableQueryable ThenInclude public async Task UpdateAsync(TEntity entity, CancellationToken token = default) { - await _repository.UpdateAsync(entity, token); + await _repository.UpdateAsync(entity, token).ConfigureAwait(false); } /// @@ -180,49 +180,49 @@ IEnumerator IEnumerable.GetEnumerator() public async Task> FindAsync(Expression> expression, Expression> orderByExpression, bool orderByAscending, int pageNumber = 1, int pageSize = 0, CancellationToken token = default) { - return await _repository.FindAsync(expression, orderByExpression, orderByAscending, pageNumber, pageSize, token); + return await _repository.FindAsync(expression, orderByExpression, orderByAscending, pageNumber, pageSize, token).ConfigureAwait(false); } /// public async Task> FindAsync(IPagedSpecification specification, CancellationToken token = default) { - return await _repository.FindAsync(specification, token); + return await _repository.FindAsync(specification, token).ConfigureAwait(false); } /// public async Task> FindAsync(ISpecification specification, CancellationToken token = default) { - return await _repository.FindAsync(specification, token); + return await _repository.FindAsync(specification, token).ConfigureAwait(false); } /// public async Task> FindAsync(Expression> expression, CancellationToken token = default) { - return await _repository.FindAsync(expression, token); + return await _repository.FindAsync(expression, token).ConfigureAwait(false); } /// public async Task DeleteManyAsync(ISpecification specification, CancellationToken token = default) { - return await _repository.DeleteManyAsync(specification, token); + return await _repository.DeleteManyAsync(specification, token).ConfigureAwait(false); } /// public async Task DeleteManyAsync(ISpecification specification, bool isSoftDelete, CancellationToken token = default) { - return await _repository.DeleteManyAsync(specification, isSoftDelete, token); + return await _repository.DeleteManyAsync(specification, isSoftDelete, token).ConfigureAwait(false); } /// public async Task DeleteManyAsync(Expression> expression, CancellationToken token = default) { - return await _repository.DeleteManyAsync(expression, token); + return await _repository.DeleteManyAsync(expression, token).ConfigureAwait(false); } /// public async Task DeleteManyAsync(Expression> expression, bool isSoftDelete, CancellationToken token = default) { - return await _repository.DeleteManyAsync(expression, isSoftDelete, token); + return await _repository.DeleteManyAsync(expression, isSoftDelete, token).ConfigureAwait(false); } // Cached items — these overloads check the cache first and fall through to the inner repository on a miss. @@ -232,32 +232,32 @@ public async Task> FindAsync(object cacheKey, Expression object>> orderByExpression, bool orderByAscending, int pageNumber = 1, int pageSize = 0, CancellationToken token = default) { var data = await _cacheService.GetOrCreateAsync(cacheKey, - async () => await _repository.FindAsync(expression, orderByExpression, orderByAscending, pageNumber, pageSize, token)); - return await data; + async () => await _repository.FindAsync(expression, orderByExpression, orderByAscending, pageNumber, pageSize, token).ConfigureAwait(false)).ConfigureAwait(false); + return await data.ConfigureAwait(false); } /// public async Task> FindAsync(object cacheKey, IPagedSpecification specification, CancellationToken token = default) { var data = await _cacheService.GetOrCreateAsync(cacheKey, - async () => await _repository.FindAsync(specification, token)); - return await data; + async () => await _repository.FindAsync(specification, token).ConfigureAwait(false)).ConfigureAwait(false); + return await data.ConfigureAwait(false); } /// public async Task> FindAsync(object cacheKey, ISpecification specification, CancellationToken token = default) { var data = await _cacheService.GetOrCreateAsync(cacheKey, - async () => await _repository.FindAsync(specification, token)); - return await data; + async () => await _repository.FindAsync(specification, token).ConfigureAwait(false)).ConfigureAwait(false); + return await data.ConfigureAwait(false); } /// public async Task> FindAsync(object cacheKey, Expression> expression, CancellationToken token = default) { var data = await _cacheService.GetOrCreateAsync(cacheKey, - async () => await _repository.FindAsync(expression, token)); - return await data; + async () => await _repository.FindAsync(expression, token).ConfigureAwait(false)).ConfigureAwait(false); + return await data.ConfigureAwait(false); } /// @@ -268,7 +268,7 @@ public async Task> FindAsync(object cacheKey, Expression entities, CancellationToken token = default) { if (entities == null) throw new ArgumentNullException(nameof(entities)); - await _repository.AddRangeAsync(entities, token); + await _repository.AddRangeAsync(entities, token).ConfigureAwait(false); } } } diff --git a/Src/RCommon.Persistence.Caching/Crud/CachingLinqRepository.cs b/Src/RCommon.Persistence.Caching/Crud/CachingLinqRepository.cs index b7eddea2..d3b06a45 100644 --- a/Src/RCommon.Persistence.Caching/Crud/CachingLinqRepository.cs +++ b/Src/RCommon.Persistence.Caching/Crud/CachingLinqRepository.cs @@ -57,37 +57,37 @@ public CachingLinqRepository(IGraphRepository repository, ICommonFactor /// public async Task AddAsync(TEntity entity, CancellationToken token = default) { - await _repository.AddAsync(entity, token); + await _repository.AddAsync(entity, token).ConfigureAwait(false); } /// public async Task AnyAsync(Expression> expression, CancellationToken token = default) { - return await _repository.AnyAsync(expression, token); + return await _repository.AnyAsync(expression, token).ConfigureAwait(false); } /// public async Task AnyAsync(ISpecification specification, CancellationToken token = default) { - return await _repository.AnyAsync(specification, token); + return await _repository.AnyAsync(specification, token).ConfigureAwait(false); } /// public async Task DeleteAsync(TEntity entity, CancellationToken token = default) { - await _repository.DeleteAsync(entity, token); + await _repository.DeleteAsync(entity, token).ConfigureAwait(false); } /// public async Task DeleteAsync(TEntity entity, bool isSoftDelete, CancellationToken token = default) { - await _repository.DeleteAsync(entity, isSoftDelete, token); + await _repository.DeleteAsync(entity, isSoftDelete, token).ConfigureAwait(false); } /// public async Task FindAsync(object primaryKey, CancellationToken token = default) { - return await _repository.FindAsync(primaryKey, token); + return await _repository.FindAsync(primaryKey, token).ConfigureAwait(false); } /// @@ -123,25 +123,25 @@ public IQueryable FindQuery(IPagedSpecification specification) /// public async Task FindSingleOrDefaultAsync(Expression> expression, CancellationToken token = default) { - return await _repository.FindSingleOrDefaultAsync(expression, token); + return await _repository.FindSingleOrDefaultAsync(expression, token).ConfigureAwait(false); } /// public async Task FindSingleOrDefaultAsync(ISpecification specification, CancellationToken token = default) { - return await _repository.FindSingleOrDefaultAsync(specification, token); + return await _repository.FindSingleOrDefaultAsync(specification, token).ConfigureAwait(false); } /// public async Task GetCountAsync(ISpecification selectSpec, CancellationToken token = default) { - return await _repository.GetCountAsync(selectSpec, token); + return await _repository.GetCountAsync(selectSpec, token).ConfigureAwait(false); } /// public async Task GetCountAsync(Expression> expression, CancellationToken token = default) { - return await _repository.GetCountAsync(expression, token); + return await _repository.GetCountAsync(expression, token).ConfigureAwait(false); } /// @@ -165,7 +165,7 @@ public IEagerLoadableQueryable ThenInclude public async Task UpdateAsync(TEntity entity, CancellationToken token = default) { - await _repository.UpdateAsync(entity, token); + await _repository.UpdateAsync(entity, token).ConfigureAwait(false); } /// @@ -178,49 +178,49 @@ IEnumerator IEnumerable.GetEnumerator() public async Task> FindAsync(Expression> expression, Expression> orderByExpression, bool orderByAscending, int pageNumber = 1, int pageSize = 0, CancellationToken token = default) { - return await _repository.FindAsync(expression, orderByExpression, orderByAscending, pageNumber, pageSize, token); + return await _repository.FindAsync(expression, orderByExpression, orderByAscending, pageNumber, pageSize, token).ConfigureAwait(false); } /// public async Task> FindAsync(IPagedSpecification specification, CancellationToken token = default) { - return await _repository.FindAsync(specification, token); + return await _repository.FindAsync(specification, token).ConfigureAwait(false); } /// public async Task> FindAsync(ISpecification specification, CancellationToken token = default) { - return await _repository.FindAsync(specification, token); + return await _repository.FindAsync(specification, token).ConfigureAwait(false); } /// public async Task> FindAsync(Expression> expression, CancellationToken token = default) { - return await _repository.FindAsync(expression, token); + return await _repository.FindAsync(expression, token).ConfigureAwait(false); } /// public async Task DeleteManyAsync(ISpecification specification, CancellationToken token = default) { - return await _repository.DeleteManyAsync(specification, token); + return await _repository.DeleteManyAsync(specification, token).ConfigureAwait(false); } /// public async Task DeleteManyAsync(ISpecification specification, bool isSoftDelete, CancellationToken token = default) { - return await _repository.DeleteManyAsync(specification, isSoftDelete, token); + return await _repository.DeleteManyAsync(specification, isSoftDelete, token).ConfigureAwait(false); } /// public async Task DeleteManyAsync(Expression> expression, CancellationToken token = default) { - return await _repository.DeleteManyAsync(expression, token); + return await _repository.DeleteManyAsync(expression, token).ConfigureAwait(false); } /// public async Task DeleteManyAsync(Expression> expression, bool isSoftDelete, CancellationToken token = default) { - return await _repository.DeleteManyAsync(expression, isSoftDelete, token); + return await _repository.DeleteManyAsync(expression, isSoftDelete, token).ConfigureAwait(false); } // Cached items — these overloads check the cache first and fall through to the inner repository on a miss. @@ -230,32 +230,32 @@ public async Task> FindAsync(object cacheKey, Expression object>> orderByExpression, bool orderByAscending, int pageNumber = 1, int pageSize = 0, CancellationToken token = default) { var data = await _cacheService.GetOrCreateAsync(cacheKey, - async () => await _repository.FindAsync(expression, orderByExpression, orderByAscending, pageNumber, pageSize, token)); - return await data; + async () => await _repository.FindAsync(expression, orderByExpression, orderByAscending, pageNumber, pageSize, token).ConfigureAwait(false)).ConfigureAwait(false); + return await data.ConfigureAwait(false); } /// public async Task> FindAsync(object cacheKey, IPagedSpecification specification, CancellationToken token = default) { var data = await _cacheService.GetOrCreateAsync(cacheKey, - async () => await _repository.FindAsync(specification, token)); - return await data; + async () => await _repository.FindAsync(specification, token).ConfigureAwait(false)).ConfigureAwait(false); + return await data.ConfigureAwait(false); } /// public async Task> FindAsync(object cacheKey, ISpecification specification, CancellationToken token = default) { var data = await _cacheService.GetOrCreateAsync(cacheKey, - async () => await _repository.FindAsync(specification, token)); - return await data; + async () => await _repository.FindAsync(specification, token).ConfigureAwait(false)).ConfigureAwait(false); + return await data.ConfigureAwait(false); } /// public async Task> FindAsync(object cacheKey, Expression> expression, CancellationToken token = default) { var data = await _cacheService.GetOrCreateAsync(cacheKey, - async () => await _repository.FindAsync(expression, token)); - return await data; + async () => await _repository.FindAsync(expression, token).ConfigureAwait(false)).ConfigureAwait(false); + return await data.ConfigureAwait(false); } /// @@ -266,7 +266,7 @@ public async Task> FindAsync(object cacheKey, Expression entities, CancellationToken token = default) { if (entities == null) throw new ArgumentNullException(nameof(entities)); - await _repository.AddRangeAsync(entities, token); + await _repository.AddRangeAsync(entities, token).ConfigureAwait(false); } } } diff --git a/Src/RCommon.Persistence.Caching/Crud/CachingSqlMapperRepository.cs b/Src/RCommon.Persistence.Caching/Crud/CachingSqlMapperRepository.cs index 8a48da17..adb8f95c 100644 --- a/Src/RCommon.Persistence.Caching/Crud/CachingSqlMapperRepository.cs +++ b/Src/RCommon.Persistence.Caching/Crud/CachingSqlMapperRepository.cs @@ -48,103 +48,103 @@ public CachingSqlMapperRepository(ISqlMapperRepository repository, ICom /// public async Task AddAsync(TEntity entity, CancellationToken token = default) { - await _repository.AddAsync(entity, token); + await _repository.AddAsync(entity, token).ConfigureAwait(false); } /// public async Task AnyAsync(System.Linq.Expressions.Expression> expression, CancellationToken token = default) { - return await _repository.AnyAsync(expression, token); + return await _repository.AnyAsync(expression, token).ConfigureAwait(false); } /// public async Task AnyAsync(ISpecification specification, CancellationToken token = default) { - return await _repository.AnyAsync(specification, token); + return await _repository.AnyAsync(specification, token).ConfigureAwait(false); } /// public async Task DeleteAsync(TEntity entity, CancellationToken token = default) { - await _repository.DeleteAsync(entity, token); + await _repository.DeleteAsync(entity, token).ConfigureAwait(false); } /// public async Task DeleteAsync(TEntity entity, bool isSoftDelete, CancellationToken token = default) { - await _repository.DeleteAsync(entity, isSoftDelete, token); + await _repository.DeleteAsync(entity, isSoftDelete, token).ConfigureAwait(false); } /// public async Task> FindAsync(ISpecification specification, CancellationToken token = default) { - return await _repository.FindAsync(specification, token); + return await _repository.FindAsync(specification, token).ConfigureAwait(false); } /// public async Task> FindAsync(System.Linq.Expressions.Expression> expression, CancellationToken token = default) { - return await _repository.FindAsync(expression, token); + return await _repository.FindAsync(expression, token).ConfigureAwait(false); } /// public async Task FindAsync(object primaryKey, CancellationToken token = default) { - return await _repository.FindAsync(primaryKey, token); + return await _repository.FindAsync(primaryKey, token).ConfigureAwait(false); } /// public async Task FindSingleOrDefaultAsync(System.Linq.Expressions.Expression> expression, CancellationToken token = default) { - return await _repository.FindSingleOrDefaultAsync(expression, token); + return await _repository.FindSingleOrDefaultAsync(expression, token).ConfigureAwait(false); } /// public async Task FindSingleOrDefaultAsync(ISpecification specification, CancellationToken token = default) { - return await _repository.FindSingleOrDefaultAsync(specification, token); + return await _repository.FindSingleOrDefaultAsync(specification, token).ConfigureAwait(false); } /// public async Task GetCountAsync(ISpecification selectSpec, CancellationToken token = default) { - return await _repository.GetCountAsync(selectSpec, token); + return await _repository.GetCountAsync(selectSpec, token).ConfigureAwait(false); } /// public async Task GetCountAsync(System.Linq.Expressions.Expression> expression, CancellationToken token = default) { - return await _repository.GetCountAsync(expression, token); + return await _repository.GetCountAsync(expression, token).ConfigureAwait(false); } /// public async Task UpdateAsync(TEntity entity, CancellationToken token = default) { - await _repository.UpdateAsync(entity, token); + await _repository.UpdateAsync(entity, token).ConfigureAwait(false); } /// public async Task DeleteManyAsync(ISpecification specification, CancellationToken token = default) { - return await _repository.DeleteManyAsync(specification, token); + return await _repository.DeleteManyAsync(specification, token).ConfigureAwait(false); } /// public async Task DeleteManyAsync(ISpecification specification, bool isSoftDelete, CancellationToken token = default) { - return await _repository.DeleteManyAsync(specification, isSoftDelete, token); + return await _repository.DeleteManyAsync(specification, isSoftDelete, token).ConfigureAwait(false); } /// public async Task DeleteManyAsync(Expression> expression, CancellationToken token = default) { - return await _repository.DeleteManyAsync(expression, token); + return await _repository.DeleteManyAsync(expression, token).ConfigureAwait(false); } /// public async Task DeleteManyAsync(Expression> expression, bool isSoftDelete, CancellationToken token = default) { - return await _repository.DeleteManyAsync(expression, isSoftDelete, token); + return await _repository.DeleteManyAsync(expression, isSoftDelete, token).ConfigureAwait(false); } // Cached Items — these overloads check the cache first and fall through to the inner repository on a miss. @@ -153,16 +153,16 @@ public async Task DeleteManyAsync(Expression> expressio public async Task> FindAsync(object cacheKey, ISpecification specification, CancellationToken token = default) { var data = await _cacheService.GetOrCreateAsync(cacheKey, - async () => await _repository.FindAsync(specification, token)); - return await data; + async () => await _repository.FindAsync(specification, token).ConfigureAwait(false)).ConfigureAwait(false); + return await data.ConfigureAwait(false); } /// public async Task> FindAsync(object cacheKey, System.Linq.Expressions.Expression> expression, CancellationToken token = default) { var data = await _cacheService.GetOrCreateAsync(cacheKey, - async () => await _repository.FindAsync(expression, token)); - return await data; + async () => await _repository.FindAsync(expression, token).ConfigureAwait(false)).ConfigureAwait(false); + return await data.ConfigureAwait(false); } /// @@ -173,7 +173,7 @@ public async Task> FindAsync(object cacheKey, System.Linq.E public async Task AddRangeAsync(IEnumerable entities, CancellationToken token = default) { if (entities == null) throw new ArgumentNullException(nameof(entities)); - await _repository.AddRangeAsync(entities, token); + await _repository.AddRangeAsync(entities, token).ConfigureAwait(false); } } } diff --git a/Src/RCommon.Persistence/Crud/IAggregateRepository.cs b/Src/RCommon.Persistence/Crud/IAggregateRepository.cs new file mode 100644 index 00000000..3f6ebf2c --- /dev/null +++ b/Src/RCommon.Persistence/Crud/IAggregateRepository.cs @@ -0,0 +1,30 @@ +using System; +using System.Linq.Expressions; +using System.Threading; +using System.Threading.Tasks; +using RCommon.Entities; + +namespace RCommon.Persistence.Crud; + +/// +/// DDD-constrained repository for aggregate roots. Provides only aggregate-appropriate +/// operations: load by ID, find by specification, existence check, add, update, delete, +/// and eager loading. Does not expose IQueryable or collection queries. +/// +public interface IAggregateRepository : INamedDataSource + where TAggregate : class, IAggregateRoot + where TKey : IEquatable +{ + Task GetByIdAsync(TKey id, CancellationToken cancellationToken = default); + Task FindAsync(ISpecification specification, CancellationToken cancellationToken = default); + Task ExistsAsync(TKey id, CancellationToken cancellationToken = default); + + Task AddAsync(TAggregate aggregate, CancellationToken cancellationToken = default); + Task UpdateAsync(TAggregate aggregate, CancellationToken cancellationToken = default); + Task DeleteAsync(TAggregate aggregate, CancellationToken cancellationToken = default); + + IAggregateRepository Include( + Expression> path); + IAggregateRepository ThenInclude( + Expression> path); +} diff --git a/Src/RCommon.Persistence/Crud/IReadModelRepository.cs b/Src/RCommon.Persistence/Crud/IReadModelRepository.cs new file mode 100644 index 00000000..b615ca3e --- /dev/null +++ b/Src/RCommon.Persistence/Crud/IReadModelRepository.cs @@ -0,0 +1,36 @@ +using System; +using System.Collections.Generic; +using System.Linq.Expressions; +using System.Threading; +using System.Threading.Tasks; +using RCommon; +using RCommon.Models; + +namespace RCommon.Persistence.Crud; + +public interface IReadModelRepository : INamedDataSource + where TReadModel : class, IReadModel +{ + Task FindAsync( + ISpecification specification, + CancellationToken cancellationToken = default); + + Task> FindAllAsync( + ISpecification specification, + CancellationToken cancellationToken = default); + + Task> GetPagedAsync( + IPagedSpecification specification, + CancellationToken cancellationToken = default); + + Task GetCountAsync( + ISpecification specification, + CancellationToken cancellationToken = default); + + Task AnyAsync( + ISpecification specification, + CancellationToken cancellationToken = default); + + IReadModelRepository Include( + Expression> path); +} diff --git a/Src/RCommon.Persistence/IReadModel.cs b/Src/RCommon.Persistence/IReadModel.cs index 590457a1..3e0d0c27 100644 --- a/Src/RCommon.Persistence/IReadModel.cs +++ b/Src/RCommon.Persistence/IReadModel.cs @@ -1,19 +1,7 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; +namespace RCommon.Persistence; -namespace RCommon.Persistence -{ - /// - /// Marker interface for read model entities used in CQRS-style query projections. - /// - /// - /// Implementing this interface signals that the entity is intended for read-only query scenarios - /// and should not be used for write (command) operations. - /// - public interface IReadModel - { - } -} +/// +/// Marker interface for read-model/projection types used in CQRS query-side repositories. +/// Read models are optimized for querying and do not participate in domain event tracking. +/// +public interface IReadModel { } diff --git a/Src/RCommon.Persistence/Inbox/IInboxMessage.cs b/Src/RCommon.Persistence/Inbox/IInboxMessage.cs new file mode 100644 index 00000000..0a84326d --- /dev/null +++ b/Src/RCommon.Persistence/Inbox/IInboxMessage.cs @@ -0,0 +1,11 @@ +using System; + +namespace RCommon.Persistence.Inbox; + +public interface IInboxMessage +{ + Guid MessageId { get; } + string EventType { get; } + string? ConsumerType { get; } + DateTimeOffset ReceivedAtUtc { get; } +} diff --git a/Src/RCommon.Persistence/Inbox/IInboxStore.cs b/Src/RCommon.Persistence/Inbox/IInboxStore.cs new file mode 100644 index 00000000..9c888a28 --- /dev/null +++ b/Src/RCommon.Persistence/Inbox/IInboxStore.cs @@ -0,0 +1,12 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace RCommon.Persistence.Inbox; + +public interface IInboxStore +{ + Task ExistsAsync(Guid messageId, string? consumerType = null, CancellationToken cancellationToken = default); + Task RecordAsync(IInboxMessage message, CancellationToken cancellationToken = default); + Task CleanupAsync(TimeSpan olderThan, CancellationToken cancellationToken = default); +} diff --git a/Src/RCommon.Persistence/Inbox/InboxMessage.cs b/Src/RCommon.Persistence/Inbox/InboxMessage.cs new file mode 100644 index 00000000..f3dbd06c --- /dev/null +++ b/Src/RCommon.Persistence/Inbox/InboxMessage.cs @@ -0,0 +1,11 @@ +using System; + +namespace RCommon.Persistence.Inbox; + +public class InboxMessage : IInboxMessage +{ + public Guid MessageId { get; set; } + public string EventType { get; set; } = string.Empty; + public string? ConsumerType { get; set; } + public DateTimeOffset ReceivedAtUtc { get; set; } +} diff --git a/Src/RCommon.Persistence/Inbox/InboxPersistenceBuilderExtensions.cs b/Src/RCommon.Persistence/Inbox/InboxPersistenceBuilderExtensions.cs new file mode 100644 index 00000000..978a3e92 --- /dev/null +++ b/Src/RCommon.Persistence/Inbox/InboxPersistenceBuilderExtensions.cs @@ -0,0 +1,14 @@ +using Microsoft.Extensions.DependencyInjection; +using RCommon.Persistence.Inbox; + +namespace RCommon; + +public static class InboxPersistenceBuilderExtensions +{ + public static IPersistenceBuilder AddInbox(this IPersistenceBuilder builder) + where TInboxStore : class, IInboxStore + { + builder.Services.AddScoped(); + return builder; + } +} diff --git a/Src/RCommon.Persistence/Outbox/ExponentialBackoffStrategy.cs b/Src/RCommon.Persistence/Outbox/ExponentialBackoffStrategy.cs new file mode 100644 index 00000000..45b7666a --- /dev/null +++ b/Src/RCommon.Persistence/Outbox/ExponentialBackoffStrategy.cs @@ -0,0 +1,30 @@ +using System; + +namespace RCommon.Persistence.Outbox; + +public class ExponentialBackoffStrategy : IBackoffStrategy +{ + private readonly TimeSpan _baseDelay; + private readonly TimeSpan _maxDelay; + private readonly double _multiplier; + + public ExponentialBackoffStrategy(TimeSpan baseDelay, TimeSpan maxDelay, double multiplier = 2.0) + { + if (baseDelay <= TimeSpan.Zero) + throw new ArgumentOutOfRangeException(nameof(baseDelay), "Base delay must be positive."); + if (maxDelay < baseDelay) + throw new ArgumentOutOfRangeException(nameof(maxDelay), "Max delay must be greater than or equal to base delay."); + if (multiplier <= 1.0) + throw new ArgumentOutOfRangeException(nameof(multiplier), "Multiplier must be greater than 1.0 for exponential growth."); + + _baseDelay = baseDelay; + _maxDelay = maxDelay; + _multiplier = multiplier; + } + + public TimeSpan ComputeDelay(int retryCount) + => TimeSpan.FromSeconds( + Math.Min( + _baseDelay.TotalSeconds * Math.Pow(_multiplier, retryCount), + _maxDelay.TotalSeconds)); +} diff --git a/Src/RCommon.Persistence/Outbox/IBackoffStrategy.cs b/Src/RCommon.Persistence/Outbox/IBackoffStrategy.cs new file mode 100644 index 00000000..3c5e8e24 --- /dev/null +++ b/Src/RCommon.Persistence/Outbox/IBackoffStrategy.cs @@ -0,0 +1,8 @@ +using System; + +namespace RCommon.Persistence.Outbox; + +public interface IBackoffStrategy +{ + TimeSpan ComputeDelay(int retryCount); +} diff --git a/Src/RCommon.Persistence/Outbox/ILockStatementProvider.cs b/Src/RCommon.Persistence/Outbox/ILockStatementProvider.cs new file mode 100644 index 00000000..cf377e8b --- /dev/null +++ b/Src/RCommon.Persistence/Outbox/ILockStatementProvider.cs @@ -0,0 +1,6 @@ +namespace RCommon.Persistence.Outbox; + +public interface ILockStatementProvider +{ + string ProviderName { get; } +} diff --git a/Src/RCommon.Persistence/Outbox/IOutboxMessage.cs b/Src/RCommon.Persistence/Outbox/IOutboxMessage.cs new file mode 100644 index 00000000..e51dc4d2 --- /dev/null +++ b/Src/RCommon.Persistence/Outbox/IOutboxMessage.cs @@ -0,0 +1,20 @@ +using System; + +namespace RCommon.Persistence.Outbox; + +public interface IOutboxMessage +{ + Guid Id { get; } + string EventType { get; } + string EventPayload { get; } + DateTimeOffset CreatedAtUtc { get; } + DateTimeOffset? ProcessedAtUtc { get; set; } + DateTimeOffset? DeadLetteredAtUtc { get; set; } + string? ErrorMessage { get; set; } + int RetryCount { get; set; } + string? CorrelationId { get; set; } + string? TenantId { get; set; } + DateTimeOffset? NextRetryAtUtc { get; set; } + string? LockedByInstanceId { get; set; } + DateTimeOffset? LockedUntilUtc { get; set; } +} diff --git a/Src/RCommon.Persistence/Outbox/IOutboxSerializer.cs b/Src/RCommon.Persistence/Outbox/IOutboxSerializer.cs new file mode 100644 index 00000000..14ffa675 --- /dev/null +++ b/Src/RCommon.Persistence/Outbox/IOutboxSerializer.cs @@ -0,0 +1,11 @@ +using RCommon.Models.Events; + +namespace RCommon.Persistence.Outbox; + +public interface IOutboxSerializer +{ + string Serialize(ISerializableEvent @event); + string GetEventTypeName(ISerializableEvent @event); + ISerializableEvent Deserialize(string eventType, string payload); +} + diff --git a/Src/RCommon.Persistence/Outbox/IOutboxStore.cs b/Src/RCommon.Persistence/Outbox/IOutboxStore.cs new file mode 100644 index 00000000..e50526c2 --- /dev/null +++ b/Src/RCommon.Persistence/Outbox/IOutboxStore.cs @@ -0,0 +1,19 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace RCommon.Persistence.Outbox; + +public interface IOutboxStore +{ + Task SaveAsync(IOutboxMessage message, CancellationToken cancellationToken = default); + Task MarkProcessedAsync(Guid messageId, CancellationToken cancellationToken = default); + Task MarkFailedAsync(Guid messageId, string error, DateTimeOffset nextRetryAtUtc, CancellationToken cancellationToken = default); + Task MarkDeadLetteredAsync(Guid messageId, CancellationToken cancellationToken = default); + Task DeleteProcessedAsync(TimeSpan olderThan, CancellationToken cancellationToken = default); + Task DeleteDeadLetteredAsync(TimeSpan olderThan, CancellationToken cancellationToken = default); + Task> ClaimAsync(string instanceId, int batchSize, TimeSpan lockDuration, CancellationToken cancellationToken = default); + Task> GetDeadLettersAsync(int batchSize, int offset = 0, CancellationToken cancellationToken = default); + Task ReplayDeadLetterAsync(Guid messageId, CancellationToken cancellationToken = default); +} diff --git a/Src/RCommon.Persistence/Outbox/JsonOutboxSerializer.cs b/Src/RCommon.Persistence/Outbox/JsonOutboxSerializer.cs new file mode 100644 index 00000000..9c310582 --- /dev/null +++ b/Src/RCommon.Persistence/Outbox/JsonOutboxSerializer.cs @@ -0,0 +1,43 @@ +using System; +using System.Text.Json; +using RCommon; +using RCommon.Models.Events; + +namespace RCommon.Persistence.Outbox; + +public class JsonOutboxSerializer : IOutboxSerializer +{ + public string Serialize(ISerializableEvent @event) + { + Guard.IsNotNull(@event, nameof(@event)); + return JsonSerializer.Serialize(@event, @event.GetType()); + } + + public string GetEventTypeName(ISerializableEvent @event) + { + Guard.IsNotNull(@event, nameof(@event)); + var type = @event.GetType(); + return $"{type.FullName}, {type.Assembly.GetName().Name}"; + } + + public ISerializableEvent Deserialize(string eventType, string payload) + { + Guard.IsNotNull(eventType, nameof(eventType)); + Guard.IsNotNull(payload, nameof(payload)); + + var type = Type.GetType(eventType) + ?? throw new InvalidOperationException($"Cannot resolve type '{eventType}'."); + + if (!typeof(ISerializableEvent).IsAssignableFrom(type)) + { + throw new InvalidOperationException( + $"Type '{eventType}' does not implement ISerializableEvent."); + } + + var result = JsonSerializer.Deserialize(payload, type) + ?? throw new InvalidOperationException( + $"Deserialization of '{eventType}' returned null."); + + return (ISerializableEvent)result; + } +} diff --git a/Src/RCommon.Persistence/Outbox/OutboxEntityEventTracker.cs b/Src/RCommon.Persistence/Outbox/OutboxEntityEventTracker.cs new file mode 100644 index 00000000..01b30826 --- /dev/null +++ b/Src/RCommon.Persistence/Outbox/OutboxEntityEventTracker.cs @@ -0,0 +1,88 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using RCommon.Entities; +using RCommon.EventHandling.Producers; + +namespace RCommon.Persistence.Outbox; + +/// +/// A decorator over that implements the two-phase +/// transactional outbox pattern for domain event persistence. +/// +/// +/// This tracker adds two-phase commit behaviour on top of the in-memory tracker: +/// +/// +/// (Phase 1, within transaction): Walks each tracked entity's +/// object graph to collect domain events, adds them to the buffer, +/// then calls to flush them to the +/// within the active transaction. +/// +/// +/// (Phase 3, post-commit): Delegates to +/// which reads pending messages from the store +/// and dispatches them to registered event producers. +/// +/// +/// +public class OutboxEntityEventTracker : IEntityEventTracker +{ + private readonly InMemoryEntityEventTracker _inner; + private readonly OutboxEventRouter _outboxRouter; + + /// + /// Initializes a new instance of . + /// + /// The inner in-memory tracker that manages the entity collection. + /// The outbox router used to buffer and persist events. + /// + /// Thrown when or is null. + /// + public OutboxEntityEventTracker(InMemoryEntityEventTracker inner, OutboxEventRouter outboxRouter) + { + _inner = inner ?? throw new ArgumentNullException(nameof(inner)); + _outboxRouter = outboxRouter ?? throw new ArgumentNullException(nameof(outboxRouter)); + } + + /// + public void AddEntity(IBusinessEntity entity) => _inner.AddEntity(entity); + + /// + public ICollection TrackedEntities => _inner.TrackedEntities; + + /// + /// + /// Walks the object graph of each tracked entity to collect domain events, buffers them in the + /// , then flushes the buffer to the + /// within the active transaction (Phase 1). + /// + public async Task PersistEventsAsync(CancellationToken cancellationToken = default) + { + // Walk entity graph and collect events into the router buffer + foreach (var entity in _inner.TrackedEntities) + { + var entityGraph = entity.TraverseGraphFor(); + foreach (var graphEntity in entityGraph) + { + _outboxRouter.AddTransactionalEvents(graphEntity.LocalEvents); + } + } + + // Flush buffer to outbox store (within the active transaction) + await _outboxRouter.PersistBufferedEventsAsync(cancellationToken).ConfigureAwait(false); + } + + /// + /// + /// Delegates to which reads pending messages + /// from the and dispatches them to registered event producers + /// (Phase 3, post-commit). + /// + public async Task EmitTransactionalEventsAsync(CancellationToken cancellationToken = default) + { + await _outboxRouter.RouteEventsAsync(cancellationToken).ConfigureAwait(false); + return true; + } +} diff --git a/Src/RCommon.Persistence/Outbox/OutboxEventRouter.cs b/Src/RCommon.Persistence/Outbox/OutboxEventRouter.cs new file mode 100644 index 00000000..afd50635 --- /dev/null +++ b/Src/RCommon.Persistence/Outbox/OutboxEventRouter.cs @@ -0,0 +1,179 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using RCommon.EventHandling.Producers; +using RCommon.Models.Events; +using RCommon.Security.Claims; + +namespace RCommon.Persistence.Outbox; + +/// +/// An implementation that persists events to an outbox store +/// before dispatching them to instances. +/// +/// +/// This router follows the transactional outbox pattern: +/// +/// and buffer events +/// in-memory without touching the store (called during business logic). +/// drains the buffer and writes +/// rows to within the active transaction (Phase 1). +/// dispatches the retained events (kept in memory after +/// ) to producers and marks each message processed on success — no store +/// reads are performed; failures are logged and retried by the background processor (Phase 3, post-commit). +/// performs direct +/// dispatch without touching the store (for non-outbox routing scenarios). +/// +/// This should be registered as a scoped dependency. +/// +public class OutboxEventRouter : IEventRouter +{ + private readonly IOutboxStore _outboxStore; + private readonly IOutboxSerializer _serializer; + private readonly IGuidGenerator _guidGenerator; + private readonly ITenantIdAccessor _tenantIdAccessor; + private readonly IServiceProvider _serviceProvider; + private readonly EventSubscriptionManager _subscriptionManager; + private readonly ILogger _logger; + private readonly OutboxOptions _options; + private readonly ConcurrentQueue _buffer = new(); + private readonly List<(Guid MessageId, ISerializableEvent Event)> _persistedEvents = new(); + + public OutboxEventRouter( + IOutboxStore outboxStore, + IOutboxSerializer serializer, + IGuidGenerator guidGenerator, + ITenantIdAccessor tenantIdAccessor, + IServiceProvider serviceProvider, + EventSubscriptionManager subscriptionManager, + ILogger logger, + IOptions options) + { + _outboxStore = outboxStore ?? throw new ArgumentNullException(nameof(outboxStore)); + _serializer = serializer ?? throw new ArgumentNullException(nameof(serializer)); + _guidGenerator = guidGenerator ?? throw new ArgumentNullException(nameof(guidGenerator)); + _tenantIdAccessor = tenantIdAccessor ?? throw new ArgumentNullException(nameof(tenantIdAccessor)); + _serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider)); + _subscriptionManager = subscriptionManager ?? throw new ArgumentNullException(nameof(subscriptionManager)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _options = options?.Value ?? throw new ArgumentNullException(nameof(options)); + } + + /// + public void AddTransactionalEvent(ISerializableEvent serializableEvent) + { + Guard.IsNotNull(serializableEvent, nameof(serializableEvent)); + _buffer.Enqueue(serializableEvent); + } + + /// + public void AddTransactionalEvents(IEnumerable serializableEvents) + { + Guard.IsNotNull(serializableEvents, nameof(serializableEvents)); + foreach (var e in serializableEvents) + { + AddTransactionalEvent(e); + } + } + + /// + /// Drains the in-memory buffer and writes each event as an to the + /// . This must be called within the active database transaction (UnitOfWork Phase 1). + /// + /// A token to observe for cancellation requests. + public async Task PersistBufferedEventsAsync(CancellationToken cancellationToken = default) + { + var events = new List(); + while (_buffer.TryDequeue(out var e)) + { + events.Add(e); + } + + foreach (var @event in events) + { + var message = new OutboxMessage + { + Id = _guidGenerator.Create(), + EventType = _serializer.GetEventTypeName(@event), + EventPayload = _serializer.Serialize(@event), + CreatedAtUtc = DateTimeOffset.UtcNow, + TenantId = _tenantIdAccessor.GetTenantId() + // Note: CorrelationId population is left for a future enhancement (V2) + }; + + _logger.LogDebug("Persisting outbox message {Id} for event {EventType}", message.Id, message.EventType); + await _outboxStore.SaveAsync(message, cancellationToken).ConfigureAwait(false); + _persistedEvents.Add((message.Id, @event)); + } + } + + /// + /// Dispatches retained events that were persisted during to registered + /// instances, and marks each message processed on success. Events are dispatched + /// from the in-memory retained list — no store reads are performed. This should be called post-commit + /// (UnitOfWork Phase 3). If dispatch fails for a message, a warning is logged and the background processor + /// will retry via . + /// + /// A token to observe for cancellation requests. + public async Task RouteEventsAsync(CancellationToken cancellationToken = default) + { + if (_persistedEvents.Count == 0) return; + + _logger.LogInformation("OutboxEventRouter dispatching {Count} retained messages", _persistedEvents.Count); + + var producers = _serviceProvider.GetServices(); + + foreach (var (messageId, @event) in _persistedEvents) + { + try + { + var filteredProducers = _subscriptionManager.HasSubscriptions + ? _subscriptionManager.GetProducersForEvent(producers, @event.GetType()) + : producers; + + foreach (var producer in filteredProducers) + { + await producer.ProduceEventAsync(@event, cancellationToken).ConfigureAwait(false); + } + + await _outboxStore.MarkProcessedAsync(messageId, cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Best-effort dispatch failed for message {Id}; background processor will retry", messageId); + } + } + + _persistedEvents.Clear(); + } + + /// + /// Dispatches the provided events directly to registered instances + /// without interacting with the outbox store. Used for non-outbox routing scenarios. + /// + /// The events to dispatch. + /// A token to observe for cancellation requests. + public async Task RouteEventsAsync(IEnumerable transactionalEvents, CancellationToken cancellationToken = default) + { + Guard.IsNotNull(transactionalEvents, nameof(transactionalEvents)); + + var producers = _serviceProvider.GetServices(); + + foreach (var @event in transactionalEvents) + { + var filteredProducers = _subscriptionManager.HasSubscriptions + ? _subscriptionManager.GetProducersForEvent(producers, @event.GetType()) + : producers; + + foreach (var producer in filteredProducers) + { + await producer.ProduceEventAsync(@event, cancellationToken).ConfigureAwait(false); + } + } + } +} diff --git a/Src/RCommon.Persistence/Outbox/OutboxMessage.cs b/Src/RCommon.Persistence/Outbox/OutboxMessage.cs new file mode 100644 index 00000000..75fb0be8 --- /dev/null +++ b/Src/RCommon.Persistence/Outbox/OutboxMessage.cs @@ -0,0 +1,20 @@ +using System; + +namespace RCommon.Persistence.Outbox; + +public class OutboxMessage : IOutboxMessage +{ + public Guid Id { get; set; } + public string EventType { get; set; } = string.Empty; + public string EventPayload { get; set; } = string.Empty; + public DateTimeOffset CreatedAtUtc { get; set; } + public DateTimeOffset? ProcessedAtUtc { get; set; } + public DateTimeOffset? DeadLetteredAtUtc { get; set; } + public string? ErrorMessage { get; set; } + public int RetryCount { get; set; } + public string? CorrelationId { get; set; } + public string? TenantId { get; set; } + public DateTimeOffset? NextRetryAtUtc { get; set; } + public string? LockedByInstanceId { get; set; } + public DateTimeOffset? LockedUntilUtc { get; set; } +} diff --git a/Src/RCommon.Persistence/Outbox/OutboxOptions.cs b/Src/RCommon.Persistence/Outbox/OutboxOptions.cs new file mode 100644 index 00000000..0ca637b7 --- /dev/null +++ b/Src/RCommon.Persistence/Outbox/OutboxOptions.cs @@ -0,0 +1,19 @@ +using System; + +namespace RCommon.Persistence.Outbox; + +public class OutboxOptions +{ + public TimeSpan PollingInterval { get; set; } = TimeSpan.FromSeconds(5); + public int BatchSize { get; set; } = 100; + public int MaxRetries { get; set; } = 5; + public TimeSpan CleanupAge { get; set; } = TimeSpan.FromDays(7); + public TimeSpan CleanupInterval { get; set; } = TimeSpan.FromHours(1); + public string TableName { get; set; } = "__OutboxMessages"; + public TimeSpan LockDuration { get; set; } = TimeSpan.FromMinutes(5); + public TimeSpan BackoffBaseDelay { get; set; } = TimeSpan.FromSeconds(5); + public TimeSpan BackoffMaxDelay { get; set; } = TimeSpan.FromMinutes(30); + public double BackoffMultiplier { get; set; } = 2.0; + public string InboxTableName { get; set; } = "__InboxMessages"; +} + diff --git a/Src/RCommon.Persistence/Outbox/OutboxPersistenceBuilderExtensions.cs b/Src/RCommon.Persistence/Outbox/OutboxPersistenceBuilderExtensions.cs new file mode 100644 index 00000000..c030ef99 --- /dev/null +++ b/Src/RCommon.Persistence/Outbox/OutboxPersistenceBuilderExtensions.cs @@ -0,0 +1,73 @@ +using System; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Options; +using RCommon.Entities; +using RCommon.EventHandling.Producers; +using RCommon.Persistence.Outbox; + +namespace RCommon; + +public static class OutboxPersistenceBuilderExtensions +{ + /// + /// Registers the transactional outbox pattern services into the DI container. + /// + /// The implementation to register (scoped). + /// The persistence builder to extend. + /// Optional action to configure . + /// The for fluent chaining. + /// + /// Registration details: + /// + /// — scoped () + /// — singleton (, replaceable via TryAddSingleton) + /// — scoped (concrete registration) + /// — scoped (forwards to ) + /// — scoped (required by ) + /// — scoped () + /// — hosted service (singleton) + /// + /// + public static IPersistenceBuilder AddOutbox( + this IPersistenceBuilder builder, + Action? configure = null) + where TOutboxStore : class, IOutboxStore + { + // Outbox store (scoped — participates in per-request transaction) + builder.Services.AddScoped(); + + // Serializer (singleton, replaceable) + builder.Services.TryAddSingleton(); + + // Outbox event router (scoped — replaces InMemoryTransactionalEventRouter) + builder.Services.AddScoped(); + builder.Services.AddScoped(sp => sp.GetRequiredService()); + + // Entity event tracker decorator (scoped — replaces InMemoryEntityEventTracker) + builder.Services.AddScoped(); + builder.Services.AddScoped(); + + // Background processing service (singleton) + builder.Services.AddHostedService(); + + // Options + if (configure != null) + { + builder.Services.Configure(configure); + } + else + { + builder.Services.Configure(_ => { }); + } + + // Backoff strategy (singleton, replaceable) + builder.Services.TryAddSingleton(sp => + { + var opts = sp.GetRequiredService>().Value; + return new ExponentialBackoffStrategy(opts.BackoffBaseDelay, opts.BackoffMaxDelay, opts.BackoffMultiplier); + }); + + return builder; + } +} diff --git a/Src/RCommon.Persistence/Outbox/OutboxProcessingService.cs b/Src/RCommon.Persistence/Outbox/OutboxProcessingService.cs new file mode 100644 index 00000000..5843164b --- /dev/null +++ b/Src/RCommon.Persistence/Outbox/OutboxProcessingService.cs @@ -0,0 +1,138 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using RCommon.EventHandling.Producers; +using RCommon.Models.Events; +using RCommon.Persistence.Inbox; + +namespace RCommon.Persistence.Outbox; + +public class OutboxProcessingService : BackgroundService +{ + private readonly IServiceProvider _serviceProvider; + private readonly OutboxOptions _options; + private readonly ILogger _logger; + private readonly IBackoffStrategy _backoffStrategy; + private readonly string _instanceId = Guid.NewGuid().ToString("N"); + private DateTimeOffset _lastCleanupUtc = DateTimeOffset.MinValue; + + public OutboxProcessingService( + IServiceProvider serviceProvider, + IOptions options, + ILogger logger, + IBackoffStrategy backoffStrategy) + { + _serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider)); + _options = options?.Value ?? throw new ArgumentNullException(nameof(options)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _backoffStrategy = backoffStrategy ?? throw new ArgumentNullException(nameof(backoffStrategy)); + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + _logger.LogInformation("OutboxProcessingService started. Polling every {Interval}s", _options.PollingInterval.TotalSeconds); + + while (!stoppingToken.IsCancellationRequested) + { + try + { + await ProcessBatchAsync(stoppingToken).ConfigureAwait(false); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + _logger.LogError(ex, "OutboxProcessingService encountered an error during polling"); + } + + await Task.Delay(_options.PollingInterval, stoppingToken).ConfigureAwait(false); + } + } + + public async Task ProcessBatchAsync(CancellationToken cancellationToken) + { + using var scope = _serviceProvider.CreateScope(); + var store = scope.ServiceProvider.GetRequiredService(); + var serializer = scope.ServiceProvider.GetRequiredService(); + var producers = scope.ServiceProvider.GetServices(); + var subscriptionManager = scope.ServiceProvider.GetRequiredService(); + var inboxStore = scope.ServiceProvider.GetService(); + + var pending = await store.ClaimAsync(_instanceId, _options.BatchSize, _options.LockDuration, cancellationToken).ConfigureAwait(false); + + foreach (var message in pending) + { + try + { + if (message.RetryCount >= _options.MaxRetries) + { + _logger.LogWarning("Outbox message {Id} exceeded max retries ({Max}). Dead-lettering.", + message.Id, _options.MaxRetries); + await store.MarkDeadLetteredAsync(message.Id, cancellationToken).ConfigureAwait(false); + continue; + } + + // Inbox auto-check: skip if already processed + if (inboxStore != null && await inboxStore.ExistsAsync(message.Id, cancellationToken: cancellationToken).ConfigureAwait(false)) + { + _logger.LogDebug("Outbox message {Id} already in inbox, marking processed", message.Id); + await store.MarkProcessedAsync(message.Id, cancellationToken).ConfigureAwait(false); + continue; + } + + var @event = serializer.Deserialize(message.EventType, message.EventPayload); + var filteredProducers = subscriptionManager.HasSubscriptions + ? subscriptionManager.GetProducersForEvent(producers, @event.GetType()) + : producers; + + foreach (var producer in filteredProducers) + { + await producer.ProduceEventAsync((dynamic)@event, cancellationToken).ConfigureAwait(false); + } + + // Record in inbox after successful dispatch + if (inboxStore != null) + { + await inboxStore.RecordAsync(new InboxMessage + { + MessageId = message.Id, + EventType = message.EventType, + ReceivedAtUtc = DateTimeOffset.UtcNow + }, cancellationToken).ConfigureAwait(false); + } + + await store.MarkProcessedAsync(message.Id, cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + _logger.LogWarning(ex, "Failed to dispatch outbox message {Id} (retry {Retry})", + message.Id, message.RetryCount); + + if (message.RetryCount + 1 >= _options.MaxRetries) + { + await store.MarkDeadLetteredAsync(message.Id, cancellationToken).ConfigureAwait(false); + } + else + { + var delay = _backoffStrategy.ComputeDelay(message.RetryCount + 1); + await store.MarkFailedAsync(message.Id, ex.Message, DateTimeOffset.UtcNow + delay, cancellationToken).ConfigureAwait(false); + } + } + } + + // Periodic cleanup (throttled by CleanupInterval) + if (DateTimeOffset.UtcNow - _lastCleanupUtc >= _options.CleanupInterval) + { + await store.DeleteProcessedAsync(_options.CleanupAge, cancellationToken).ConfigureAwait(false); + await store.DeleteDeadLetteredAsync(_options.CleanupAge, cancellationToken).ConfigureAwait(false); + if (inboxStore != null) + { + await inboxStore.CleanupAsync(_options.CleanupAge, cancellationToken).ConfigureAwait(false); + } + _lastCleanupUtc = DateTimeOffset.UtcNow; + } + } +} diff --git a/Src/RCommon.Persistence/Outbox/PostgreSqlLockStatementProvider.cs b/Src/RCommon.Persistence/Outbox/PostgreSqlLockStatementProvider.cs new file mode 100644 index 00000000..1e4acb7b --- /dev/null +++ b/Src/RCommon.Persistence/Outbox/PostgreSqlLockStatementProvider.cs @@ -0,0 +1,6 @@ +namespace RCommon.Persistence.Outbox; + +public class PostgreSqlLockStatementProvider : ILockStatementProvider +{ + public string ProviderName => "PostgreSql"; +} diff --git a/Src/RCommon.Persistence/Outbox/SqlServerLockStatementProvider.cs b/Src/RCommon.Persistence/Outbox/SqlServerLockStatementProvider.cs new file mode 100644 index 00000000..a6ad2978 --- /dev/null +++ b/Src/RCommon.Persistence/Outbox/SqlServerLockStatementProvider.cs @@ -0,0 +1,6 @@ +namespace RCommon.Persistence.Outbox; + +public class SqlServerLockStatementProvider : ILockStatementProvider +{ + public string ProviderName => "SqlServer"; +} diff --git a/Src/RCommon.Persistence/RCommon.Persistence.csproj b/Src/RCommon.Persistence/RCommon.Persistence.csproj index baef4117..4730a284 100644 --- a/Src/RCommon.Persistence/RCommon.Persistence.csproj +++ b/Src/RCommon.Persistence/RCommon.Persistence.csproj @@ -20,6 +20,7 @@ + @@ -46,4 +47,10 @@ + + + + + + diff --git a/Src/RCommon.Persistence/Sagas/ISaga.cs b/Src/RCommon.Persistence/Sagas/ISaga.cs new file mode 100644 index 00000000..067aa615 --- /dev/null +++ b/Src/RCommon.Persistence/Sagas/ISaga.cs @@ -0,0 +1,15 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using RCommon.Models.Events; + +namespace RCommon.Persistence.Sagas; + +public interface ISaga + where TState : SagaState + where TKey : IEquatable +{ + Task HandleAsync(TEvent @event, TState state, CancellationToken ct = default) + where TEvent : ISerializableEvent; + Task CompensateAsync(TState state, CancellationToken ct = default); +} diff --git a/Src/RCommon.Persistence/Sagas/ISagaStore.cs b/Src/RCommon.Persistence/Sagas/ISagaStore.cs new file mode 100644 index 00000000..28d659bf --- /dev/null +++ b/Src/RCommon.Persistence/Sagas/ISagaStore.cs @@ -0,0 +1,15 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace RCommon.Persistence.Sagas; + +public interface ISagaStore + where TState : SagaState + where TKey : IEquatable +{ + Task FindByCorrelationIdAsync(string correlationId, CancellationToken ct = default); + Task GetByIdAsync(TKey id, CancellationToken ct = default); + Task SaveAsync(TState state, CancellationToken ct = default); + Task DeleteAsync(TState state, CancellationToken ct = default); +} diff --git a/Src/RCommon.Persistence/Sagas/InMemorySagaStore.cs b/Src/RCommon.Persistence/Sagas/InMemorySagaStore.cs new file mode 100644 index 00000000..cb071055 --- /dev/null +++ b/Src/RCommon.Persistence/Sagas/InMemorySagaStore.cs @@ -0,0 +1,38 @@ +using System; +using System.Collections.Concurrent; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +namespace RCommon.Persistence.Sagas; + +public class InMemorySagaStore : ISagaStore + where TState : SagaState + where TKey : IEquatable +{ + private readonly ConcurrentDictionary _store = new(); + + public Task GetByIdAsync(TKey id, CancellationToken ct = default) + { + _store.TryGetValue(id, out var state); + return Task.FromResult(state); + } + + public Task FindByCorrelationIdAsync(string correlationId, CancellationToken ct = default) + { + var state = _store.Values.FirstOrDefault(s => s.CorrelationId == correlationId); + return Task.FromResult(state); + } + + public Task SaveAsync(TState state, CancellationToken ct = default) + { + _store.AddOrUpdate(state.Id, state, (_, _) => state); + return Task.CompletedTask; + } + + public Task DeleteAsync(TState state, CancellationToken ct = default) + { + _store.TryRemove(state.Id, out _); + return Task.CompletedTask; + } +} diff --git a/Src/RCommon.Persistence/Sagas/SagaOrchestrator.cs b/Src/RCommon.Persistence/Sagas/SagaOrchestrator.cs new file mode 100644 index 00000000..f77de353 --- /dev/null +++ b/Src/RCommon.Persistence/Sagas/SagaOrchestrator.cs @@ -0,0 +1,67 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using RCommon.Models.Events; +using RCommon.StateMachines; + +namespace RCommon.Persistence.Sagas; + +public abstract class SagaOrchestrator + : ISaga + where TState : SagaState + where TKey : IEquatable + where TSagaState : struct, Enum + where TSagaTrigger : struct, Enum +{ + private readonly IStateMachineConfigurator _configurator; + private IStateMachine? _stateMachineTemplate; + + protected ISagaStore Store { get; } + + protected SagaOrchestrator( + ISagaStore store, + IStateMachineConfigurator configurator) + { + Store = store; + _configurator = configurator; + } + + protected abstract void ConfigureStateMachine( + IStateMachineConfigurator configurator); + + protected abstract TSagaTrigger MapEventToTrigger(TEvent @event) + where TEvent : ISerializableEvent; + + protected abstract TSagaState InitialState { get; } + + private void EnsureConfigured() + { + if (_stateMachineTemplate == null) + { + ConfigureStateMachine(_configurator); + _stateMachineTemplate = _configurator.Build(InitialState); + } + } + + public async Task HandleAsync(TEvent @event, TState state, CancellationToken ct = default) + where TEvent : ISerializableEvent + { + EnsureConfigured(); + + var currentState = string.IsNullOrEmpty(state.CurrentStep) + ? InitialState + : Enum.Parse(state.CurrentStep); + + var machine = _configurator.Build(currentState); + var trigger = MapEventToTrigger(@event); + + if (!machine.CanFire(trigger)) + return; + + await machine.FireAsync(trigger, ct).ConfigureAwait(false); + state.CurrentStep = machine.CurrentState.ToString()!; + await Store.SaveAsync(state, ct).ConfigureAwait(false); + } + + public abstract Task CompensateAsync(TState state, CancellationToken ct = default); +} diff --git a/Src/RCommon.Persistence/Sagas/SagaState.cs b/Src/RCommon.Persistence/Sagas/SagaState.cs new file mode 100644 index 00000000..982646cd --- /dev/null +++ b/Src/RCommon.Persistence/Sagas/SagaState.cs @@ -0,0 +1,17 @@ +using System; + +namespace RCommon.Persistence.Sagas; + +public abstract class SagaState + where TKey : IEquatable +{ + public TKey Id { get; set; } = default!; + public string CorrelationId { get; set; } = default!; + public DateTimeOffset StartedAt { get; set; } + public DateTimeOffset? CompletedAt { get; set; } + public string CurrentStep { get; set; } = default!; + public bool IsCompleted { get; set; } + public bool IsFaulted { get; set; } + public string? FaultReason { get; set; } + public int Version { get; set; } +} diff --git a/Src/RCommon.Persistence/Transactions/IUnitOfWork.cs b/Src/RCommon.Persistence/Transactions/IUnitOfWork.cs index 93eb218f..fb00356c 100644 --- a/Src/RCommon.Persistence/Transactions/IUnitOfWork.cs +++ b/Src/RCommon.Persistence/Transactions/IUnitOfWork.cs @@ -1,4 +1,5 @@ using System; +using System.Threading; using System.Threading.Tasks; using System.Transactions; @@ -40,11 +41,21 @@ public interface IUnitOfWork : IDisposable /// TransactionMode TransactionMode { get; set; } + /// + /// Asynchronously commits the unit of work, completing the underlying transaction scope + /// and dispatching any tracked domain events after the transaction is fully committed. + /// + /// A token to monitor for cancellation requests. + /// Thrown if the unit of work has already been disposed. + /// Thrown if the unit of work has already been completed. + Task CommitAsync(CancellationToken cancellationToken = default); + /// /// Commits the unit of work, completing the underlying transaction scope. /// /// Thrown if the unit of work has already been disposed. /// Thrown if the unit of work has already been completed. + [Obsolete("Use CommitAsync instead for automatic domain event dispatch.")] void Commit(); } } diff --git a/Src/RCommon.Persistence/Transactions/UnitOfWork.cs b/Src/RCommon.Persistence/Transactions/UnitOfWork.cs index 18733b68..deebf42d 100644 --- a/Src/RCommon.Persistence/Transactions/UnitOfWork.cs +++ b/Src/RCommon.Persistence/Transactions/UnitOfWork.cs @@ -1,10 +1,12 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; +using RCommon.Entities; using RCommon.EventHandling; using System; using System.Collections.Generic; using System.Linq; using System.Text; +using System.Threading; using System.Threading.Tasks; using System.Transactions; @@ -23,8 +25,10 @@ public class UnitOfWork : DisposableResource, IUnitOfWork { private readonly ILogger _logger; private readonly IGuidGenerator _guidGenerator; + private readonly IEntityEventTracker? _eventTracker; private UnitOfWorkState _state; private TransactionScope _transactionScope; + private bool _transactionScopeDisposed; /// /// Initializes a new instance of the class using configured settings. @@ -32,10 +36,12 @@ public class UnitOfWork : DisposableResource, IUnitOfWork /// The logger for diagnostic output. /// The GUID generator for creating the transaction identifier. /// The configured settings for isolation level and auto-complete behavior. - public UnitOfWork(ILogger logger, IGuidGenerator guidGenerator, IOptions unitOfWorkSettings) + /// Optional entity event tracker for dispatching domain events after commit. + public UnitOfWork(ILogger logger, IGuidGenerator guidGenerator, IOptions unitOfWorkSettings, IEntityEventTracker? eventTracker = null) { _logger = logger; _guidGenerator = guidGenerator; + _eventTracker = eventTracker; TransactionId = _guidGenerator.Create(); TransactionMode = TransactionMode.Default; @@ -52,10 +58,12 @@ public UnitOfWork(ILogger logger, IGuidGenerator guidGenerator, IOpt /// The GUID generator for creating the transaction identifier. /// The transaction mode for this unit of work. /// The isolation level for the underlying transaction. - public UnitOfWork(ILogger logger, IGuidGenerator guidGenerator, TransactionMode transactionMode, IsolationLevel isolationLevel) + /// Optional entity event tracker for dispatching domain events after commit. + public UnitOfWork(ILogger logger, IGuidGenerator guidGenerator, TransactionMode transactionMode, IsolationLevel isolationLevel, IEntityEventTracker? eventTracker = null) { _logger = logger; _guidGenerator = guidGenerator; + _eventTracker = eventTracker; TransactionId = _guidGenerator.Create(); TransactionMode = transactionMode; @@ -66,6 +74,7 @@ public UnitOfWork(ILogger logger, IGuidGenerator guidGenerator, Tran } /// + [Obsolete("Use CommitAsync instead for automatic domain event dispatch.")] public void Commit() { Guard.Against(_state == UnitOfWorkState.Disposed, @@ -77,6 +86,44 @@ public void Commit() this.Complete(); } + /// + public async Task CommitAsync(CancellationToken cancellationToken = default) + { + Guard.Against(_state == UnitOfWorkState.Disposed, + "Cannot commit a disposed UnitOfWorkScope instance."); + Guard.Against(_state == UnitOfWorkState.Completed, + "This unit of work scope has been marked completed."); + + _state = UnitOfWorkState.CommitAttempted; + + // Phase 1: persist events to outbox (within active transaction) + if (_eventTracker != null) + { + await _eventTracker.PersistEventsAsync(cancellationToken).ConfigureAwait(false); + } + + // Phase 2: commit transaction (domain writes + outbox writes atomically) + _transactionScope.Complete(); + _transactionScope.Dispose(); + _transactionScopeDisposed = true; + _state = UnitOfWorkState.Completed; + + // Phase 3: immediate dispatch attempt (best-effort, failures handled by poller) + if (_eventTracker != null) + { + var dispatched = await _eventTracker + .EmitTransactionalEventsAsync(cancellationToken) + .ConfigureAwait(false); + + if (!dispatched) + { + _logger.LogWarning( + "UnitOfWork {TransactionId}: domain event dispatch returned false.", + TransactionId); + } + } + } + /// /// Marks the unit of work as rolled back, preventing the transaction from being committed. /// @@ -130,10 +177,13 @@ protected override void Dispose(bool disposing) } finally { - _transactionScope.Dispose(); + if (!_transactionScopeDisposed) + { + _transactionScope.Dispose(); + } _state = UnitOfWorkState.Disposed; _logger.LogDebug("UnitOfWork {0} Disposed.", TransactionId); - this.Dispose(); + base.Dispose(disposing); } } } diff --git a/Src/RCommon.SendGrid/SendGridEmailService.cs b/Src/RCommon.SendGrid/SendGridEmailService.cs index 0847445b..73583a95 100644 --- a/Src/RCommon.SendGrid/SendGridEmailService.cs +++ b/Src/RCommon.SendGrid/SendGridEmailService.cs @@ -62,12 +62,12 @@ private void OnEmailSent(MailMessage message) /// Converts the to a SendGrid , /// streams any attachments, then sends via the SendGrid API client. /// - public async Task SendEmailAsync(MailMessage message) + public async Task SendEmailAsync(MailMessage message, CancellationToken cancellationToken = default) { // No-op when there are no recipients. if (message.To.Count == 0) { - await Task.CompletedTask; + return; } // Map the MailMessage to a SendGrid message, choosing plain text or HTML based on IsBodyHtml. @@ -83,10 +83,10 @@ public async Task SendEmailAsync(MailMessage message) { foreach (var attachment in message.Attachments) { - await sgMessage.AddAttachmentAsync(attachment.Name, attachment.ContentStream); + await sgMessage.AddAttachmentAsync(attachment.Name, attachment.ContentStream).ConfigureAwait(false); } } - await _client.SendEmailAsync(sgMessage); + await _client.SendEmailAsync(sgMessage).ConfigureAwait(false); OnEmailSent(message); } diff --git a/Src/RCommon.Stateless/DeferredStateConfigurator.cs b/Src/RCommon.Stateless/DeferredStateConfigurator.cs new file mode 100644 index 00000000..a79db7c3 --- /dev/null +++ b/Src/RCommon.Stateless/DeferredStateConfigurator.cs @@ -0,0 +1,72 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using RCommon.StateMachines; + +namespace RCommon.Stateless; + +/// +/// Records calls as deferred actions +/// that are replayed against a real +/// when is called. +/// +/// +/// This enables the consumer pattern where ForState() is called during configuration +/// (before any machine exists) and Build() is called later, potentially multiple times +/// with different initial states, each producing an independent machine. +/// +internal class DeferredStateConfigurator : IStateConfigurator + where TState : struct, Enum + where TTrigger : struct, Enum +{ + private readonly List.StateConfiguration>> _actions = new(); + + /// + public IStateConfigurator Permit(TTrigger trigger, TState destinationState) + { + _actions.Add(config => config.Permit(trigger, destinationState)); + return this; + } + + /// + public IStateConfigurator PermitIf(TTrigger trigger, TState destinationState, Func guard) + { + _actions.Add(config => config.PermitIf(trigger, destinationState, guard)); + return this; + } + + /// + /// + /// Stateless's OnEntryAsync does not accept a . + /// The action is invoked with . + /// + public IStateConfigurator OnEntry(Func action) + { + _actions.Add(config => config.OnEntryAsync(() => action(CancellationToken.None))); + return this; + } + + /// + /// + /// Stateless's OnExitAsync does not accept a . + /// The action is invoked with . + /// + public IStateConfigurator OnExit(Func action) + { + _actions.Add(config => config.OnExitAsync(() => action(CancellationToken.None))); + return this; + } + + /// + /// Replays all recorded configuration actions against the specified state configuration. + /// + /// The Stateless state configuration to apply the deferred actions to. + internal void ApplyTo(global::Stateless.StateMachine.StateConfiguration stateConfig) + { + foreach (var action in _actions) + { + action(stateConfig); + } + } +} diff --git a/Src/RCommon.Stateless/RCommon.Stateless.csproj b/Src/RCommon.Stateless/RCommon.Stateless.csproj new file mode 100644 index 00000000..eb59aeb1 --- /dev/null +++ b/Src/RCommon.Stateless/RCommon.Stateless.csproj @@ -0,0 +1,45 @@ + + + net8.0;net9.0;net10.0 + True + RCommon.Stateless + https://rcommon.com + RCommon; Stateless; State Machine; FSM; Workflow + Apache-2.0 + True + A cohesive set of infrastructure libraries for dotnet that utilizes abstractions for event handling, persistence, unit of work, mediator, distributed messaging, event bus, CQRS, email, and more + https://github.com/RCommon-Team/RCommon + RCommon + Jason Webb + RCommon-Icon.jpg + README.md + enable + enable + + + + + + + + + + + + + True + \ + + + + + + True + \ + + + + + + + diff --git a/Src/RCommon.Stateless/README.md b/Src/RCommon.Stateless/README.md new file mode 100644 index 00000000..c1d0f773 --- /dev/null +++ b/Src/RCommon.Stateless/README.md @@ -0,0 +1,3 @@ +# RCommon.Stateless + +Stateless state machine adapter for the RCommon framework. Wraps the Stateless library to implement `IStateMachine` and related interfaces. diff --git a/Src/RCommon.Stateless/StatelessBuilderExtensions.cs b/Src/RCommon.Stateless/StatelessBuilderExtensions.cs new file mode 100644 index 00000000..8ebb4035 --- /dev/null +++ b/Src/RCommon.Stateless/StatelessBuilderExtensions.cs @@ -0,0 +1,24 @@ +using Microsoft.Extensions.DependencyInjection; +using RCommon.Stateless; +using RCommon.StateMachines; + +namespace RCommon; + +/// +/// Provides extension methods on for registering the Stateless +/// state machine adapter into the RCommon configuration pipeline. +/// +public static class StatelessBuilderExtensions +{ + /// + /// Registers the Stateless library as the + /// implementation, enabling state machine support throughout the application. + /// + /// The RCommon builder instance. + /// The for further chaining. + public static IRCommonBuilder WithStatelessStateMachine(this IRCommonBuilder builder) + { + builder.Services.AddTransient(typeof(IStateMachineConfigurator<,>), typeof(StatelessConfigurator<,>)); + return builder; + } +} diff --git a/Src/RCommon.Stateless/StatelessConfigurator.cs b/Src/RCommon.Stateless/StatelessConfigurator.cs new file mode 100644 index 00000000..81a0d97e --- /dev/null +++ b/Src/RCommon.Stateless/StatelessConfigurator.cs @@ -0,0 +1,55 @@ +using System; +using System.Collections.Generic; +using RCommon.StateMachines; + +namespace RCommon.Stateless; + +/// +/// Implements using the Stateless library. +/// +/// +/// Configuration is deferred: records actions that are replayed each time +/// is called, allowing multiple independent machines to be created from +/// the same configurator instance. +/// +public class StatelessConfigurator : IStateMachineConfigurator + where TState : struct, Enum + where TTrigger : struct, Enum +{ + private readonly List>> _configActions = new(); + + /// + /// + /// Creates a that records all + /// configuration calls. The deferred actions are replayed when is invoked. + /// + public IStateConfigurator ForState(TState state) + { + var deferred = new DeferredStateConfigurator(); + _configActions.Add(machine => + { + var stateConfig = machine.Configure(state); + deferred.ApplyTo(stateConfig); + }); + return deferred; + } + + /// + /// + /// Creates a new with the given + /// , replays all deferred configuration actions, and returns + /// it wrapped in a . + /// Each call produces a fully independent machine instance. + /// + public IStateMachine Build(TState initialState) + { + var machine = new global::Stateless.StateMachine(initialState); + + foreach (var configAction in _configActions) + { + configAction(machine); + } + + return new StatelessStateMachine(machine); + } +} diff --git a/Src/RCommon.Stateless/StatelessStateMachine.cs b/Src/RCommon.Stateless/StatelessStateMachine.cs new file mode 100644 index 00000000..53b54f0b --- /dev/null +++ b/Src/RCommon.Stateless/StatelessStateMachine.cs @@ -0,0 +1,68 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using RCommon.StateMachines; + +namespace RCommon.Stateless; + +/// +/// Wraps a to implement +/// the RCommon abstraction. +/// +public class StatelessStateMachine : IStateMachine + where TState : struct, Enum + where TTrigger : struct, Enum +{ + private readonly global::Stateless.StateMachine _machine; + private readonly Dictionary<(TTrigger, Type), object> _triggerParameterCache = new(); + + /// + /// Initializes a new instance of + /// wrapping the specified Stateless machine. + /// + /// The underlying Stateless state machine instance. + internal StatelessStateMachine(global::Stateless.StateMachine machine) + { + _machine = machine ?? throw new ArgumentNullException(nameof(machine)); + } + + /// + public TState CurrentState => _machine.State; + + /// + public bool CanFire(TTrigger trigger) => _machine.CanFire(trigger); + + /// +#pragma warning disable CS0618 // Stateless recommends PermittedTriggersAsync, but the interface requires a synchronous property + public IEnumerable PermittedTriggers => _machine.PermittedTriggers; +#pragma warning restore CS0618 + + /// + public async Task FireAsync(TTrigger trigger, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + await _machine.FireAsync(trigger).ConfigureAwait(false); + } + + /// + /// + /// Uses + /// to register the parameterized trigger. The descriptor is cached by trigger and data type + /// to prevent double-registration, which Stateless does not allow. + /// + public async Task FireAsync(TTrigger trigger, TData data, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + + var key = (trigger, typeof(TData)); + if (!_triggerParameterCache.TryGetValue(key, out var cached)) + { + cached = _machine.SetTriggerParameters(trigger); + _triggerParameterCache[key] = cached; + } + + var descriptor = (global::Stateless.StateMachine.TriggerWithParameters)cached; + await _machine.FireAsync(descriptor, data).ConfigureAwait(false); + } +} diff --git a/Src/RCommon.Wolverine.Outbox/IWolverineOutboxBuilder.cs b/Src/RCommon.Wolverine.Outbox/IWolverineOutboxBuilder.cs new file mode 100644 index 00000000..88ffb8c2 --- /dev/null +++ b/Src/RCommon.Wolverine.Outbox/IWolverineOutboxBuilder.cs @@ -0,0 +1,6 @@ +namespace RCommon.Wolverine.Outbox; + +public interface IWolverineOutboxBuilder +{ + IWolverineOutboxBuilder UseEntityFrameworkCoreTransactions(); +} diff --git a/Src/RCommon.Wolverine.Outbox/RCommon.Wolverine.Outbox.csproj b/Src/RCommon.Wolverine.Outbox/RCommon.Wolverine.Outbox.csproj new file mode 100644 index 00000000..c4752ab3 --- /dev/null +++ b/Src/RCommon.Wolverine.Outbox/RCommon.Wolverine.Outbox.csproj @@ -0,0 +1,48 @@ + + + + net8.0;net9.0;net10.0 + True + RCommon.Wolverine.Outbox + https://rcommon.com + RCommon; Wolverine; Outbox; Durable Messaging; Event Bus + Apache-2.0 + True + A cohesive set of infrastructure libraries for dotnet that utilizes abstractions for event handling, persistence, unit of work, mediator, distributed messaging, event bus, CQRS, email, and more + https://github.com/RCommon-Team/RCommon + RCommon + Jason Webb + RCommon-Icon.jpg + README.md + enable + enable + + + + + + + + + + + + + + True + \ + + + + + + True + \ + + + + + + + + diff --git a/Src/RCommon.Wolverine.Outbox/README.md b/Src/RCommon.Wolverine.Outbox/README.md new file mode 100644 index 00000000..6d7afe63 --- /dev/null +++ b/Src/RCommon.Wolverine.Outbox/README.md @@ -0,0 +1,3 @@ +# RCommon.Wolverine.Outbox + +Wolverine Entity Framework Core durable messaging integration for RCommon. diff --git a/Src/RCommon.Wolverine.Outbox/WolverineOutboxBuilder.cs b/Src/RCommon.Wolverine.Outbox/WolverineOutboxBuilder.cs new file mode 100644 index 00000000..1c7a4b18 --- /dev/null +++ b/Src/RCommon.Wolverine.Outbox/WolverineOutboxBuilder.cs @@ -0,0 +1,20 @@ +using Wolverine; +using Wolverine.EntityFrameworkCore; + +namespace RCommon.Wolverine.Outbox; + +public class WolverineOutboxBuilder : IWolverineOutboxBuilder +{ + private readonly WolverineOptions _wolverineOptions; + + public WolverineOutboxBuilder(WolverineOptions wolverineOptions) + { + _wolverineOptions = wolverineOptions ?? throw new ArgumentNullException(nameof(wolverineOptions)); + } + + public IWolverineOutboxBuilder UseEntityFrameworkCoreTransactions() + { + _wolverineOptions.UseEntityFrameworkCoreTransactions(); + return this; + } +} diff --git a/Src/RCommon.Wolverine.Outbox/WolverineOutboxBuilderExtensions.cs b/Src/RCommon.Wolverine.Outbox/WolverineOutboxBuilderExtensions.cs new file mode 100644 index 00000000..16675097 --- /dev/null +++ b/Src/RCommon.Wolverine.Outbox/WolverineOutboxBuilderExtensions.cs @@ -0,0 +1,22 @@ +using Microsoft.Extensions.DependencyInjection; +using Wolverine; +using Wolverine.EntityFrameworkCore; +using RCommon.Wolverine; +using RCommon.Wolverine.Outbox; + +namespace RCommon; + +public static class WolverineOutboxBuilderExtensions +{ + public static IWolverineEventHandlingBuilder AddOutbox( + this IWolverineEventHandlingBuilder builder, + Action? configure = null) + { + builder.Services.ConfigureWolverine(opts => + { + var outboxBuilder = new WolverineOutboxBuilder(opts); + configure?.Invoke(outboxBuilder); + }); + return builder; + } +} diff --git a/Src/RCommon.Wolverine/Producers/PublishWithWolverineEventProducer.cs b/Src/RCommon.Wolverine/Producers/PublishWithWolverineEventProducer.cs index 661e7d09..46a61d4f 100644 --- a/Src/RCommon.Wolverine/Producers/PublishWithWolverineEventProducer.cs +++ b/Src/RCommon.Wolverine/Producers/PublishWithWolverineEventProducer.cs @@ -69,7 +69,7 @@ public async Task ProduceEventAsync(T @event, CancellationToken cancellationT { _logger.LogDebug("{0} publishing event: {1}", new object[] { this.GetGenericTypeName(), @event }); } - await _messageBus.PublishAsync(@event); + await _messageBus.PublishAsync(@event).ConfigureAwait(false); } } catch (Exception ex) diff --git a/Src/RCommon.Wolverine/Producers/SendWithWolverineEventProducer.cs b/Src/RCommon.Wolverine/Producers/SendWithWolverineEventProducer.cs index ee35b6bd..faa029d6 100644 --- a/Src/RCommon.Wolverine/Producers/SendWithWolverineEventProducer.cs +++ b/Src/RCommon.Wolverine/Producers/SendWithWolverineEventProducer.cs @@ -68,7 +68,7 @@ public async Task ProduceEventAsync(T @event, CancellationToken cancellationT { _logger.LogDebug("{0} publishing event: {1}", new object[] { this.GetGenericTypeName(), @event }); } - await _messageBus.SendAsync(@event); + await _messageBus.SendAsync(@event).ConfigureAwait(false); } } catch (Exception ex) diff --git a/Src/RCommon.Wolverine/Subscribers/WolverineEventHandler.cs b/Src/RCommon.Wolverine/Subscribers/WolverineEventHandler.cs index 1601a21e..10c7bdfd 100644 --- a/Src/RCommon.Wolverine/Subscribers/WolverineEventHandler.cs +++ b/Src/RCommon.Wolverine/Subscribers/WolverineEventHandler.cs @@ -36,7 +36,7 @@ public WolverineEventHandler(ISubscriber subscriber, ILogger(); @@ -45,36 +45,36 @@ public void Subscribe_RegistersHandler_AndReturnsEventBus() } [Fact] - public void Subscribe_AddsHandlerToServices() + public async Task Subscribe_DynamicHandler_IsInvokedOnPublish() { // Arrange var services = new ServiceCollection(); var serviceProvider = services.BuildServiceProvider(); - var eventBus = new InMemoryEventBus(serviceProvider, services); + var eventBus = new InMemoryEventBus(serviceProvider); - // Act + // Act - subscribe dynamically, then publish eventBus.Subscribe(); + var act = async () => await eventBus.PublishAsync(new TestEvent { Message = "test" }); - // Assert - services.Should().Contain(sd => - sd.ServiceType == typeof(ISubscriber) && - sd.ImplementationType == typeof(TestEventHandler)); + // Assert - should not throw (handler resolved via ActivatorUtilities) + await act.Should().NotThrowAsync(); } [Fact] - public void Subscribe_MultipleHandlers_ForSameEvent_RegistersAll() + public async Task Subscribe_MultipleHandlers_ForSameEvent_AllInvoked() { // Arrange var services = new ServiceCollection(); var serviceProvider = services.BuildServiceProvider(); - var eventBus = new InMemoryEventBus(serviceProvider, services); + var eventBus = new InMemoryEventBus(serviceProvider); // Act eventBus.Subscribe(); eventBus.Subscribe(); + var act = async () => await eventBus.PublishAsync(new TestEvent { Message = "test" }); // Assert - services.Count(sd => sd.ServiceType == typeof(ISubscriber)).Should().Be(2); + await act.Should().NotThrowAsync(); } #endregion @@ -82,23 +82,21 @@ public void Subscribe_MultipleHandlers_ForSameEvent_RegistersAll() #region SubscribeAllHandledEvents Tests [Fact] - public void SubscribeAllHandledEvents_RegistersAllImplementedInterfaces() + public async Task SubscribeAllHandledEvents_RegistersAllImplementedInterfaces() { // Arrange var services = new ServiceCollection(); var serviceProvider = services.BuildServiceProvider(); - var eventBus = new InMemoryEventBus(serviceProvider, services); + var eventBus = new InMemoryEventBus(serviceProvider); - // Act + // Act - subscribe multi-handler, then publish both event types eventBus.SubscribeAllHandledEvents(); + var act1 = async () => await eventBus.PublishAsync(new TestEvent()); + var act2 = async () => await eventBus.PublishAsync(new AnotherTestEvent()); - // Assert - services.Should().Contain(sd => - sd.ServiceType == typeof(ISubscriber) && - sd.ImplementationType == typeof(MultiEventHandler)); - services.Should().Contain(sd => - sd.ServiceType == typeof(ISubscriber) && - sd.ImplementationType == typeof(MultiEventHandler)); + // Assert - both event types should be handled without throwing + await act1.Should().NotThrowAsync(); + await act2.Should().NotThrowAsync(); } [Fact] @@ -107,7 +105,7 @@ public void SubscribeAllHandledEvents_ReturnsEventBus() // Arrange var services = new ServiceCollection(); var serviceProvider = services.BuildServiceProvider(); - var eventBus = new InMemoryEventBus(serviceProvider, services); + var eventBus = new InMemoryEventBus(serviceProvider); // Act var result = eventBus.SubscribeAllHandledEvents(); @@ -126,7 +124,7 @@ public async Task PublishAsync_WithNoHandlers_CompletesSuccessfully() // Arrange var services = new ServiceCollection(); var serviceProvider = services.BuildServiceProvider(); - var eventBus = new InMemoryEventBus(serviceProvider, services); + var eventBus = new InMemoryEventBus(serviceProvider); var testEvent = new TestEvent { Message = "Test" }; // Act @@ -144,7 +142,7 @@ public async Task PublishAsync_WithRegisteredHandler_InvokesHandler() var services = new ServiceCollection(); services.AddScoped>(sp => new ActionTestEventHandler(() => handlerInvoked = true)); var serviceProvider = services.BuildServiceProvider(); - var eventBus = new InMemoryEventBus(serviceProvider, services); + var eventBus = new InMemoryEventBus(serviceProvider); var testEvent = new TestEvent { Message = "Test" }; // Act @@ -164,7 +162,7 @@ public async Task PublishAsync_WithMultipleHandlers_InvokesAllHandlers() services.AddScoped>(sp => new ActionTestEventHandler(() => handler1Invoked = true)); services.AddScoped>(sp => new ActionTestEventHandler(() => handler2Invoked = true)); var serviceProvider = services.BuildServiceProvider(); - var eventBus = new InMemoryEventBus(serviceProvider, services); + var eventBus = new InMemoryEventBus(serviceProvider); var testEvent = new TestEvent { Message = "Test" }; // Act @@ -183,7 +181,7 @@ public async Task PublishAsync_PassesEventToHandler() var services = new ServiceCollection(); services.AddScoped>(sp => new CapturingTestEventHandler(e => receivedEvent = e)); var serviceProvider = services.BuildServiceProvider(); - var eventBus = new InMemoryEventBus(serviceProvider, services); + var eventBus = new InMemoryEventBus(serviceProvider); var testEvent = new TestEvent { Message = "Hello World" }; // Act @@ -194,6 +192,25 @@ public async Task PublishAsync_PassesEventToHandler() receivedEvent!.Message.Should().Be("Hello World"); } + [Fact] + public async Task PublishAsync_PassesCancellationTokenToHandler() + { + // Arrange + CancellationToken? receivedToken = null; + var services = new ServiceCollection(); + services.AddScoped>(sp => new TokenCapturingHandler(token => receivedToken = token)); + var serviceProvider = services.BuildServiceProvider(); + var eventBus = new InMemoryEventBus(serviceProvider); + using var cts = new CancellationTokenSource(); + + // Act + await eventBus.PublishAsync(new TestEvent(), cts.Token); + + // Assert + receivedToken.Should().NotBeNull(); + receivedToken!.Value.Should().Be(cts.Token); + } + #endregion #region IEventBus Interface Tests @@ -206,7 +223,7 @@ public void InMemoryEventBus_ImplementsIEventBus() var serviceProvider = services.BuildServiceProvider(); // Act - var eventBus = new InMemoryEventBus(serviceProvider, services); + var eventBus = new InMemoryEventBus(serviceProvider); // Assert eventBus.Should().BeAssignableTo(); @@ -228,7 +245,7 @@ public async Task PublishAsync_CreatesNewScope_ForEachPublish() return new TestEventHandler(); }); var serviceProvider = services.BuildServiceProvider(); - var eventBus = new InMemoryEventBus(serviceProvider, services); + var eventBus = new InMemoryEventBus(serviceProvider); // Act await eventBus.PublishAsync(new TestEvent()); @@ -248,7 +265,7 @@ public void FluentChaining_Subscribe_AllowsMultipleSubscriptions() // Arrange var services = new ServiceCollection(); var serviceProvider = services.BuildServiceProvider(); - var eventBus = new InMemoryEventBus(serviceProvider, services); + var eventBus = new InMemoryEventBus(serviceProvider); // Act var result = eventBus @@ -257,8 +274,6 @@ public void FluentChaining_Subscribe_AllowsMultipleSubscriptions() // Assert result.Should().BeSameAs(eventBus); - services.Should().Contain(sd => sd.ServiceType == typeof(ISubscriber)); - services.Should().Contain(sd => sd.ServiceType == typeof(ISubscriber)); } #endregion @@ -344,5 +359,21 @@ public Task HandleAsync(TestEvent @event, CancellationToken cancellationToken = } } + public class TokenCapturingHandler : ISubscriber + { + private readonly Action _capture; + + public TokenCapturingHandler(Action capture) + { + _capture = capture; + } + + public Task HandleAsync(TestEvent @event, CancellationToken cancellationToken = default) + { + _capture(cancellationToken); + return Task.CompletedTask; + } + } + #endregion } diff --git a/Tests/RCommon.Core.Tests/ObjectGraphWalkerTests.cs b/Tests/RCommon.Core.Tests/ObjectGraphWalkerTests.cs new file mode 100644 index 00000000..403c6227 --- /dev/null +++ b/Tests/RCommon.Core.Tests/ObjectGraphWalkerTests.cs @@ -0,0 +1,208 @@ +using FluentAssertions; +using RCommon.Reflection; +using Xunit; + +namespace RCommon.Core.Tests; + +public class ObjectGraphWalkerTests +{ + #region TraverseGraphFor_NullRoot Tests + + [Fact] + public void TraverseGraphFor_NullRoot_ReturnsEmpty() + { + // Arrange + object? root = null; + + // Act + var act = () => ObjectGraphWalker.TraverseGraphFor(root!); + + // Assert + act.Should().NotThrow(); + act().Should().BeEmpty(); + } + + #endregion + + #region TraverseGraphFor_SimpleMatch Tests + + [Fact] + public void TraverseGraphFor_SimpleMatch_FindsInstance() + { + // Arrange + var target = new TargetItem { Name = "Found" }; + + // Act + var results = ObjectGraphWalker.TraverseGraphFor(target); + + // Assert + results.Should().ContainSingle() + .Which.Should().BeSameAs(target); + } + + #endregion + + #region TraverseGraphFor_NestedMatch Tests + + [Fact] + public void TraverseGraphFor_NestedMatch_FindsNestedInstances() + { + // Arrange + var nestedItem = new TargetItem { Name = "Nested" }; + var container = new Container { Item = nestedItem }; + + // Act + var results = ObjectGraphWalker.TraverseGraphFor(container); + + // Assert + results.Should().ContainSingle() + .Which.Should().BeSameAs(nestedItem); + } + + #endregion + + #region TraverseGraphFor_CircularReference Tests + + [Fact] + public void TraverseGraphFor_CircularReference_DoesNotInfiniteLoop() + { + // Arrange + var a = new Container { Item = new TargetItem { Name = "A" } }; + var b = new Container { Item = new TargetItem { Name = "B" } }; + a.Self = b; + b.Self = a; + + // Act + var act = () => ObjectGraphWalker.TraverseGraphFor(a); + + // Assert + act.Should().NotThrow(); + act().Should().HaveCount(2); + } + + #endregion + + #region TraverseGraphFor_Collection Tests + + [Fact] + public void TraverseGraphFor_Collection_FindsItemsInList() + { + // Arrange + var item1 = new TargetItem { Name = "First" }; + var item2 = new TargetItem { Name = "Second" }; + var item3 = new TargetItem { Name = "Third" }; + var listContainer = new ListContainer + { + Items = new List { item1, item2, item3 } + }; + + // Act + var results = ObjectGraphWalker.TraverseGraphFor(listContainer); + + // Assert + results.Should().HaveCount(3); + results.Should().Contain(item1); + results.Should().Contain(item2); + results.Should().Contain(item3); + } + + #endregion + + #region TraverseGraphFor_NoMatches Tests + + [Fact] + public void TraverseGraphFor_NoMatches_ReturnsEmpty() + { + // Arrange + var container = new Container + { + Item = null, + Self = null + }; + + // Act + var results = ObjectGraphWalker.TraverseGraphFor(container); + + // Assert + results.Should().BeEmpty(); + } + + #endregion + + #region TraverseGraphFor_ValueTypeProperties Tests + + [Fact] + public void TraverseGraphFor_ValueTypeProperties_DoesNotRecurseInfinitely() + { + // Arrange + var obj = new ContainerWithValueTypes + { + Created = DateTime.UtcNow, + Count = 42, + Item = new TargetItem { Name = "WithValueTypes" } + }; + + // Act + var act = () => ObjectGraphWalker.TraverseGraphFor(obj); + + // Assert + act.Should().NotThrow(); + act().Should().ContainSingle() + .Which.Name.Should().Be("WithValueTypes"); + } + + #endregion + + #region TraverseGraphFor_DuplicateReferences Tests + + [Fact] + public void TraverseGraphFor_DuplicateReferences_FoundOnce() + { + // Arrange + var sharedItem = new TargetItem { Name = "Shared" }; + var dualRef = new DualRefContainer + { + ItemA = sharedItem, + ItemB = sharedItem + }; + + // Act + var results = ObjectGraphWalker.TraverseGraphFor(dualRef); + + // Assert + results.Should().ContainSingle() + .Which.Should().BeSameAs(sharedItem); + } + + #endregion + + #region Test Helper Classes + + private class TargetItem { public string? Name { get; set; } } + + private class Container + { + public TargetItem? Item { get; set; } + public Container? Self { get; set; } + } + + private class ContainerWithValueTypes + { + public DateTime Created { get; set; } + public int Count { get; set; } + public TargetItem? Item { get; set; } + } + + private class DualRefContainer + { + public TargetItem? ItemA { get; set; } + public TargetItem? ItemB { get; set; } + } + + private class ListContainer + { + public List? Items { get; set; } + } + + #endregion +} diff --git a/Tests/RCommon.Core.Tests/SpecificationTests.cs b/Tests/RCommon.Core.Tests/SpecificationTests.cs index 7c7160d8..cad0390c 100644 --- a/Tests/RCommon.Core.Tests/SpecificationTests.cs +++ b/Tests/RCommon.Core.Tests/SpecificationTests.cs @@ -250,6 +250,87 @@ public void CombinedAndOrOperators_EvaluatesCorrectly() #endregion + #region Not Extension Tests + + [Fact] + public void Not_NegatesSpecification_SatisfiedBecomesFalse() + { + // Arrange + var activeSpec = new Specification(x => x.IsActive); + ISpecification notActiveSpec = activeSpec.Not(); + + var activeEntity = new TestEntity { Id = 1, IsActive = true }; + var inactiveEntity = new TestEntity { Id = 2, IsActive = false }; + + // Act & Assert + notActiveSpec.IsSatisfiedBy(activeEntity).Should().BeFalse(); + notActiveSpec.IsSatisfiedBy(inactiveEntity).Should().BeTrue(); + } + + [Fact] + public void Not_ReturnsNewSpecification() + { + // Arrange + var spec = new Specification(x => x.Id > 0); + + // Act + var negated = spec.Not(); + + // Assert + negated.Should().NotBeNull(); + negated.Should().NotBeSameAs(spec); + } + + [Fact] + public void Not_DoubleNegation_RestoresOriginalBehavior() + { + // Arrange + var spec = new Specification(x => x.Id > 10); + var doubleNegated = spec.Not().Not(); + + var matching = new TestEntity { Id = 15 }; + var nonMatching = new TestEntity { Id = 5 }; + + // Act & Assert + doubleNegated.IsSatisfiedBy(matching).Should().BeTrue(); + doubleNegated.IsSatisfiedBy(nonMatching).Should().BeFalse(); + } + + [Fact] + public void Not_CombinedWithAnd_WorksCorrectly() + { + // Arrange — Active AND NOT HighId + var activeSpec = new Specification(x => x.IsActive); + var highIdSpec = new Specification(x => x.Id > 100); + var combinedSpec = activeSpec.And(highIdSpec.Not()); + + var activeHighId = new TestEntity { Id = 150, IsActive = true }; + var activeLowId = new TestEntity { Id = 50, IsActive = true }; + var inactiveLowId = new TestEntity { Id = 50, IsActive = false }; + + // Act & Assert + combinedSpec.IsSatisfiedBy(activeHighId).Should().BeFalse(); + combinedSpec.IsSatisfiedBy(activeLowId).Should().BeTrue(); + combinedSpec.IsSatisfiedBy(inactiveLowId).Should().BeFalse(); + } + + [Fact] + public void Not_PredicateIsUsableAsExpression() + { + // Arrange + var spec = new Specification(x => x.Name == "Test"); + var negated = spec.Not(); + + // Act — compile and invoke the predicate expression + var compiled = negated.Predicate.Compile(); + + // Assert + compiled(new TestEntity { Name = "Test" }).Should().BeFalse(); + compiled(new TestEntity { Name = "Other" }).Should().BeTrue(); + } + + #endregion + #region Test Helper Classes public class TestEntity diff --git a/Tests/RCommon.Core.Tests/StateMachineInterfaceTests.cs b/Tests/RCommon.Core.Tests/StateMachineInterfaceTests.cs new file mode 100644 index 00000000..76e2e9dc --- /dev/null +++ b/Tests/RCommon.Core.Tests/StateMachineInterfaceTests.cs @@ -0,0 +1,57 @@ +using System; +using System.Linq; +using FluentAssertions; +using RCommon.StateMachines; +using Xunit; + +namespace RCommon.Core.Tests; + +public class StateMachineInterfaceTests +{ + [Fact] + public void IStateMachine_Has_Struct_And_Enum_Constraints() + { + var type = typeof(IStateMachine<,>); + var tState = type.GetGenericArguments()[0]; + var tTrigger = type.GetGenericArguments()[1]; + + tState.GenericParameterAttributes.HasFlag( + System.Reflection.GenericParameterAttributes.NotNullableValueTypeConstraint) + .Should().BeTrue("TState must be struct"); + tState.GetGenericParameterConstraints().Should().Contain(typeof(Enum)); + + tTrigger.GenericParameterAttributes.HasFlag( + System.Reflection.GenericParameterAttributes.NotNullableValueTypeConstraint) + .Should().BeTrue("TTrigger must be struct"); + tTrigger.GetGenericParameterConstraints().Should().Contain(typeof(Enum)); + } + + [Fact] + public void IStateMachine_Has_Required_Members() + { + var type = typeof(IStateMachine<,>); + type.GetProperty("CurrentState").Should().NotBeNull(); + type.GetProperty("PermittedTriggers").Should().NotBeNull(); + type.GetMethod("CanFire").Should().NotBeNull(); + type.GetMethods().Where(m => m.Name == "FireAsync").Should().HaveCountGreaterThanOrEqualTo(2, + "should have FireAsync and FireAsync overloads"); + } + + [Fact] + public void IStateMachineConfigurator_Has_ForState_And_Build() + { + var type = typeof(IStateMachineConfigurator<,>); + type.GetMethod("ForState").Should().NotBeNull(); + type.GetMethod("Build").Should().NotBeNull(); + } + + [Fact] + public void IStateConfigurator_Has_Required_Members() + { + var type = typeof(IStateConfigurator<,>); + type.GetMethod("Permit").Should().NotBeNull(); + type.GetMethod("OnEntry").Should().NotBeNull(); + type.GetMethod("OnExit").Should().NotBeNull(); + type.GetMethod("PermitIf").Should().NotBeNull(); + } +} diff --git a/Tests/RCommon.Core.Tests/TryForEachTests.cs b/Tests/RCommon.Core.Tests/TryForEachTests.cs new file mode 100644 index 00000000..851cafa8 --- /dev/null +++ b/Tests/RCommon.Core.Tests/TryForEachTests.cs @@ -0,0 +1,184 @@ +using FluentAssertions; +using Xunit; + +namespace RCommon.Core.Tests; + +public class TryForEachTests +{ + #region IEnumerable overload + + [Fact] + public void TryForEach_AllSucceed_ExecutesAllActions() + { + // Arrange + var items = new[] { 1, 2, 3 }; + var visited = new List(); + + // Act + items.TryForEach(item => visited.Add(item)); + + // Assert + visited.Should().Equal(1, 2, 3); + } + + [Fact] + public void TryForEach_ExceptionThrown_ContinuesEnumerating() + { + // Arrange + var items = new[] { 1, 2, 3 }; + var visited = new List(); + + // Act + items.TryForEach(item => + { + if (item == 2) throw new InvalidOperationException("fail"); + visited.Add(item); + }); + + // Assert - item 2 threw but 1 and 3 were still processed + visited.Should().Equal(1, 3); + } + + [Fact] + public void TryForEach_WithOnError_CallbackReceivesItemAndException() + { + // Arrange + var items = new[] { 1, 2, 3 }; + var errors = new List<(int item, Exception ex)>(); + + // Act + items.TryForEach( + item => { if (item == 2) throw new InvalidOperationException("fail"); }, + onError: (item, ex) => errors.Add((item, ex)) + ); + + // Assert + errors.Should().HaveCount(1); + errors[0].item.Should().Be(2); + errors[0].ex.Should().BeOfType(); + } + + [Fact] + public void TryForEach_WithoutOnError_SilentlySwallowsExceptions() + { + // Arrange + var items = new[] { 1, 2, 3 }; + var visited = new List(); + + // Act - no onError callback provided; exceptions must not propagate + var act = () => items.TryForEach(item => + { + if (item == 2) throw new InvalidOperationException("fail"); + visited.Add(item); + }); + + // Assert - no exception escapes the extension method + act.Should().NotThrow(); + visited.Should().Equal(1, 3); + } + + [Fact] + public void TryForEach_MultipleFailures_OnErrorCalledForEach() + { + // Arrange + var items = new[] { 1, 2, 3, 4, 5 }; + var errors = new List<(int item, Exception ex)>(); + var visited = new List(); + + // Act - items 2 and 4 throw + items.TryForEach( + item => + { + if (item == 2 || item == 4) throw new ArgumentException($"bad item {item}"); + visited.Add(item); + }, + onError: (item, ex) => errors.Add((item, ex)) + ); + + // Assert + errors.Should().HaveCount(2); + errors[0].item.Should().Be(2); + errors[1].item.Should().Be(4); + errors.Should().AllSatisfy(e => e.ex.Should().BeOfType()); + visited.Should().Equal(1, 3, 5); + } + + [Fact] + public void TryForEach_EmptyCollection_DoesNothing() + { + // Arrange + var items = Array.Empty(); + var visited = new List(); + var errors = new List<(int item, Exception ex)>(); + + // Act + items.TryForEach( + item => visited.Add(item), + onError: (item, ex) => errors.Add((item, ex)) + ); + + // Assert + visited.Should().BeEmpty(); + errors.Should().BeEmpty(); + } + + #endregion + + #region IEnumerator overload + + [Fact] + public void TryForEach_Enumerator_AllSucceed_ExecutesAllActions() + { + // Arrange + var items = new List { 10, 20, 30 }; + var visited = new List(); + + // Act + using var enumerator = items.GetEnumerator(); + enumerator.TryForEach(item => visited.Add(item)); + + // Assert + visited.Should().Equal(10, 20, 30); + } + + [Fact] + public void TryForEach_Enumerator_ExceptionThrown_ContinuesEnumerating() + { + // Arrange + var items = new List { "a", "b", "c" }; + var visited = new List(); + + // Act + using var enumerator = items.GetEnumerator(); + enumerator.TryForEach(item => + { + if (item == "b") throw new InvalidOperationException("fail"); + visited.Add(item); + }); + + // Assert - "b" threw but "a" and "c" were still processed + visited.Should().Equal("a", "c"); + } + + [Fact] + public void TryForEach_Enumerator_WithOnError_CallbackReceivesItemAndException() + { + // Arrange + var items = new List { "x", "y", "z" }; + var errors = new List<(string item, Exception ex)>(); + + // Act + using var enumerator = items.GetEnumerator(); + enumerator.TryForEach( + item => { if (item == "y") throw new NotSupportedException("unsupported"); }, + onError: (item, ex) => errors.Add((item, ex)) + ); + + // Assert + errors.Should().HaveCount(1); + errors[0].item.Should().Be("y"); + errors[0].ex.Should().BeOfType(); + } + + #endregion +} diff --git a/Tests/RCommon.Core.Tests/TypeExtensionsTests.cs b/Tests/RCommon.Core.Tests/TypeExtensionsTests.cs new file mode 100644 index 00000000..adb72a04 --- /dev/null +++ b/Tests/RCommon.Core.Tests/TypeExtensionsTests.cs @@ -0,0 +1,205 @@ +using System.Collections.Concurrent; +using FluentAssertions; +using Xunit; + +namespace RCommon.Core.Tests; + +public class TypeExtensionsTests +{ + #region Helper Types + + private class ClassWithStringCtor + { + public ClassWithStringCtor(string value) { } + } + + private class ClassWithNoCtor { } + + private interface ITestInterface { } + + private class TestImplementation : ITestInterface { } + + private class UnrelatedClass { } + + #endregion + + #region GetGenericTypeName Tests + + [Fact] + public void GetGenericTypeName_NonGenericType_ReturnsTypeName() + { + // Arrange + var type = typeof(string); + + // Act + var result = type.GetGenericTypeName(); + + // Assert + result.Should().Be("String"); + } + + [Fact] + public void GetGenericTypeName_GenericType_ReturnsFormattedName() + { + // Arrange + var type = typeof(List); + + // Act + var result = type.GetGenericTypeName(); + + // Assert + result.Should().Be("List"); + } + + [Fact] + public void GetGenericTypeName_MultipleGenericArgs_ReturnsFormattedName() + { + // Arrange + var type = typeof(Dictionary); + + // Act + var result = type.GetGenericTypeName(); + + // Assert + result.Should().Be("Dictionary"); + } + + #endregion + + #region PrettyPrint Tests + + [Fact] + public void PrettyPrint_SimpleType_ReturnsName() + { + // Arrange + var type = typeof(int); + + // Act + var result = type.PrettyPrint(); + + // Assert + result.Should().Be("Int32"); + } + + [Fact] + public void PrettyPrint_GenericType_ReturnsReadableName() + { + // Arrange + var type = typeof(List); + + // Act + var result = type.PrettyPrint(); + + // Assert + result.Should().Be("List"); + } + + [Fact] + public void PrettyPrint_SameType_ReturnsCachedResult() + { + // Arrange + var type = typeof(string); + + // Act + var firstCall = type.PrettyPrint(); + var secondCall = type.PrettyPrint(); + + // Assert + // ReferenceEquals confirms the same cached string instance is returned on subsequent calls + object.ReferenceEquals(firstCall, secondCall).Should().BeTrue(); + } + + #endregion + + #region GetCacheKey Tests + + [Fact] + public void GetCacheKey_ReturnsExpectedFormat() + { + // Arrange + var type = typeof(string); + + // Act + var result = type.GetCacheKey(); + + // Assert + result.Should().Contain("hash:"); + result.Should().StartWith(type.PrettyPrint()); + } + + [Fact] + public void GetCacheKey_SameType_ReturnsSameKey() + { + // Arrange + var type = typeof(int); + + // Act + var firstKey = type.GetCacheKey(); + var secondKey = type.GetCacheKey(); + + // Assert + firstKey.Should().Be(secondKey); + } + + #endregion + + #region HasConstructorParameterOfType Tests + + [Fact] + public void HasConstructorParameterOfType_WithMatchingParam_ReturnsTrue() + { + // Arrange + var type = typeof(ClassWithStringCtor); + + // Act + var result = type.HasConstructorParameterOfType(t => t == typeof(string)); + + // Assert + result.Should().BeTrue(); + } + + [Fact] + public void HasConstructorParameterOfType_NoMatchingParam_ReturnsFalse() + { + // Arrange + var type = typeof(ClassWithNoCtor); + + // Act + var result = type.HasConstructorParameterOfType(t => t == typeof(string)); + + // Assert + result.Should().BeFalse(); + } + + #endregion + + #region IsAssignableTo Tests + + [Fact] + public void IsAssignableTo_ImplementsInterface_ReturnsTrue() + { + // Arrange + var type = typeof(TestImplementation); + + // Act + var result = type.IsAssignableTo(); + + // Assert + result.Should().BeTrue(); + } + + [Fact] + public void IsAssignableTo_DoesNotImplement_ReturnsFalse() + { + // Arrange + var type = typeof(UnrelatedClass); + + // Act + var result = type.IsAssignableTo(); + + // Assert + result.Should().BeFalse(); + } + + #endregion +} diff --git a/Tests/RCommon.Dapper.Tests/DapperInboxStoreTests.cs b/Tests/RCommon.Dapper.Tests/DapperInboxStoreTests.cs new file mode 100644 index 00000000..6b051ae5 --- /dev/null +++ b/Tests/RCommon.Dapper.Tests/DapperInboxStoreTests.cs @@ -0,0 +1,44 @@ +using FluentAssertions; +using Microsoft.Extensions.Options; +using Moq; +using RCommon.Persistence; +using RCommon.Persistence.Dapper.Inbox; +using RCommon.Persistence.Outbox; +using Xunit; + +namespace RCommon.Dapper.Tests; + +public class DapperInboxStoreTests +{ + [Fact] + public void Constructor_NullDataStoreFactory_ThrowsArgumentNullException() + { + var act = () => new DapperInboxStore( + null!, + Options.Create(new DefaultDataStoreOptions { DefaultDataStoreName = "test" }), + Options.Create(new OutboxOptions())); + act.Should().Throw().WithParameterName("dataStoreFactory"); + } + + [Fact] + public void Constructor_NullDefaultDataStoreOptions_ThrowsArgumentNullException() + { + var factoryMock = new Mock(); + var act = () => new DapperInboxStore( + factoryMock.Object, + Options.Create(new DefaultDataStoreOptions()), + Options.Create(new OutboxOptions())); + act.Should().Throw().WithParameterName("defaultDataStoreOptions"); + } + + [Fact] + public void Constructor_SucceedsWithValidParameters() + { + var factoryMock = new Mock(); + var store = new DapperInboxStore( + factoryMock.Object, + Options.Create(new DefaultDataStoreOptions { DefaultDataStoreName = "test" }), + Options.Create(new OutboxOptions())); + store.Should().NotBeNull(); + } +} diff --git a/Tests/RCommon.Dapper.Tests/DapperOutboxStoreTests.cs b/Tests/RCommon.Dapper.Tests/DapperOutboxStoreTests.cs new file mode 100644 index 00000000..63d4d4e6 --- /dev/null +++ b/Tests/RCommon.Dapper.Tests/DapperOutboxStoreTests.cs @@ -0,0 +1,72 @@ +using FluentAssertions; +using Microsoft.Extensions.Options; +using Moq; +using RCommon.Persistence; +using RCommon.Persistence.Dapper.Outbox; +using RCommon.Persistence.Outbox; +using RCommon.Persistence.Sql; +using System.Data; +using System.Data.Common; +using Xunit; + +namespace RCommon.Dapper.Tests; + +public class DapperOutboxStoreTests +{ + private readonly Mock _lockProviderMock = new(); + + public DapperOutboxStoreTests() + { + _lockProviderMock.Setup(l => l.ProviderName).Returns("SqlServer"); + } + + [Fact] + public void Constructor_ThrowsOnNullDataStoreFactory() + { + var act = () => new DapperOutboxStore( + null!, + Options.Create(new DefaultDataStoreOptions { DefaultDataStoreName = "test" }), + Options.Create(new OutboxOptions()), + _lockProviderMock.Object); + + act.Should().Throw(); + } + + [Fact] + public void Constructor_ThrowsOnNullDefaultDataStoreOptions() + { + var factoryMock = new Mock(); + var act = () => new DapperOutboxStore( + factoryMock.Object, + Options.Create(new DefaultDataStoreOptions()), + Options.Create(new OutboxOptions()), + _lockProviderMock.Object); + + act.Should().Throw(); + } + + [Fact] + public void Constructor_SucceedsWithValidParameters() + { + var factoryMock = new Mock(); + var store = new DapperOutboxStore( + factoryMock.Object, + Options.Create(new DefaultDataStoreOptions { DefaultDataStoreName = "test" }), + Options.Create(new OutboxOptions()), + _lockProviderMock.Object); + + store.Should().NotBeNull(); + } + + [Fact] + public void Constructor_NullLockStatementProvider_ThrowsArgumentNullException() + { + var factoryMock = new Mock(); + var act = () => new DapperOutboxStore( + factoryMock.Object, + Options.Create(new DefaultDataStoreOptions { DefaultDataStoreName = "test" }), + Options.Create(new OutboxOptions()), + null!); + act.Should().Throw().WithParameterName("lockStatementProvider"); + } +} diff --git a/Tests/RCommon.EfCore.Tests/EFCoreInboxStoreTests.cs b/Tests/RCommon.EfCore.Tests/EFCoreInboxStoreTests.cs new file mode 100644 index 00000000..203759be --- /dev/null +++ b/Tests/RCommon.EfCore.Tests/EFCoreInboxStoreTests.cs @@ -0,0 +1,105 @@ +using FluentAssertions; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; +using Moq; +using RCommon.Persistence; +using RCommon.Persistence.EFCore; +using RCommon.Persistence.EFCore.Inbox; +using RCommon.Persistence.Inbox; +using RCommon.Persistence.Outbox; +using Xunit; + +namespace RCommon.EfCore.Tests; + +public class EFCoreInboxStoreTests : IDisposable +{ + private readonly TestOutboxDbContext _dbContext; + private readonly EFCoreInboxStore _store; + + public EFCoreInboxStoreTests() + { + var dbOptions = new DbContextOptionsBuilder() + .UseSqlite("DataSource=:memory:") + .Options; + _dbContext = new TestOutboxDbContext(dbOptions); + _dbContext.Database.OpenConnection(); + _dbContext.Database.EnsureCreated(); + + var factoryMock = new Mock(); + factoryMock.Setup(f => f.Resolve(It.IsAny())) + .Returns(_dbContext); + + var defaultOptions = Options.Create(new DefaultDataStoreOptions { DefaultDataStoreName = "test" }); + var outboxOptions = Options.Create(new OutboxOptions()); + + _store = new EFCoreInboxStore(factoryMock.Object, defaultOptions, outboxOptions); + } + + [Fact] + public async Task ExistsAsync_NoRecord_ReturnsFalse() + { + var result = await _store.ExistsAsync(Guid.NewGuid(), "TestConsumer"); + result.Should().BeFalse(); + } + + [Fact] + public async Task RecordAsync_ThenExistsAsync_ReturnsTrue() + { + var messageId = Guid.NewGuid(); + await _store.RecordAsync(new InboxMessage + { + MessageId = messageId, + EventType = "TestEvent", + ConsumerType = "TestConsumer", + ReceivedAtUtc = DateTimeOffset.UtcNow + }); + + var result = await _store.ExistsAsync(messageId, "TestConsumer"); + result.Should().BeTrue(); + } + + [Fact] + public async Task ExistsAsync_DifferentConsumer_ReturnsFalse() + { + var messageId = Guid.NewGuid(); + await _store.RecordAsync(new InboxMessage + { + MessageId = messageId, + EventType = "TestEvent", + ConsumerType = "ConsumerA", + ReceivedAtUtc = DateTimeOffset.UtcNow + }); + + var result = await _store.ExistsAsync(messageId, "ConsumerB"); + result.Should().BeFalse(); + } + + [Fact] + public async Task CleanupAsync_RemovesOldEntries() + { + var old = new InboxMessage + { + MessageId = Guid.NewGuid(), + EventType = "TestEvent", + ConsumerType = "TestConsumer", + ReceivedAtUtc = DateTimeOffset.UtcNow.AddDays(-10) + }; + await _store.RecordAsync(old); + + var recent = new InboxMessage + { + MessageId = Guid.NewGuid(), + EventType = "TestEvent", + ConsumerType = "TestConsumer", + ReceivedAtUtc = DateTimeOffset.UtcNow + }; + await _store.RecordAsync(recent); + + await _store.CleanupAsync(TimeSpan.FromDays(7)); + + (await _store.ExistsAsync(old.MessageId, "TestConsumer")).Should().BeFalse(); + (await _store.ExistsAsync(recent.MessageId, "TestConsumer")).Should().BeTrue(); + } + + public void Dispose() => _dbContext?.Dispose(); +} diff --git a/Tests/RCommon.EfCore.Tests/EFCoreIntegrationTests.cs b/Tests/RCommon.EfCore.Tests/EFCoreIntegrationTests.cs index dca94576..92b6c607 100644 --- a/Tests/RCommon.EfCore.Tests/EFCoreIntegrationTests.cs +++ b/Tests/RCommon.EfCore.Tests/EFCoreIntegrationTests.cs @@ -8,6 +8,7 @@ using RCommon.Persistence.Crud; using RCommon.Persistence.EFCore; using RCommon.Persistence.EFCore.Crud; +using System.Threading; using Xunit; namespace RCommon.EfCore.Tests; @@ -286,7 +287,9 @@ public void AddEntity(IBusinessEntity entity) _trackedEntities.Add(entity); } - public Task EmitTransactionalEventsAsync() + public Task PersistEventsAsync(CancellationToken cancellationToken = default) => Task.CompletedTask; + + public Task EmitTransactionalEventsAsync(CancellationToken cancellationToken = default) { return Task.FromResult(true); } diff --git a/Tests/RCommon.EfCore.Tests/EFCoreOutboxStoreTests.cs b/Tests/RCommon.EfCore.Tests/EFCoreOutboxStoreTests.cs new file mode 100644 index 00000000..4e6088c8 --- /dev/null +++ b/Tests/RCommon.EfCore.Tests/EFCoreOutboxStoreTests.cs @@ -0,0 +1,361 @@ +using FluentAssertions; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; +using Moq; +using RCommon.Persistence; +using RCommon.Persistence.EFCore; +using RCommon.Persistence.EFCore.Outbox; +using RCommon.Persistence.Outbox; +using Xunit; + +namespace RCommon.EfCore.Tests; + +public class TestOutboxDbContext : RCommonDbContext +{ + public TestOutboxDbContext(DbContextOptions options) : base(options) { } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.AddOutboxMessages(); + modelBuilder.AddInboxMessages(); + } +} + +public class EFCoreOutboxStoreTests : IDisposable +{ + private readonly TestOutboxDbContext _dbContext; + private readonly EFCoreOutboxStore _store; + + public EFCoreOutboxStoreTests() + { + var dbOptions = new DbContextOptionsBuilder() + .UseSqlite("DataSource=:memory:") + .Options; + _dbContext = new TestOutboxDbContext(dbOptions); + _dbContext.Database.OpenConnection(); + _dbContext.Database.EnsureCreated(); + + var factoryMock = new Mock(); + factoryMock.Setup(f => f.Resolve(It.IsAny())) + .Returns(_dbContext); + var defaultOpts = Options.Create(new DefaultDataStoreOptions { DefaultDataStoreName = "test" }); + var outboxOpts = Options.Create(new OutboxOptions { MaxRetries = 3 }); + + _store = new EFCoreOutboxStore(factoryMock.Object, defaultOpts, outboxOpts); + } + + [Fact] + public async Task SaveAsync_PersistsMessage() + { + var msg = new OutboxMessage + { + Id = Guid.NewGuid(), + EventType = "Test.Event", + EventPayload = "{}", + CreatedAtUtc = DateTimeOffset.UtcNow + }; + await _store.SaveAsync(msg); + var count = await _dbContext.Set().CountAsync(); + count.Should().Be(1); + } + + [Fact] + public async Task MarkProcessedAsync_SetsProcessedAtUtc() + { + var msg = new OutboxMessage + { + Id = Guid.NewGuid(), + EventType = "T", + EventPayload = "{}", + CreatedAtUtc = DateTimeOffset.UtcNow + }; + _dbContext.Set().Add(msg); + await _dbContext.SaveChangesAsync(); + + await _store.MarkProcessedAsync(msg.Id); + + var updated = await _dbContext.Set().FindAsync(msg.Id); + updated!.ProcessedAtUtc.Should().NotBeNull(); + } + + [Fact] + public async Task MarkFailedAsync_IncrementsRetryCountAndSetsError() + { + var msg = new OutboxMessage + { + Id = Guid.NewGuid(), + EventType = "T", + EventPayload = "{}", + CreatedAtUtc = DateTimeOffset.UtcNow, + RetryCount = 1, + LockedByInstanceId = "instance-1", + LockedUntilUtc = DateTimeOffset.UtcNow.AddMinutes(5) + }; + _dbContext.Set().Add(msg); + await _dbContext.SaveChangesAsync(); + + var nextRetry = DateTimeOffset.UtcNow.AddMinutes(10); + await _store.MarkFailedAsync(msg.Id, "error", nextRetry); + + var updated = await _dbContext.Set().FindAsync(msg.Id); + updated!.RetryCount.Should().Be(2); + updated.ErrorMessage.Should().Be("error"); + updated.NextRetryAtUtc.Should().NotBeNull(); + updated.NextRetryAtUtc!.Value.Should().BeCloseTo(nextRetry, TimeSpan.FromSeconds(1)); + updated.LockedByInstanceId.Should().BeNull(); + updated.LockedUntilUtc.Should().BeNull(); + } + + [Fact] + public async Task MarkDeadLetteredAsync_SetsDeadLetteredAtUtc() + { + var msg = new OutboxMessage + { + Id = Guid.NewGuid(), + EventType = "T", + EventPayload = "{}", + CreatedAtUtc = DateTimeOffset.UtcNow + }; + _dbContext.Set().Add(msg); + await _dbContext.SaveChangesAsync(); + + await _store.MarkDeadLetteredAsync(msg.Id); + + var updated = await _dbContext.Set().FindAsync(msg.Id); + updated!.DeadLetteredAtUtc.Should().NotBeNull(); + } + + [Fact] + public async Task ClaimAsync_FiltersCorrectly() + { + var pending = new OutboxMessage + { + Id = Guid.NewGuid(), + EventType = "T", + EventPayload = "{}", + CreatedAtUtc = DateTimeOffset.UtcNow, + RetryCount = 0 + }; + var processed = new OutboxMessage + { + Id = Guid.NewGuid(), + EventType = "T", + EventPayload = "{}", + CreatedAtUtc = DateTimeOffset.UtcNow, + ProcessedAtUtc = DateTimeOffset.UtcNow + }; + var deadLettered = new OutboxMessage + { + Id = Guid.NewGuid(), + EventType = "T", + EventPayload = "{}", + CreatedAtUtc = DateTimeOffset.UtcNow, + DeadLetteredAtUtc = DateTimeOffset.UtcNow + }; + var maxedOut = new OutboxMessage + { + Id = Guid.NewGuid(), + EventType = "T", + EventPayload = "{}", + CreatedAtUtc = DateTimeOffset.UtcNow, + RetryCount = 3 // equals MaxRetries = 3, so excluded + }; + _dbContext.Set().AddRange(pending, processed, deadLettered, maxedOut); + await _dbContext.SaveChangesAsync(); + + var result = await _store.ClaimAsync("instance-1", 100, TimeSpan.FromMinutes(5)); + + result.Should().HaveCount(1); + result[0].Id.Should().Be(pending.Id); + result[0].LockedByInstanceId.Should().Be("instance-1"); + result[0].LockedUntilUtc.Should().NotBeNull(); + } + + [Fact] + public async Task ClaimAsync_RespectsNextRetryAtUtc() + { + var futureRetry = new OutboxMessage + { + Id = Guid.NewGuid(), + EventType = "T", + EventPayload = "{}", + CreatedAtUtc = DateTimeOffset.UtcNow, + RetryCount = 1, + NextRetryAtUtc = DateTimeOffset.UtcNow.AddMinutes(10) // in the future — should NOT be claimed + }; + var readyRetry = new OutboxMessage + { + Id = Guid.NewGuid(), + EventType = "T", + EventPayload = "{}", + CreatedAtUtc = DateTimeOffset.UtcNow, + RetryCount = 1, + NextRetryAtUtc = DateTimeOffset.UtcNow.AddMinutes(-1) // in the past — should be claimed + }; + _dbContext.Set().AddRange(futureRetry, readyRetry); + await _dbContext.SaveChangesAsync(); + + var result = await _store.ClaimAsync("instance-1", 100, TimeSpan.FromMinutes(5)); + + result.Should().HaveCount(1); + result[0].Id.Should().Be(readyRetry.Id); + } + + [Fact] + public async Task ClaimAsync_RespectsLockedUntilUtc() + { + var locked = new OutboxMessage + { + Id = Guid.NewGuid(), + EventType = "T", + EventPayload = "{}", + CreatedAtUtc = DateTimeOffset.UtcNow, + RetryCount = 0, + LockedByInstanceId = "other-instance", + LockedUntilUtc = DateTimeOffset.UtcNow.AddMinutes(5) // lock not expired — should NOT be claimed + }; + var expiredLock = new OutboxMessage + { + Id = Guid.NewGuid(), + EventType = "T", + EventPayload = "{}", + CreatedAtUtc = DateTimeOffset.UtcNow, + RetryCount = 0, + LockedByInstanceId = "other-instance", + LockedUntilUtc = DateTimeOffset.UtcNow.AddMinutes(-1) // lock expired — should be claimed + }; + _dbContext.Set().AddRange(locked, expiredLock); + await _dbContext.SaveChangesAsync(); + + var result = await _store.ClaimAsync("instance-1", 100, TimeSpan.FromMinutes(5)); + + result.Should().HaveCount(1); + result[0].Id.Should().Be(expiredLock.Id); + result[0].LockedByInstanceId.Should().Be("instance-1"); + } + + [Fact] + public async Task GetDeadLettersAsync_ReturnsOnlyDeadLettered() + { + var deadLettered = new OutboxMessage + { + Id = Guid.NewGuid(), + EventType = "T", + EventPayload = "{}", + CreatedAtUtc = DateTimeOffset.UtcNow, + DeadLetteredAtUtc = DateTimeOffset.UtcNow + }; + var pending = new OutboxMessage + { + Id = Guid.NewGuid(), + EventType = "T", + EventPayload = "{}", + CreatedAtUtc = DateTimeOffset.UtcNow + }; + var processed = new OutboxMessage + { + Id = Guid.NewGuid(), + EventType = "T", + EventPayload = "{}", + CreatedAtUtc = DateTimeOffset.UtcNow, + ProcessedAtUtc = DateTimeOffset.UtcNow + }; + _dbContext.Set().AddRange(deadLettered, pending, processed); + await _dbContext.SaveChangesAsync(); + + var result = await _store.GetDeadLettersAsync(100); + + result.Should().HaveCount(1); + result[0].Id.Should().Be(deadLettered.Id); + } + + [Fact] + public async Task GetDeadLettersAsync_PaginatesCorrectly() + { + var now = DateTimeOffset.UtcNow; + var messages = Enumerable.Range(0, 5).Select(i => new OutboxMessage + { + Id = Guid.NewGuid(), + EventType = "T", + EventPayload = "{}", + CreatedAtUtc = now, + DeadLetteredAtUtc = now.AddMinutes(-i) // different times so ordering is deterministic + }).ToList(); + + _dbContext.Set().AddRange(messages); + await _dbContext.SaveChangesAsync(); + + // Ordered descending by DeadLetteredAtUtc, skip 2, take 2 + var result = await _store.GetDeadLettersAsync(batchSize: 2, offset: 2); + + result.Should().HaveCount(2); + // The 3rd and 4th most recently dead-lettered messages (index 2 and 3 in descending order) + result[0].Id.Should().Be(messages[2].Id); + result[1].Id.Should().Be(messages[3].Id); + } + + [Fact] + public async Task ReplayDeadLetterAsync_ResetsAllFields() + { + var msg = new OutboxMessage + { + Id = Guid.NewGuid(), + EventType = "T", + EventPayload = "{}", + CreatedAtUtc = DateTimeOffset.UtcNow, + DeadLetteredAtUtc = DateTimeOffset.UtcNow, + ProcessedAtUtc = DateTimeOffset.UtcNow, + ErrorMessage = "some error", + RetryCount = 3, + NextRetryAtUtc = DateTimeOffset.UtcNow.AddMinutes(5), + LockedByInstanceId = "instance-1", + LockedUntilUtc = DateTimeOffset.UtcNow.AddMinutes(5) + }; + _dbContext.Set().Add(msg); + await _dbContext.SaveChangesAsync(); + + await _store.ReplayDeadLetterAsync(msg.Id); + + var updated = await _dbContext.Set().FindAsync(msg.Id); + updated!.DeadLetteredAtUtc.Should().BeNull(); + updated.ProcessedAtUtc.Should().BeNull(); + updated.ErrorMessage.Should().BeNull(); + updated.RetryCount.Should().Be(0); + updated.NextRetryAtUtc.Should().BeNull(); + updated.LockedByInstanceId.Should().BeNull(); + updated.LockedUntilUtc.Should().BeNull(); + } + + [Fact] + public async Task ReplayDeadLetterAsync_ThrowsForNonExistent() + { + var nonExistentId = Guid.NewGuid(); + + var act = async () => await _store.ReplayDeadLetterAsync(nonExistentId); + + await act.Should().ThrowAsync() + .WithMessage($"*{nonExistentId}*"); + } + + [Fact] + public async Task ReplayDeadLetterAsync_ThrowsForNonDeadLettered() + { + var msg = new OutboxMessage + { + Id = Guid.NewGuid(), + EventType = "T", + EventPayload = "{}", + CreatedAtUtc = DateTimeOffset.UtcNow + // DeadLetteredAtUtc is null — not dead-lettered + }; + _dbContext.Set().Add(msg); + await _dbContext.SaveChangesAsync(); + + var act = async () => await _store.ReplayDeadLetterAsync(msg.Id); + + await act.Should().ThrowAsync() + .WithMessage($"*{msg.Id}*"); + } + + public void Dispose() => _dbContext.Dispose(); +} diff --git a/Tests/RCommon.EfCore.Tests/RCommon.EfCore.Tests.csproj b/Tests/RCommon.EfCore.Tests/RCommon.EfCore.Tests.csproj index 759897da..a02784ae 100644 --- a/Tests/RCommon.EfCore.Tests/RCommon.EfCore.Tests.csproj +++ b/Tests/RCommon.EfCore.Tests/RCommon.EfCore.Tests.csproj @@ -2,6 +2,7 @@ + diff --git a/Tests/RCommon.Entities.Tests/AggregateRootTests.cs b/Tests/RCommon.Entities.Tests/AggregateRootTests.cs new file mode 100644 index 00000000..b721aba5 --- /dev/null +++ b/Tests/RCommon.Entities.Tests/AggregateRootTests.cs @@ -0,0 +1,297 @@ +using Bogus; +using FluentAssertions; +using Moq; +using RCommon.Entities; +using RCommon.EventHandling.Producers; +using RCommon.Models.Events; +using Xunit; + +namespace RCommon.Entities.Tests; + +/// +/// Unit tests for AggregateRoot{TKey}, IAggregateRoot, and IAggregateRoot{TKey}. +/// +public class AggregateRootTests +{ + private readonly Faker _faker; + + public AggregateRootTests() + { + _faker = new Faker(); + } + + #region Test Types + + /// + /// Concrete aggregate root for testing with int key. + /// Exposes protected methods for test access. + /// + private class TestAggregateInt : AggregateRoot + { + public string Name { get; set; } = string.Empty; + + public TestAggregateInt() : base() { } + + public TestAggregateInt(int id) : base(id) { } + + /// + /// Public wrapper for the protected AddDomainEvent method. + /// + public void RaiseDomainEvent(IDomainEvent domainEvent) + => AddDomainEvent(domainEvent); + + /// + /// Public wrapper for the protected RemoveDomainEvent method. + /// + public void UndoDomainEvent(IDomainEvent domainEvent) + => RemoveDomainEvent(domainEvent); + + /// + /// Public wrapper for the protected IncrementVersion method. + /// + public void BumpVersion() + => IncrementVersion(); + } + + private class TestAggregateGuid : AggregateRoot + { + public TestAggregateGuid() : base() { } + + public TestAggregateGuid(Guid id) : base(id) { } + + public void RaiseDomainEvent(IDomainEvent domainEvent) + => AddDomainEvent(domainEvent); + } + + private record TestDomainEvent(string Message) : DomainEvent; + + private record TestOtherDomainEvent(int Code) : DomainEvent; + + #endregion + + #region Interface Conformance Tests + + [Fact] + public void AggregateRoot_Implements_IAggregateRoot() + { + var aggregate = new TestAggregateInt(1); + aggregate.Should().BeAssignableTo(); + } + + [Fact] + public void AggregateRoot_Implements_IAggregateRootGeneric() + { + var aggregate = new TestAggregateInt(1); + aggregate.Should().BeAssignableTo>(); + } + + [Fact] + public void AggregateRoot_Implements_IBusinessEntity() + { + var aggregate = new TestAggregateInt(1); + aggregate.Should().BeAssignableTo(); + } + + [Fact] + public void AggregateRoot_Implements_IBusinessEntityGeneric() + { + var aggregate = new TestAggregateInt(1); + aggregate.Should().BeAssignableTo>(); + } + + #endregion + + #region Identity Tests + + [Fact] + public void AggregateRoot_ConstructorWithId_SetsId() + { + var aggregate = new TestAggregateInt(42); + aggregate.Id.Should().Be(42); + } + + [Fact] + public void AggregateRoot_DefaultConstructor_IdIsDefault() + { + var aggregate = new TestAggregateInt(); + aggregate.Id.Should().Be(0); + } + + [Fact] + public void AggregateRoot_GuidKey_SetsId() + { + var id = Guid.NewGuid(); + var aggregate = new TestAggregateGuid(id); + aggregate.Id.Should().Be(id); + } + + #endregion + + #region Version Tests + + [Fact] + public void AggregateRoot_DefaultVersion_IsZero() + { + var aggregate = new TestAggregateInt(1); + aggregate.Version.Should().Be(0); + } + + [Fact] + public void IncrementVersion_IncrementsVersionByOne() + { + var aggregate = new TestAggregateInt(1); + aggregate.BumpVersion(); + aggregate.Version.Should().Be(1); + } + + [Fact] + public void IncrementVersion_CalledMultipleTimes_VersionIncrementsCorrectly() + { + var aggregate = new TestAggregateInt(1); + aggregate.BumpVersion(); + aggregate.BumpVersion(); + aggregate.BumpVersion(); + aggregate.Version.Should().Be(3); + } + + #endregion + + #region Domain Event Add/Remove/Clear Tests + + [Fact] + public void AddDomainEvent_AddsEventToDomainEvents() + { + var aggregate = new TestAggregateInt(1); + var domainEvent = new TestDomainEvent("test"); + aggregate.RaiseDomainEvent(domainEvent); + aggregate.DomainEvents.Should().ContainSingle() + .Which.Should().Be(domainEvent); + } + + [Fact] + public void AddDomainEvent_MultipleEvents_AllPresent() + { + var aggregate = new TestAggregateInt(1); + var event1 = new TestDomainEvent("first"); + var event2 = new TestOtherDomainEvent(42); + aggregate.RaiseDomainEvent(event1); + aggregate.RaiseDomainEvent(event2); + aggregate.DomainEvents.Should().HaveCount(2); + aggregate.DomainEvents.Should().Contain(event1); + aggregate.DomainEvents.Should().Contain(event2); + } + + [Fact] + public void RemoveDomainEvent_RemovesEventFromDomainEvents() + { + var aggregate = new TestAggregateInt(1); + var domainEvent = new TestDomainEvent("test"); + aggregate.RaiseDomainEvent(domainEvent); + aggregate.UndoDomainEvent(domainEvent); + aggregate.DomainEvents.Should().BeEmpty(); + } + + [Fact] + public void ClearDomainEvents_RemovesAllEvents() + { + var aggregate = new TestAggregateInt(1); + aggregate.RaiseDomainEvent(new TestDomainEvent("first")); + aggregate.RaiseDomainEvent(new TestOtherDomainEvent(42)); + aggregate.ClearDomainEvents(); + aggregate.DomainEvents.Should().BeEmpty(); + } + + [Fact] + public void DomainEvents_WhenEmpty_ReturnsEmptyCollection() + { + var aggregate = new TestAggregateInt(1); + aggregate.DomainEvents.Should().BeEmpty(); + aggregate.DomainEvents.Should().NotBeNull(); + } + + #endregion + + #region Dual-List Sync Tests (DomainEvents + LocalEvents) + + [Fact] + public void AddDomainEvent_AlsoAppearsInLocalEvents() + { + var aggregate = new TestAggregateInt(1); + var domainEvent = new TestDomainEvent("test"); + aggregate.RaiseDomainEvent(domainEvent); + aggregate.DomainEvents.Should().ContainSingle().Which.Should().Be(domainEvent); + aggregate.LocalEvents.Should().ContainSingle().Which.Should().Be(domainEvent); + } + + [Fact] + public void RemoveDomainEvent_AlsoRemovesFromLocalEvents() + { + var aggregate = new TestAggregateInt(1); + var domainEvent = new TestDomainEvent("test"); + aggregate.RaiseDomainEvent(domainEvent); + aggregate.UndoDomainEvent(domainEvent); + aggregate.DomainEvents.Should().BeEmpty(); + aggregate.LocalEvents.Should().BeEmpty(); + } + + [Fact] + public void ClearDomainEvents_AlsoClearsLocalEvents() + { + var aggregate = new TestAggregateInt(1); + aggregate.RaiseDomainEvent(new TestDomainEvent("one")); + aggregate.RaiseDomainEvent(new TestDomainEvent("two")); + aggregate.ClearDomainEvents(); + aggregate.DomainEvents.Should().BeEmpty(); + aggregate.LocalEvents.Should().BeEmpty(); + } + + #endregion + + #region Event Pipeline Integration Tests + + [Fact] + public async Task DomainEvents_FlowThrough_EntityEventTracker() + { + // Arrange + var mockEventRouter = new Mock(); + mockEventRouter.Setup(x => x.RouteEventsAsync()).Returns(Task.CompletedTask); + var tracker = new InMemoryEntityEventTracker(mockEventRouter.Object); + + var aggregate = new TestAggregateInt(1); + aggregate.AllowEventTracking = true; + var domainEvent = new TestDomainEvent("integration test"); + aggregate.RaiseDomainEvent(domainEvent); + + // Act + tracker.AddEntity(aggregate); + await tracker.EmitTransactionalEventsAsync(); + + // Assert — the domain event (which IS-A ISerializableEvent) was routed + mockEventRouter.Verify( + x => x.AddTransactionalEvents(It.Is>( + events => events.Contains(domainEvent))), + Times.AtLeastOnce); + mockEventRouter.Verify(x => x.RouteEventsAsync(), Times.Once); + } + + #endregion + + #region Inherited BusinessEntity Behavior Tests + + [Fact] + public void AggregateRoot_GetKeys_ReturnsId() + { + var aggregate = new TestAggregateInt(42); + var keys = aggregate.GetKeys(); + keys.Should().ContainSingle().Which.Should().Be(42); + } + + [Fact] + public void AggregateRoot_EntityEquals_SameReference_ReturnsTrue() + { + var aggregate = new TestAggregateInt(42); + aggregate.EntityEquals(aggregate).Should().BeTrue(); + } + + #endregion +} diff --git a/Tests/RCommon.Entities.Tests/DomainEntityTests.cs b/Tests/RCommon.Entities.Tests/DomainEntityTests.cs new file mode 100644 index 00000000..83367c44 --- /dev/null +++ b/Tests/RCommon.Entities.Tests/DomainEntityTests.cs @@ -0,0 +1,313 @@ +using Bogus; +using FluentAssertions; +using RCommon.Entities; +using Xunit; + +namespace RCommon.Entities.Tests; + +/// +/// Unit tests for DomainEntity{TKey} abstract class. +/// +public class DomainEntityTests +{ + private readonly Faker _faker; + + public DomainEntityTests() + { + _faker = new Faker(); + } + + #region Test Entities + + private class TestDomainEntityInt : DomainEntity + { + public string Name { get; set; } = string.Empty; + + public TestDomainEntityInt() { } + + public TestDomainEntityInt(int id) + { + Id = id; + } + } + + private class TestDomainEntityGuid : DomainEntity + { + public string Name { get; set; } = string.Empty; + + public TestDomainEntityGuid() { } + + public TestDomainEntityGuid(Guid id) + { + Id = id; + } + } + + private class TestDomainEntityString : DomainEntity + { + public string Name { get; set; } = string.Empty; + + public TestDomainEntityString() { } + + public TestDomainEntityString(string id) + { + Id = id; + } + } + + /// + /// A different entity type with the same key type, for cross-type equality tests. + /// + private class TestOtherDomainEntityInt : DomainEntity + { + public TestOtherDomainEntityInt(int id) + { + Id = id; + } + } + + #endregion + + #region Identity Tests + + [Fact] + public void DomainEntity_DefaultConstructor_IdIsDefault() + { + var entity = new TestDomainEntityInt(); + entity.Id.Should().Be(default(int)); + } + + [Fact] + public void DomainEntity_ConstructorWithId_SetsId() + { + var id = _faker.Random.Int(1, 1000); + var entity = new TestDomainEntityInt(id); + entity.Id.Should().Be(id); + } + + [Fact] + public void DomainEntity_GuidKey_SetsId() + { + var id = Guid.NewGuid(); + var entity = new TestDomainEntityGuid(id); + entity.Id.Should().Be(id); + } + + [Fact] + public void DomainEntity_StringKey_SetsId() + { + var id = _faker.Random.AlphaNumeric(10); + var entity = new TestDomainEntityString(id); + entity.Id.Should().Be(id); + } + + #endregion + + #region Transient Detection Tests + + [Fact] + public void IsTransient_DefaultIntId_ReturnsTrue() + { + var entity = new TestDomainEntityInt(); + entity.IsTransient().Should().BeTrue(); + } + + [Fact] + public void IsTransient_DefaultGuidId_ReturnsTrue() + { + var entity = new TestDomainEntityGuid(); + entity.IsTransient().Should().BeTrue(); + } + + [Fact] + public void IsTransient_NullStringId_ReturnsTrue() + { + var entity = new TestDomainEntityString(); + entity.IsTransient().Should().BeTrue(); + } + + [Fact] + public void IsTransient_NonDefaultId_ReturnsFalse() + { + var entity = new TestDomainEntityInt(42); + entity.IsTransient().Should().BeFalse(); + } + + [Fact] + public void IsTransient_NonEmptyGuidId_ReturnsFalse() + { + var entity = new TestDomainEntityGuid(Guid.NewGuid()); + entity.IsTransient().Should().BeFalse(); + } + + #endregion + + #region Equality Tests + + [Fact] + public void Equals_SameId_ReturnsTrue() + { + var entity1 = new TestDomainEntityInt(42); + var entity2 = new TestDomainEntityInt(42); + entity1.Equals(entity2).Should().BeTrue(); + } + + [Fact] + public void Equals_DifferentId_ReturnsFalse() + { + var entity1 = new TestDomainEntityInt(42); + var entity2 = new TestDomainEntityInt(99); + entity1.Equals(entity2).Should().BeFalse(); + } + + [Fact] + public void Equals_SameReference_ReturnsTrue() + { + var entity = new TestDomainEntityInt(42); + entity.Equals(entity).Should().BeTrue(); + } + + [Fact] + public void Equals_Null_ReturnsFalse() + { + var entity = new TestDomainEntityInt(42); + entity.Equals(null).Should().BeFalse(); + } + + [Fact] + public void Equals_DifferentType_SameId_ReturnsFalse() + { + var entity1 = new TestDomainEntityInt(42); + var entity2 = new TestOtherDomainEntityInt(42); + entity1.Equals(entity2).Should().BeFalse(); + } + + [Fact] + public void Equals_BothTransient_ReturnsFalse() + { + var entity1 = new TestDomainEntityInt(); + var entity2 = new TestDomainEntityInt(); + entity1.Equals(entity2).Should().BeFalse(); + } + + [Fact] + public void Equals_OneTransient_ReturnsFalse() + { + var entity1 = new TestDomainEntityInt(42); + var entity2 = new TestDomainEntityInt(); + entity1.Equals(entity2).Should().BeFalse(); + } + + [Fact] + public void Equals_ObjectOverload_WorksCorrectly() + { + var entity1 = new TestDomainEntityInt(42); + object entity2 = new TestDomainEntityInt(42); + entity1.Equals(entity2).Should().BeTrue(); + } + + [Fact] + public void Equals_NonDomainEntityObject_ReturnsFalse() + { + var entity = new TestDomainEntityInt(42); + var nonEntity = "not an entity"; + entity.Equals(nonEntity).Should().BeFalse(); + } + + #endregion + + #region GetHashCode Tests + + [Fact] + public void GetHashCode_SameId_ReturnsSameHash() + { + var entity1 = new TestDomainEntityInt(42); + var entity2 = new TestDomainEntityInt(42); + entity1.GetHashCode().Should().Be(entity2.GetHashCode()); + } + + [Fact] + public void GetHashCode_TransientEntity_ReturnsObjectHashCode() + { + var entity1 = new TestDomainEntityInt(); + var entity2 = new TestDomainEntityInt(); + entity1.GetHashCode().Should().NotBe(0); + entity2.GetHashCode().Should().NotBe(0); + } + + #endregion + + #region Operator Tests + + [Fact] + public void EqualityOperator_SameId_ReturnsTrue() + { + var entity1 = new TestDomainEntityInt(42); + var entity2 = new TestDomainEntityInt(42); + (entity1 == entity2).Should().BeTrue(); + } + + [Fact] + public void EqualityOperator_DifferentId_ReturnsFalse() + { + var entity1 = new TestDomainEntityInt(42); + var entity2 = new TestDomainEntityInt(99); + (entity1 == entity2).Should().BeFalse(); + } + + [Fact] + public void EqualityOperator_BothNull_ReturnsTrue() + { + TestDomainEntityInt? entity1 = null; + TestDomainEntityInt? entity2 = null; + (entity1 == entity2).Should().BeTrue(); + } + + [Fact] + public void EqualityOperator_OneNull_ReturnsFalse() + { + var entity1 = new TestDomainEntityInt(42); + TestDomainEntityInt? entity2 = null; + (entity1 == entity2).Should().BeFalse(); + } + + [Fact] + public void InequalityOperator_DifferentId_ReturnsTrue() + { + var entity1 = new TestDomainEntityInt(42); + var entity2 = new TestDomainEntityInt(99); + (entity1 != entity2).Should().BeTrue(); + } + + [Fact] + public void InequalityOperator_SameId_ReturnsFalse() + { + var entity1 = new TestDomainEntityInt(42); + var entity2 = new TestDomainEntityInt(42); + (entity1 != entity2).Should().BeFalse(); + } + + #endregion + + #region IEquatable Tests + + [Fact] + public void DomainEntity_Implements_IEquatable() + { + var entity = new TestDomainEntityInt(42); + entity.Should().BeAssignableTo>>(); + } + + #endregion + + #region Does NOT implement IBusinessEntity + + [Fact] + public void DomainEntity_DoesNotImplement_IBusinessEntity() + { + var entity = new TestDomainEntityInt(42); + entity.Should().NotBeAssignableTo(); + } + + #endregion +} diff --git a/Tests/RCommon.Entities.Tests/DomainEventTests.cs b/Tests/RCommon.Entities.Tests/DomainEventTests.cs new file mode 100644 index 00000000..769769b0 --- /dev/null +++ b/Tests/RCommon.Entities.Tests/DomainEventTests.cs @@ -0,0 +1,190 @@ +// Tests/RCommon.Entities.Tests/DomainEventTests.cs +using FluentAssertions; +using RCommon.Entities; +using RCommon.Models.Events; +using Xunit; + +namespace RCommon.Entities.Tests; + +/// +/// Unit tests for IDomainEvent interface and DomainEvent abstract record. +/// +public class DomainEventTests +{ + #region Test Domain Events + + /// + /// Concrete domain event for testing. + /// + private record TestOrderPlacedEvent(Guid OrderId, decimal Total) : DomainEvent; + + /// + /// Another concrete domain event for equality testing. + /// + private record TestOrderCancelledEvent(Guid OrderId, string Reason) : DomainEvent; + + #endregion + + #region IDomainEvent Contract Tests + + [Fact] + public void DomainEvent_Implements_IDomainEvent() + { + // Arrange & Act + var domainEvent = new TestOrderPlacedEvent(Guid.NewGuid(), 99.99m); + + // Assert + domainEvent.Should().BeAssignableTo(); + } + + [Fact] + public void DomainEvent_Implements_ISerializableEvent() + { + // Arrange & Act + var domainEvent = new TestOrderPlacedEvent(Guid.NewGuid(), 99.99m); + + // Assert + domainEvent.Should().BeAssignableTo(); + } + + #endregion + + #region Default Property Tests + + [Fact] + public void DomainEvent_EventId_IsAssignedByDefault() + { + // Arrange & Act + var domainEvent = new TestOrderPlacedEvent(Guid.NewGuid(), 99.99m); + + // Assert + domainEvent.EventId.Should().NotBe(Guid.Empty); + } + + [Fact] + public void DomainEvent_OccurredOn_IsAssignedByDefault() + { + // Arrange & Act + var before = DateTimeOffset.UtcNow; + var domainEvent = new TestOrderPlacedEvent(Guid.NewGuid(), 99.99m); + var after = DateTimeOffset.UtcNow; + + // Assert + domainEvent.OccurredOn.Should().BeOnOrAfter(before); + domainEvent.OccurredOn.Should().BeOnOrBefore(after); + } + + [Fact] + public void DomainEvent_TwoInstances_HaveDifferentEventIds() + { + // Arrange & Act + var event1 = new TestOrderPlacedEvent(Guid.NewGuid(), 99.99m); + var event2 = new TestOrderPlacedEvent(Guid.NewGuid(), 99.99m); + + // Assert + event1.EventId.Should().NotBe(event2.EventId); + } + + #endregion + + #region Init Property Override Tests + + [Fact] + public void DomainEvent_EventId_CanBeOverriddenViaInit() + { + // Arrange + var customId = Guid.NewGuid(); + + // Act + var domainEvent = new TestOrderPlacedEvent(Guid.NewGuid(), 99.99m) + { + EventId = customId + }; + + // Assert + domainEvent.EventId.Should().Be(customId); + } + + [Fact] + public void DomainEvent_OccurredOn_CanBeOverriddenViaInit() + { + // Arrange + var customTime = new DateTimeOffset(2024, 1, 15, 10, 30, 0, TimeSpan.Zero); + + // Act + var domainEvent = new TestOrderPlacedEvent(Guid.NewGuid(), 99.99m) + { + OccurredOn = customTime + }; + + // Assert + domainEvent.OccurredOn.Should().Be(customTime); + } + + #endregion + + #region Record Equality Tests + + [Fact] + public void DomainEvent_SameValues_AreEqual() + { + // Arrange + var orderId = Guid.NewGuid(); + var eventId = Guid.NewGuid(); + var occurredOn = DateTimeOffset.UtcNow; + + // Act + var event1 = new TestOrderPlacedEvent(orderId, 99.99m) { EventId = eventId, OccurredOn = occurredOn }; + var event2 = new TestOrderPlacedEvent(orderId, 99.99m) { EventId = eventId, OccurredOn = occurredOn }; + + // Assert + event1.Should().Be(event2); + } + + [Fact] + public void DomainEvent_DifferentValues_AreNotEqual() + { + // Arrange & Act + var event1 = new TestOrderPlacedEvent(Guid.NewGuid(), 99.99m); + var event2 = new TestOrderPlacedEvent(Guid.NewGuid(), 50.00m); + + // Assert + event1.Should().NotBe(event2); + } + + [Fact] + public void DomainEvent_DifferentTypes_AreNotEqual() + { + // Arrange + var orderId = Guid.NewGuid(); + + // Act + var placedEvent = new TestOrderPlacedEvent(orderId, 99.99m); + var cancelledEvent = new TestOrderCancelledEvent(orderId, "Changed mind"); + + // Assert + placedEvent.Should().NotBe(cancelledEvent); + } + + #endregion + + #region With-Expression Tests + + [Fact] + public void DomainEvent_WithExpression_CreatesModifiedCopy() + { + // Arrange + var original = new TestOrderPlacedEvent(Guid.NewGuid(), 99.99m); + + // Act + var modified = original with { Total = 149.99m }; + + // Assert + modified.Total.Should().Be(149.99m); + modified.OrderId.Should().Be(original.OrderId); + modified.EventId.Should().Be(original.EventId); + modified.OccurredOn.Should().Be(original.OccurredOn); + } + + #endregion +} diff --git a/Tests/RCommon.Entities.Tests/InMemoryEntityEventTrackerTests.cs b/Tests/RCommon.Entities.Tests/InMemoryEntityEventTrackerTests.cs index 505b415d..947d22a7 100644 --- a/Tests/RCommon.Entities.Tests/InMemoryEntityEventTrackerTests.cs +++ b/Tests/RCommon.Entities.Tests/InMemoryEntityEventTrackerTests.cs @@ -257,7 +257,7 @@ public async Task EmitTransactionalEventsAsync_WithTrackedEntity_CallsRouteEvent await tracker.EmitTransactionalEventsAsync(); // Assert - _mockEventRouter.Verify(x => x.RouteEventsAsync(), Times.Once); + _mockEventRouter.Verify(x => x.RouteEventsAsync(It.IsAny()), Times.Once); } [Fact] @@ -320,7 +320,7 @@ public async Task EmitTransactionalEventsAsync_WithMultipleEntities_ProcessesAll // Assert result.Should().BeTrue(); - _mockEventRouter.Verify(x => x.RouteEventsAsync(), Times.Once); + _mockEventRouter.Verify(x => x.RouteEventsAsync(It.IsAny()), Times.Once); } [Fact] @@ -336,7 +336,7 @@ public async Task EmitTransactionalEventsAsync_WithEntityWithoutEvents_StillCall await tracker.EmitTransactionalEventsAsync(); // Assert - _mockEventRouter.Verify(x => x.RouteEventsAsync(), Times.Once); + _mockEventRouter.Verify(x => x.RouteEventsAsync(It.IsAny()), Times.Once); } [Fact] @@ -416,7 +416,7 @@ public async Task FullWorkflow_AddEntitiesWithEvents_EmitsSuccessfully() // Assert result.Should().BeTrue(); tracker.TrackedEntities.Should().HaveCount(2); - _mockEventRouter.Verify(x => x.RouteEventsAsync(), Times.Once); + _mockEventRouter.Verify(x => x.RouteEventsAsync(It.IsAny()), Times.Once); } [Fact] @@ -433,7 +433,7 @@ public async Task EmitTransactionalEventsAsync_CalledMultipleTimes_CallsRouteEve await tracker.EmitTransactionalEventsAsync(); // Assert - _mockEventRouter.Verify(x => x.RouteEventsAsync(), Times.Exactly(3)); + _mockEventRouter.Verify(x => x.RouteEventsAsync(It.IsAny()), Times.Exactly(3)); } #endregion diff --git a/Tests/RCommon.Entities.Tests/ValueObjectTests.cs b/Tests/RCommon.Entities.Tests/ValueObjectTests.cs new file mode 100644 index 00000000..88b8c08e --- /dev/null +++ b/Tests/RCommon.Entities.Tests/ValueObjectTests.cs @@ -0,0 +1,185 @@ +using FluentAssertions; +using RCommon.Entities; +using Xunit; + +namespace RCommon.Entities.Tests; + +/// +/// Unit tests for ValueObject abstract record. +/// +public class ValueObjectTests +{ + #region Test Value Objects + + private record Money(decimal Amount, string Currency) : ValueObject; + + private record Address(string Street, string City, string ZipCode) : ValueObject; + + private record EmailAddress(string Value) : ValueObject(Value) + { + public static implicit operator EmailAddress(string value) => new(value); + } + + private record CustomerId(Guid Value) : ValueObject(Value) + { + public static implicit operator CustomerId(Guid value) => new(value); + } + + #endregion + + #region Structural Equality Tests + + [Fact] + public void ValueObject_SameValues_AreEqual() + { + var money1 = new Money(100.00m, "USD"); + var money2 = new Money(100.00m, "USD"); + money1.Should().Be(money2); + } + + [Fact] + public void ValueObject_DifferentValues_AreNotEqual() + { + var money1 = new Money(100.00m, "USD"); + var money2 = new Money(200.00m, "USD"); + money1.Should().NotBe(money2); + } + + [Fact] + public void ValueObject_DifferentTypes_AreNotEqual() + { + var money = new Money(100.00m, "USD"); + var address = new Address("123 Main St", "Springfield", "62701"); + money.Should().NotBe(address); + } + + [Fact] + public void ValueObject_SameValues_HaveSameHashCode() + { + var money1 = new Money(100.00m, "USD"); + var money2 = new Money(100.00m, "USD"); + money1.GetHashCode().Should().Be(money2.GetHashCode()); + } + + [Fact] + public void ValueObject_DifferentValues_HaveDifferentHashCode() + { + var money1 = new Money(100.00m, "USD"); + var money2 = new Money(200.00m, "EUR"); + money1.GetHashCode().Should().NotBe(money2.GetHashCode()); + } + + #endregion + + #region Operator Tests + + [Fact] + public void ValueObject_EqualityOperator_ReturnsTrueForSameValues() + { + var money1 = new Money(50.00m, "GBP"); + var money2 = new Money(50.00m, "GBP"); + (money1 == money2).Should().BeTrue(); + } + + [Fact] + public void ValueObject_InequalityOperator_ReturnsTrueForDifferentValues() + { + var money1 = new Money(50.00m, "GBP"); + var money2 = new Money(75.00m, "GBP"); + (money1 != money2).Should().BeTrue(); + } + + #endregion + + #region Immutability Tests + + [Fact] + public void ValueObject_WithExpression_CreatesModifiedCopy() + { + var original = new Money(100.00m, "USD"); + var modified = original with { Amount = 200.00m }; + modified.Amount.Should().Be(200.00m); + modified.Currency.Should().Be("USD"); + original.Amount.Should().Be(100.00m, "original should be unchanged"); + } + + #endregion + + #region Interface Conformance Tests + + [Fact] + public void ValueObject_ConcreteType_IsAssignableToValueObject() + { + var money = new Money(100.00m, "USD"); + money.Should().BeAssignableTo(); + } + + #endregion + + #region Generic ValueObject Tests + + [Fact] + public void GenericValueObject_SameValues_AreEqual() + { + var email1 = new EmailAddress("user@example.com"); + var email2 = new EmailAddress("user@example.com"); + email1.Should().Be(email2); + } + + [Fact] + public void GenericValueObject_DifferentValues_AreNotEqual() + { + var email1 = new EmailAddress("user@example.com"); + var email2 = new EmailAddress("other@example.com"); + email1.Should().NotBe(email2); + } + + [Fact] + public void GenericValueObject_ImplicitConversionToUnderlyingType() + { + var email = new EmailAddress("user@example.com"); + string raw = email; + raw.Should().Be("user@example.com"); + } + + [Fact] + public void GenericValueObject_ImplicitConversionFromUnderlyingType() + { + EmailAddress email = "user@example.com"; + email.Value.Should().Be("user@example.com"); + } + + [Fact] + public void GenericValueObject_ToString_ReturnsUnderlyingValue() + { + var email = new EmailAddress("user@example.com"); + email.ToString().Should().Be("user@example.com"); + } + + [Fact] + public void GenericValueObject_IsAssignableToValueObject() + { + var email = new EmailAddress("user@example.com"); + email.Should().BeAssignableTo>(); + email.Should().BeAssignableTo(); + } + + [Fact] + public void GenericValueObject_WithGuidValue_WorksCorrectly() + { + var id = Guid.NewGuid(); + CustomerId customerId = id; + Guid raw = customerId; + raw.Should().Be(id); + } + + [Fact] + public void GenericValueObject_SameValues_HaveSameHashCode() + { + var email1 = new EmailAddress("user@example.com"); + var email2 = new EmailAddress("user@example.com"); + email1.GetHashCode().Should().Be(email2.GetHashCode()); + } + + #endregion +} diff --git a/Tests/RCommon.Linq2Db.Tests/Linq2DbInboxStoreTests.cs b/Tests/RCommon.Linq2Db.Tests/Linq2DbInboxStoreTests.cs new file mode 100644 index 00000000..83b598b5 --- /dev/null +++ b/Tests/RCommon.Linq2Db.Tests/Linq2DbInboxStoreTests.cs @@ -0,0 +1,47 @@ +using FluentAssertions; +using Microsoft.Extensions.Options; +using Moq; +using RCommon.Persistence; +using RCommon.Persistence.Linq2Db.Inbox; +using RCommon.Persistence.Outbox; +using Xunit; + +namespace RCommon.Linq2Db.Tests; + +public class Linq2DbInboxStoreTests +{ + [Fact] + public void Constructor_NullDataStoreFactory_ThrowsArgumentNullException() + { + var act = () => new Linq2DbInboxStore( + null!, + Options.Create(new DefaultDataStoreOptions { DefaultDataStoreName = "test" }), + Options.Create(new OutboxOptions())); + + act.Should().Throw().WithParameterName("dataStoreFactory"); + } + + [Fact] + public void Constructor_NullDefaultDataStoreOptions_ThrowsArgumentNullException() + { + var factoryMock = new Mock(); + var act = () => new Linq2DbInboxStore( + factoryMock.Object, + Options.Create(new DefaultDataStoreOptions()), + Options.Create(new OutboxOptions())); + + act.Should().Throw().WithParameterName("defaultDataStoreOptions"); + } + + [Fact] + public void Constructor_SucceedsWithValidParameters() + { + var factoryMock = new Mock(); + var store = new Linq2DbInboxStore( + factoryMock.Object, + Options.Create(new DefaultDataStoreOptions { DefaultDataStoreName = "test" }), + Options.Create(new OutboxOptions())); + + store.Should().NotBeNull(); + } +} diff --git a/Tests/RCommon.Linq2Db.Tests/Linq2DbOutboxStoreTests.cs b/Tests/RCommon.Linq2Db.Tests/Linq2DbOutboxStoreTests.cs new file mode 100644 index 00000000..857fe41c --- /dev/null +++ b/Tests/RCommon.Linq2Db.Tests/Linq2DbOutboxStoreTests.cs @@ -0,0 +1,69 @@ +using FluentAssertions; +using Microsoft.Extensions.Options; +using Moq; +using RCommon.Persistence; +using RCommon.Persistence.Linq2Db.Outbox; +using RCommon.Persistence.Outbox; +using Xunit; + +namespace RCommon.Linq2Db.Tests; + +public class Linq2DbOutboxStoreTests +{ + private readonly Mock _lockProviderMock = new(); + + public Linq2DbOutboxStoreTests() + { + _lockProviderMock.Setup(l => l.ProviderName).Returns("SqlServer"); + } + + [Fact] + public void Constructor_ThrowsOnNullDataStoreFactory() + { + var act = () => new Linq2DbOutboxStore( + null!, + Options.Create(new DefaultDataStoreOptions { DefaultDataStoreName = "test" }), + Options.Create(new OutboxOptions()), + _lockProviderMock.Object); + + act.Should().Throw(); + } + + [Fact] + public void Constructor_ThrowsOnNullDefaultDataStoreOptions() + { + var factoryMock = new Mock(); + var act = () => new Linq2DbOutboxStore( + factoryMock.Object, + Options.Create(new DefaultDataStoreOptions()), + Options.Create(new OutboxOptions()), + _lockProviderMock.Object); + + act.Should().Throw(); + } + + [Fact] + public void Constructor_SucceedsWithValidParameters() + { + var factoryMock = new Mock(); + var store = new Linq2DbOutboxStore( + factoryMock.Object, + Options.Create(new DefaultDataStoreOptions { DefaultDataStoreName = "test" }), + Options.Create(new OutboxOptions()), + _lockProviderMock.Object); + + store.Should().NotBeNull(); + } + + [Fact] + public void Constructor_NullLockStatementProvider_ThrowsArgumentNullException() + { + var factoryMock = new Mock(); + var act = () => new Linq2DbOutboxStore( + factoryMock.Object, + Options.Create(new DefaultDataStoreOptions { DefaultDataStoreName = "test" }), + Options.Create(new OutboxOptions()), + null!); + act.Should().Throw().WithParameterName("lockStatementProvider"); + } +} diff --git a/Tests/RCommon.MassTransit.Outbox.Tests/MassTransitOutboxBuilderTests.cs b/Tests/RCommon.MassTransit.Outbox.Tests/MassTransitOutboxBuilderTests.cs new file mode 100644 index 00000000..18d63961 --- /dev/null +++ b/Tests/RCommon.MassTransit.Outbox.Tests/MassTransitOutboxBuilderTests.cs @@ -0,0 +1,61 @@ +using FluentAssertions; +using MassTransit; +using MassTransit.EntityFrameworkCoreIntegration; +using Moq; +using RCommon.MassTransit.Outbox; +using Xunit; + +namespace RCommon.MassTransit.Outbox.Tests; + +public class MassTransitOutboxBuilderTests +{ + /// + /// UseSqlServer and UsePostgres are extension methods on IEntityFrameworkOutboxConfigurator. + /// They cannot be verified via Moq directly, so we verify that they set the LockStatementProvider + /// property on the underlying configurator, which is the actual contract being fulfilled. + /// + [Fact] + public void UseSqlServer_SetsLockStatementProviderOnConfigurator() + { + var configuratorMock = new Mock(); + var builder = new MassTransitOutboxBuilder(configuratorMock.Object); + + var result = builder.UseSqlServer(); + + result.Should().BeSameAs(builder); + // UseSqlServer() is an extension method that sets LockStatementProvider to SqlServerLockStatementProvider + configuratorMock.VerifySet(c => c.LockStatementProvider = It.IsAny(), Times.Once); + } + + [Fact] + public void UsePostgres_SetsLockStatementProviderOnConfigurator() + { + var configuratorMock = new Mock(); + var builder = new MassTransitOutboxBuilder(configuratorMock.Object); + + var result = builder.UsePostgres(); + + result.Should().BeSameAs(builder); + // UsePostgres() is an extension method that sets LockStatementProvider to PostgresLockStatementProvider + configuratorMock.VerifySet(c => c.LockStatementProvider = It.IsAny(), Times.Once); + } + + [Fact] + public void UseBusOutbox_DelegatesToConfigurator() + { + var configuratorMock = new Mock(); + var builder = new MassTransitOutboxBuilder(configuratorMock.Object); + + var result = builder.UseBusOutbox(); + + result.Should().BeSameAs(builder); + configuratorMock.Verify(c => c.UseBusOutbox(It.IsAny>()), Times.Once); + } + + [Fact] + public void Constructor_ThrowsOnNull() + { + var act = () => new MassTransitOutboxBuilder(null!); + act.Should().Throw(); + } +} diff --git a/Tests/RCommon.MassTransit.Outbox.Tests/RCommon.MassTransit.Outbox.Tests.csproj b/Tests/RCommon.MassTransit.Outbox.Tests/RCommon.MassTransit.Outbox.Tests.csproj new file mode 100644 index 00000000..9e2cad86 --- /dev/null +++ b/Tests/RCommon.MassTransit.Outbox.Tests/RCommon.MassTransit.Outbox.Tests.csproj @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/Tests/RCommon.MassTransit.StateMachines.Tests/MassTransitStateMachineConfiguratorTests.cs b/Tests/RCommon.MassTransit.StateMachines.Tests/MassTransitStateMachineConfiguratorTests.cs new file mode 100644 index 00000000..4d42ca22 --- /dev/null +++ b/Tests/RCommon.MassTransit.StateMachines.Tests/MassTransitStateMachineConfiguratorTests.cs @@ -0,0 +1,52 @@ +using System.Threading.Tasks; +using FluentAssertions; +using RCommon.MassTransit.StateMachines; +using RCommon.StateMachines; +using Xunit; + +namespace RCommon.MassTransit.StateMachines.Tests; + +public class MassTransitStateMachineConfiguratorTests +{ + [Fact] + public void ForState_ReturnsIStateConfigurator() + { + var configurator = new MassTransitStateMachineConfigurator(); + var stateConfig = configurator.ForState(PaymentState.Pending); + stateConfig.Should().BeAssignableTo>(); + } + + [Fact] + public void Build_ReturnsIStateMachine() + { + var configurator = new MassTransitStateMachineConfigurator(); + configurator.ForState(PaymentState.Pending) + .Permit(PaymentTrigger.Authorize, PaymentState.Authorized); + var machine = configurator.Build(PaymentState.Pending); + machine.Should().BeAssignableTo>(); + } + + [Fact] + public void ForState_CalledTwice_ReturnsSameConfig() + { + var configurator = new MassTransitStateMachineConfigurator(); + var config1 = configurator.ForState(PaymentState.Pending); + var config2 = configurator.ForState(PaymentState.Pending); + config1.Should().BeSameAs(config2); + } + + [Fact] + public async Task MachinesFromSameConfigurator_AreIndependent() + { + var configurator = new MassTransitStateMachineConfigurator(); + configurator.ForState(PaymentState.Pending) + .Permit(PaymentTrigger.Authorize, PaymentState.Authorized); + var machine1 = configurator.Build(PaymentState.Pending); + var machine2 = configurator.Build(PaymentState.Pending); + + await machine1.FireAsync(PaymentTrigger.Authorize); + + machine1.CurrentState.Should().Be(PaymentState.Authorized); + machine2.CurrentState.Should().Be(PaymentState.Pending); + } +} diff --git a/Tests/RCommon.MassTransit.StateMachines.Tests/MassTransitStateMachineDITests.cs b/Tests/RCommon.MassTransit.StateMachines.Tests/MassTransitStateMachineDITests.cs new file mode 100644 index 00000000..c69fa4af --- /dev/null +++ b/Tests/RCommon.MassTransit.StateMachines.Tests/MassTransitStateMachineDITests.cs @@ -0,0 +1,44 @@ +using System; +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; +using RCommon.MassTransit.StateMachines; +using RCommon.StateMachines; +using Xunit; + +namespace RCommon.MassTransit.StateMachines.Tests; + +public class MassTransitStateMachineDITests +{ + private class TestRCommonBuilder : IRCommonBuilder + { + public IServiceCollection Services { get; } = new ServiceCollection(); + public IServiceCollection Configure() => Services; + public IRCommonBuilder WithDateTimeSystem(Action actions) => this; + public IRCommonBuilder WithSequentialGuidGenerator(Action actions) => this; + public IRCommonBuilder WithSimpleGuidGenerator() => this; + public IRCommonBuilder WithCommonFactory() + where TService : class + where TImplementation : class, TService => this; + } + + [Fact] + public void WithMassTransitStateMachine_RegistersOpenGeneric() + { + var builder = new TestRCommonBuilder(); + builder.WithMassTransitStateMachine(); + var provider = builder.Services.BuildServiceProvider(); + var configurator = provider.GetRequiredService>(); + configurator.Should().BeOfType>(); + } + + [Fact] + public void EachResolution_ReturnsNewInstance() + { + var builder = new TestRCommonBuilder(); + builder.WithMassTransitStateMachine(); + var provider = builder.Services.BuildServiceProvider(); + var instance1 = provider.GetRequiredService>(); + var instance2 = provider.GetRequiredService>(); + instance1.Should().NotBeSameAs(instance2); + } +} diff --git a/Tests/RCommon.MassTransit.StateMachines.Tests/MassTransitStateMachineTests.cs b/Tests/RCommon.MassTransit.StateMachines.Tests/MassTransitStateMachineTests.cs new file mode 100644 index 00000000..5004fdd2 --- /dev/null +++ b/Tests/RCommon.MassTransit.StateMachines.Tests/MassTransitStateMachineTests.cs @@ -0,0 +1,188 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using RCommon.MassTransit.StateMachines; +using RCommon.StateMachines; +using Xunit; + +namespace RCommon.MassTransit.StateMachines.Tests; + +public enum PaymentState { Pending, Authorized, Captured, Refunded, Failed } +public enum PaymentTrigger { Authorize, Capture, Refund, Fail } + +public class MassTransitStateMachineTests +{ + private static MassTransitStateMachineConfigurator CreateConfigurator() + { + var configurator = new MassTransitStateMachineConfigurator(); + configurator.ForState(PaymentState.Pending) + .Permit(PaymentTrigger.Authorize, PaymentState.Authorized) + .Permit(PaymentTrigger.Fail, PaymentState.Failed); + configurator.ForState(PaymentState.Authorized) + .Permit(PaymentTrigger.Capture, PaymentState.Captured); + configurator.ForState(PaymentState.Captured) + .Permit(PaymentTrigger.Refund, PaymentState.Refunded); + return configurator; + } + + [Fact] + public void Build_ReturnsCorrectInitialState() + { + var configurator = CreateConfigurator(); + var machine = configurator.Build(PaymentState.Pending); + machine.CurrentState.Should().Be(PaymentState.Pending); + } + + [Fact] + public async Task FireAsync_TransitionsCorrectly() + { + var configurator = CreateConfigurator(); + var machine = configurator.Build(PaymentState.Pending); + await machine.FireAsync(PaymentTrigger.Authorize); + machine.CurrentState.Should().Be(PaymentState.Authorized); + } + + [Fact] + public void CanFire_ReturnsTrue_ForPermittedTrigger() + { + var configurator = CreateConfigurator(); + var machine = configurator.Build(PaymentState.Pending); + machine.CanFire(PaymentTrigger.Authorize).Should().BeTrue(); + } + + [Fact] + public void CanFire_ReturnsFalse_ForUnpermittedTrigger() + { + var configurator = CreateConfigurator(); + var machine = configurator.Build(PaymentState.Pending); + machine.CanFire(PaymentTrigger.Capture).Should().BeFalse(); + } + + [Fact] + public async Task FireAsync_ThrowsForUnpermittedTrigger() + { + var configurator = CreateConfigurator(); + var machine = configurator.Build(PaymentState.Pending); + Func act = () => machine.FireAsync(PaymentTrigger.Capture); + await act.Should().ThrowAsync(); + } + + [Fact] + public void PermittedTriggers_ReturnsCorrectSet() + { + var configurator = CreateConfigurator(); + var machine = configurator.Build(PaymentState.Pending); + var triggers = machine.PermittedTriggers.ToList(); + triggers.Should().Contain(PaymentTrigger.Authorize); + triggers.Should().Contain(PaymentTrigger.Fail); + triggers.Should().HaveCount(2); + } + + [Fact] + public async Task PermitIf_AllowsTransitionWhenGuardTrue() + { + var configurator = new MassTransitStateMachineConfigurator(); + configurator.ForState(PaymentState.Pending) + .PermitIf(PaymentTrigger.Authorize, PaymentState.Authorized, () => true); + var machine = configurator.Build(PaymentState.Pending); + await machine.FireAsync(PaymentTrigger.Authorize); + machine.CurrentState.Should().Be(PaymentState.Authorized); + } + + [Fact] + public async Task PermitIf_BlocksTransitionWhenGuardFalse() + { + var configurator = new MassTransitStateMachineConfigurator(); + configurator.ForState(PaymentState.Pending) + .PermitIf(PaymentTrigger.Authorize, PaymentState.Authorized, () => false); + var machine = configurator.Build(PaymentState.Pending); + Func act = () => machine.FireAsync(PaymentTrigger.Authorize); + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task OnEntry_ExecutesDuringTransition_WithCancellationToken() + { + CancellationToken capturedToken = default; + var cts = new CancellationTokenSource(); + var configurator = new MassTransitStateMachineConfigurator(); + configurator.ForState(PaymentState.Pending) + .Permit(PaymentTrigger.Authorize, PaymentState.Authorized); + configurator.ForState(PaymentState.Authorized) + .OnEntry(ct => + { + capturedToken = ct; + return Task.CompletedTask; + }); + var machine = configurator.Build(PaymentState.Pending); + await machine.FireAsync(PaymentTrigger.Authorize, cts.Token); + capturedToken.Should().Be(cts.Token); + } + + [Fact] + public async Task OnExit_ExecutesDuringTransition_WithCancellationToken() + { + CancellationToken capturedToken = default; + var cts = new CancellationTokenSource(); + var configurator = new MassTransitStateMachineConfigurator(); + configurator.ForState(PaymentState.Pending) + .Permit(PaymentTrigger.Authorize, PaymentState.Authorized) + .OnExit(ct => + { + capturedToken = ct; + return Task.CompletedTask; + }); + var machine = configurator.Build(PaymentState.Pending); + await machine.FireAsync(PaymentTrigger.Authorize, cts.Token); + capturedToken.Should().Be(cts.Token); + } + + [Fact] + public void Build_CalledMultipleTimes_ProducesIndependentMachines() + { + var configurator = CreateConfigurator(); + var machine1 = configurator.Build(PaymentState.Pending); + var machine2 = configurator.Build(PaymentState.Authorized); + machine1.CurrentState.Should().Be(PaymentState.Pending); + machine2.CurrentState.Should().Be(PaymentState.Authorized); + } + + [Fact] + public async Task MultipleTransitions_InSequence() + { + var configurator = CreateConfigurator(); + var machine = configurator.Build(PaymentState.Pending); + + await machine.FireAsync(PaymentTrigger.Authorize); + machine.CurrentState.Should().Be(PaymentState.Authorized); + + await machine.FireAsync(PaymentTrigger.Capture); + machine.CurrentState.Should().Be(PaymentState.Captured); + + await machine.FireAsync(PaymentTrigger.Refund); + machine.CurrentState.Should().Be(PaymentState.Refunded); + } + + [Fact] + public async Task CancellationToken_ThrowsWhenCancelled() + { + var configurator = CreateConfigurator(); + var machine = configurator.Build(PaymentState.Pending); + var cts = new CancellationTokenSource(); + cts.Cancel(); + Func act = () => machine.FireAsync(PaymentTrigger.Authorize, cts.Token); + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task FireAsyncWithData_DelegatesToFireAsync() + { + var configurator = CreateConfigurator(); + var machine = configurator.Build(PaymentState.Pending); + await machine.FireAsync(PaymentTrigger.Authorize, new { Amount = 100.0m }); + machine.CurrentState.Should().Be(PaymentState.Authorized); + } +} diff --git a/Tests/RCommon.MassTransit.StateMachines.Tests/RCommon.MassTransit.StateMachines.Tests.csproj b/Tests/RCommon.MassTransit.StateMachines.Tests/RCommon.MassTransit.StateMachines.Tests.csproj new file mode 100644 index 00000000..a764502a --- /dev/null +++ b/Tests/RCommon.MassTransit.StateMachines.Tests/RCommon.MassTransit.StateMachines.Tests.csproj @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/Tests/RCommon.MassTransit.Tests/RCommon.MassTransit.Tests.csproj b/Tests/RCommon.MassTransit.Tests/RCommon.MassTransit.Tests.csproj index 61822ee0..bc567d83 100644 --- a/Tests/RCommon.MassTransit.Tests/RCommon.MassTransit.Tests.csproj +++ b/Tests/RCommon.MassTransit.Tests/RCommon.MassTransit.Tests.csproj @@ -1,7 +1,7 @@ - + diff --git a/Tests/RCommon.Mediatr.Tests/Behaviors/UnitOfWorkBehaviorTests.cs b/Tests/RCommon.Mediatr.Tests/Behaviors/UnitOfWorkBehaviorTests.cs index b04d418d..0753595f 100644 --- a/Tests/RCommon.Mediatr.Tests/Behaviors/UnitOfWorkBehaviorTests.cs +++ b/Tests/RCommon.Mediatr.Tests/Behaviors/UnitOfWorkBehaviorTests.cs @@ -128,7 +128,7 @@ public async Task UnitOfWorkRequestBehavior_Handle_CommitsOnSuccess() await behavior.Handle(request, next, CancellationToken.None); // Assert - mockUnitOfWork.Verify(x => x.Commit(), Times.Once); + mockUnitOfWork.Verify(x => x.CommitAsync(It.IsAny()), Times.Once); } [Fact] @@ -183,7 +183,7 @@ public async Task UnitOfWorkRequestBehavior_Handle_DoesNotCommitOnException() // Assert await act.Should().ThrowAsync(); - mockUnitOfWork.Verify(x => x.Commit(), Times.Never); + mockUnitOfWork.Verify(x => x.CommitAsync(It.IsAny()), Times.Never); } [Fact] @@ -349,7 +349,7 @@ public async Task UnitOfWorkRequestWithResponseBehavior_Handle_CommitsOnSuccess( await behavior.Handle(request, next, CancellationToken.None); // Assert - mockUnitOfWork.Verify(x => x.Commit(), Times.Once); + mockUnitOfWork.Verify(x => x.CommitAsync(It.IsAny()), Times.Once); } [Fact] @@ -406,7 +406,7 @@ public async Task UnitOfWorkRequestWithResponseBehavior_Handle_DoesNotCommitOnEx // Assert await act.Should().ThrowAsync(); - mockUnitOfWork.Verify(x => x.Commit(), Times.Never); + mockUnitOfWork.Verify(x => x.CommitAsync(It.IsAny()), Times.Never); } [Fact] diff --git a/Tests/RCommon.Models.Tests/PagedResultTests.cs b/Tests/RCommon.Models.Tests/PagedResultTests.cs new file mode 100644 index 00000000..b33ea94c --- /dev/null +++ b/Tests/RCommon.Models.Tests/PagedResultTests.cs @@ -0,0 +1,86 @@ +using System; +using System.Collections.Generic; +using FluentAssertions; +using RCommon.Models; +using Xunit; + +namespace RCommon.Models.Tests; + +public class PagedResultTests +{ + [Fact] + public void Constructor_Sets_Properties() + { + var items = new List { "a", "b", "c" }; + var result = new PagedResult(items, 10, 1, 5); + + result.Items.Should().BeEquivalentTo(items); + result.TotalCount.Should().Be(10); + result.PageNumber.Should().Be(1); + result.PageSize.Should().Be(5); + } + + [Fact] + public void TotalPages_Rounds_Up() + { + var result = new PagedResult(new List(), 11, 1, 5); + result.TotalPages.Should().Be(3); // ceil(11/5) = 3 + } + + [Fact] + public void TotalPages_Exact_Division() + { + var result = new PagedResult(new List(), 10, 1, 5); + result.TotalPages.Should().Be(2); + } + + [Fact] + public void HasNextPage_True_When_Not_Last_Page() + { + var result = new PagedResult(new List(), 10, 1, 5); + result.HasNextPage.Should().BeTrue(); + } + + [Fact] + public void HasNextPage_False_On_Last_Page() + { + var result = new PagedResult(new List(), 10, 2, 5); + result.HasNextPage.Should().BeFalse(); + } + + [Fact] + public void HasPreviousPage_False_On_First_Page() + { + var result = new PagedResult(new List(), 10, 1, 5); + result.HasPreviousPage.Should().BeFalse(); + } + + [Fact] + public void HasPreviousPage_True_On_Page_2() + { + var result = new PagedResult(new List(), 10, 2, 5); + result.HasPreviousPage.Should().BeTrue(); + } + + [Fact] + public void Constructor_Throws_When_PageSize_Zero() + { + var act = () => new PagedResult(new List(), 10, 1, 0); + act.Should().Throw(); + } + + [Fact] + public void Constructor_Throws_When_PageSize_Negative() + { + var act = () => new PagedResult(new List(), 10, 1, -1); + act.Should().Throw(); + } + + [Fact] + public void Empty_Result_Has_Zero_TotalPages() + { + var result = new PagedResult(new List(), 0, 1, 10); + result.TotalPages.Should().Be(0); + result.HasNextPage.Should().BeFalse(); + } +} diff --git a/Tests/RCommon.Persistence.Tests/ExponentialBackoffStrategyTests.cs b/Tests/RCommon.Persistence.Tests/ExponentialBackoffStrategyTests.cs new file mode 100644 index 00000000..7c1ee8e1 --- /dev/null +++ b/Tests/RCommon.Persistence.Tests/ExponentialBackoffStrategyTests.cs @@ -0,0 +1,78 @@ +using FluentAssertions; +using RCommon.Persistence.Outbox; +using Xunit; + +namespace RCommon.Persistence.Tests; + +public class ExponentialBackoffStrategyTests +{ + [Fact] + public void ComputeDelay_RetryCount0_ReturnsBaseDelay() + { + var strategy = new ExponentialBackoffStrategy( + TimeSpan.FromSeconds(5), TimeSpan.FromMinutes(30)); + strategy.ComputeDelay(0).Should().Be(TimeSpan.FromSeconds(5)); + } + + [Fact] + public void ComputeDelay_RetryCount1_ReturnsBaseTimesMultiplier() + { + var strategy = new ExponentialBackoffStrategy( + TimeSpan.FromSeconds(5), TimeSpan.FromMinutes(30)); + strategy.ComputeDelay(1).Should().Be(TimeSpan.FromSeconds(10)); + } + + [Fact] + public void ComputeDelay_RetryCount3_ReturnsExponentialDelay() + { + var strategy = new ExponentialBackoffStrategy( + TimeSpan.FromSeconds(5), TimeSpan.FromMinutes(30)); + // 5 * 2^3 = 40 seconds + strategy.ComputeDelay(3).Should().Be(TimeSpan.FromSeconds(40)); + } + + [Fact] + public void ComputeDelay_ExceedsMax_CapsAtMaxDelay() + { + var strategy = new ExponentialBackoffStrategy( + TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(60)); + // 5 * 2^10 = 5120 seconds, capped at 60 + strategy.ComputeDelay(10).Should().Be(TimeSpan.FromSeconds(60)); + } + + [Fact] + public void ComputeDelay_CustomMultiplier_UsesMultiplier() + { + var strategy = new ExponentialBackoffStrategy( + TimeSpan.FromSeconds(5), TimeSpan.FromMinutes(30), multiplier: 3.0); + // 5 * 3^2 = 45 seconds + strategy.ComputeDelay(2).Should().Be(TimeSpan.FromSeconds(45)); + } + + [Fact] + public void Constructor_MaxDelaySmallerThanBaseDelay_ThrowsArgumentOutOfRangeException() + { + var act = () => new ExponentialBackoffStrategy( + TimeSpan.FromSeconds(60), TimeSpan.FromSeconds(10)); + act.Should().Throw() + .WithParameterName("maxDelay"); + } + + [Fact] + public void Constructor_ZeroBaseDelay_ThrowsArgumentOutOfRangeException() + { + var act = () => new ExponentialBackoffStrategy( + TimeSpan.Zero, TimeSpan.FromMinutes(30)); + act.Should().Throw() + .WithParameterName("baseDelay"); + } + + [Fact] + public void Constructor_MultiplierLessThanOrEqualOne_ThrowsArgumentOutOfRangeException() + { + var act = () => new ExponentialBackoffStrategy( + TimeSpan.FromSeconds(5), TimeSpan.FromMinutes(30), multiplier: 1.0); + act.Should().Throw() + .WithParameterName("multiplier"); + } +} diff --git a/Tests/RCommon.Persistence.Tests/IAggregateRepositoryTests.cs b/Tests/RCommon.Persistence.Tests/IAggregateRepositoryTests.cs new file mode 100644 index 00000000..98b7e6ef --- /dev/null +++ b/Tests/RCommon.Persistence.Tests/IAggregateRepositoryTests.cs @@ -0,0 +1,54 @@ +using System; +using System.Reflection; +using FluentAssertions; +using RCommon.Entities; +using RCommon.Persistence.Crud; +using Xunit; + +namespace RCommon.Persistence.Tests; + +public class IAggregateRepositoryTests +{ + [Fact] + public void Interface_Has_IAggregateRoot_Constraint_On_TAggregate() + { + var type = typeof(IAggregateRepository<,>); + var genericArgs = type.GetGenericArguments(); + var tAggregate = genericArgs[0]; + var constraints = tAggregate.GetGenericParameterConstraints(); + + constraints.Should().Contain(t => t.IsGenericType + && t.GetGenericTypeDefinition() == typeof(IAggregateRoot<>), + "TAggregate must be constrained to IAggregateRoot"); + } + + [Fact] + public void Interface_Has_IEquatable_Constraint_On_TKey() + { + var type = typeof(IAggregateRepository<,>); + var genericArgs = type.GetGenericArguments(); + var tKey = genericArgs[1]; + var constraints = tKey.GetGenericParameterConstraints(); + + constraints.Should().Contain(t => t.IsGenericType + && t.GetGenericTypeDefinition() == typeof(IEquatable<>), + "TKey must be constrained to IEquatable"); + } + + [Fact] + public void Interface_Inherits_INamedDataSource() + { + var type = typeof(IAggregateRepository<,>); + type.GetInterfaces().Should().Contain(typeof(INamedDataSource)); + } + + [Fact] + public void Interface_Does_Not_Inherit_ILinqRepository() + { + var type = typeof(IAggregateRepository<,>); + var interfaces = type.GetInterfaces(); + interfaces.Should().NotContain(i => i.Name.Contains("ILinqRepository")); + interfaces.Should().NotContain(i => i.Name.Contains("IGraphRepository")); + interfaces.Should().NotContain(i => i.Name.Contains("IReadOnlyRepository")); + } +} diff --git a/Tests/RCommon.Persistence.Tests/IReadModelRepositoryTests.cs b/Tests/RCommon.Persistence.Tests/IReadModelRepositoryTests.cs new file mode 100644 index 00000000..64045fed --- /dev/null +++ b/Tests/RCommon.Persistence.Tests/IReadModelRepositoryTests.cs @@ -0,0 +1,38 @@ +using System; +using FluentAssertions; +using RCommon.Persistence; +using RCommon.Persistence.Crud; +using Xunit; + +namespace RCommon.Persistence.Tests; + +public class IReadModelRepositoryTests +{ + [Fact] + public void Interface_Has_IReadModel_Constraint() + { + var type = typeof(IReadModelRepository<>); + var tReadModel = type.GetGenericArguments()[0]; + var constraints = tReadModel.GetGenericParameterConstraints(); + + constraints.Should().Contain(typeof(IReadModel)); + } + + [Fact] + public void Interface_Inherits_INamedDataSource() + { + var type = typeof(IReadModelRepository<>); + type.GetInterfaces().Should().Contain(typeof(INamedDataSource)); + } + + [Fact] + public void Interface_Has_Class_Constraint() + { + var type = typeof(IReadModelRepository<>); + var tReadModel = type.GetGenericArguments()[0]; + var attrs = tReadModel.GenericParameterAttributes; + + attrs.HasFlag(System.Reflection.GenericParameterAttributes.ReferenceTypeConstraint) + .Should().BeTrue(); + } +} diff --git a/Tests/RCommon.Persistence.Tests/InMemorySagaStoreTests.cs b/Tests/RCommon.Persistence.Tests/InMemorySagaStoreTests.cs new file mode 100644 index 00000000..797a2491 --- /dev/null +++ b/Tests/RCommon.Persistence.Tests/InMemorySagaStoreTests.cs @@ -0,0 +1,76 @@ +using System; +using System.Threading.Tasks; +using FluentAssertions; +using RCommon.Persistence.Sagas; +using Xunit; + +namespace RCommon.Persistence.Tests; + +// Use a unique name since TestSagaData already exists in SagaOrchestratorTests.cs +public class InMemoryTestState : SagaState +{ + public string? Data { get; set; } +} + +public class InMemorySagaStoreTests +{ + [Fact] + public async Task SaveAsync_And_GetByIdAsync_RoundTrips() + { + var store = new InMemorySagaStore(); + var state = new InMemoryTestState { Id = Guid.NewGuid(), CorrelationId = "c1", Data = "test" }; + + await store.SaveAsync(state); + + var loaded = await store.GetByIdAsync(state.Id); + loaded.Should().BeSameAs(state); + } + + [Fact] + public async Task FindByCorrelationIdAsync_Returns_Matching_State() + { + var store = new InMemorySagaStore(); + var state = new InMemoryTestState { Id = Guid.NewGuid(), CorrelationId = "order-456" }; + + await store.SaveAsync(state); + + var found = await store.FindByCorrelationIdAsync("order-456"); + found.Should().BeSameAs(state); + } + + [Fact] + public async Task FindByCorrelationIdAsync_Returns_Null_When_Not_Found() + { + var store = new InMemorySagaStore(); + + var found = await store.FindByCorrelationIdAsync("nonexistent"); + found.Should().BeNull(); + } + + [Fact] + public async Task DeleteAsync_Removes_State() + { + var store = new InMemorySagaStore(); + var state = new InMemoryTestState { Id = Guid.NewGuid(), CorrelationId = "c1" }; + + await store.SaveAsync(state); + await store.DeleteAsync(state); + + var loaded = await store.GetByIdAsync(state.Id); + loaded.Should().BeNull(); + } + + [Fact] + public async Task SaveAsync_Updates_Existing_State() + { + var store = new InMemorySagaStore(); + var state = new InMemoryTestState { Id = Guid.NewGuid(), CorrelationId = "c1", Data = "v1" }; + + await store.SaveAsync(state); + state.Data = "v2"; + await store.SaveAsync(state); + + var loaded = await store.GetByIdAsync(state.Id); + loaded!.Data.Should().Be("v2"); + } +} diff --git a/Tests/RCommon.Persistence.Tests/JsonOutboxSerializerTests.cs b/Tests/RCommon.Persistence.Tests/JsonOutboxSerializerTests.cs new file mode 100644 index 00000000..fc5ce57d --- /dev/null +++ b/Tests/RCommon.Persistence.Tests/JsonOutboxSerializerTests.cs @@ -0,0 +1,61 @@ +using FluentAssertions; +using RCommon.Models.Events; +using RCommon.Persistence.Outbox; +using System.Text.Json; +using Xunit; + +namespace RCommon.Persistence.Tests; + +public record SerializerTestEvent(string Name, int Value) : ISerializableEvent; + +public class JsonOutboxSerializerTests +{ + private readonly JsonOutboxSerializer _serializer = new(); + + [Fact] + public void Serialize_ReturnsValidJson() + { + var @event = new SerializerTestEvent("OrderCreated", 42); + var json = _serializer.Serialize(@event); + var doc = JsonDocument.Parse(json); + doc.RootElement.GetProperty("Name").GetString().Should().Be("OrderCreated"); + doc.RootElement.GetProperty("Value").GetInt32().Should().Be(42); + } + + [Fact] + public void GetEventTypeName_ReturnsShortAssemblyQualifiedName() + { + var @event = new SerializerTestEvent("Test", 1); + var typeName = _serializer.GetEventTypeName(@event); + typeName.Should().Contain("SerializerTestEvent"); + typeName.Should().Contain(","); + } + + [Fact] + public void Deserialize_RoundTrips() + { + var original = new SerializerTestEvent("OrderCreated", 42); + var json = _serializer.Serialize(original); + var typeName = _serializer.GetEventTypeName(original); + var deserialized = _serializer.Deserialize(typeName, json); + deserialized.Should().BeOfType(); + var typed = (SerializerTestEvent)deserialized; + typed.Name.Should().Be("OrderCreated"); + typed.Value.Should().Be(42); + } + + [Fact] + public void Deserialize_ThrowsForUnknownType() + { + var act = () => _serializer.Deserialize("NonExistent.Type, FakeAssembly", "{}"); + act.Should().Throw(); + } + + [Fact] + public void Deserialize_ThrowsForNonSerializableEventType() + { + var typeName = typeof(string).AssemblyQualifiedName!; + var act = () => _serializer.Deserialize(typeName, "\"hello\""); + act.Should().Throw(); + } +} diff --git a/Tests/RCommon.Persistence.Tests/OutboxConcurrencyTests.cs b/Tests/RCommon.Persistence.Tests/OutboxConcurrencyTests.cs new file mode 100644 index 00000000..b682dd65 --- /dev/null +++ b/Tests/RCommon.Persistence.Tests/OutboxConcurrencyTests.cs @@ -0,0 +1,55 @@ +using FluentAssertions; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using Moq; +using RCommon.EventHandling.Producers; +using RCommon.Models.Events; +using RCommon.Persistence.Outbox; +using RCommon.Security.Claims; +using Xunit; + +namespace RCommon.Persistence.Tests; + +public record ConcurrencyTestEvent(string Data) : ISerializableEvent; + +public class OutboxConcurrencyTests +{ + [Fact] + public async Task EmptyBuffer_PersistBufferedEventsAsync_NoStoreCalls() + { + var storeMock = new Mock(); + var guidGenMock = new Mock(); + var tenantMock = new Mock(); + var serviceProviderMock = new Mock(); + + var router = new OutboxEventRouter( + storeMock.Object, new JsonOutboxSerializer(), + guidGenMock.Object, tenantMock.Object, + serviceProviderMock.Object, new EventSubscriptionManager(), + NullLogger.Instance, + Options.Create(new OutboxOptions())); + + await router.PersistBufferedEventsAsync(); + storeMock.Verify(s => s.SaveAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task RouteEventsAsync_NoRetainedEvents_CompletesQuickly() + { + var storeMock = new Mock(); + var guidGenMock = new Mock(); + var tenantMock = new Mock(); + var serviceProviderMock = new Mock(); + + var router = new OutboxEventRouter( + storeMock.Object, new JsonOutboxSerializer(), + guidGenMock.Object, tenantMock.Object, + serviceProviderMock.Object, new EventSubscriptionManager(), + NullLogger.Instance, + Options.Create(new OutboxOptions())); + + // No events buffered or persisted, so retained list is empty + await router.RouteEventsAsync(); + storeMock.Verify(s => s.MarkProcessedAsync(It.IsAny(), It.IsAny()), Times.Never); + } +} diff --git a/Tests/RCommon.Persistence.Tests/OutboxEntityEventTrackerTests.cs b/Tests/RCommon.Persistence.Tests/OutboxEntityEventTrackerTests.cs new file mode 100644 index 00000000..4f58cfa3 --- /dev/null +++ b/Tests/RCommon.Persistence.Tests/OutboxEntityEventTrackerTests.cs @@ -0,0 +1,76 @@ +using FluentAssertions; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using Moq; +using RCommon.Entities; +using RCommon.EventHandling.Producers; +using RCommon.Models.Events; +using RCommon.Persistence.Outbox; +using RCommon.Security.Claims; +using Xunit; + +namespace RCommon.Persistence.Tests; + +public record TrackerTestEvent(string Data) : ISerializableEvent; + +public class OutboxEntityEventTrackerTests +{ + private readonly Mock _storeMock = new(); + private readonly Mock _guidGenMock = new(); + private readonly OutboxEventRouter _outboxRouter; + private readonly InMemoryEntityEventTracker _innerTracker; + + public OutboxEntityEventTrackerTests() + { + _guidGenMock.Setup(g => g.Create()).Returns(Guid.NewGuid()); + var tenantMock = new Mock(); + var serviceProviderMock = new Mock(); + + _outboxRouter = new OutboxEventRouter( + _storeMock.Object, + new JsonOutboxSerializer(), + _guidGenMock.Object, + tenantMock.Object, + serviceProviderMock.Object, + new EventSubscriptionManager(), + NullLogger.Instance, + Options.Create(new OutboxOptions())); + + _innerTracker = new InMemoryEntityEventTracker(_outboxRouter); + } + + [Fact] + public void AddEntity_DelegatesToInnerTracker() + { + var tracker = new OutboxEntityEventTracker(_innerTracker, _outboxRouter); + var entityMock = new Mock(); + entityMock.Setup(e => e.AllowEventTracking).Returns(true); + + tracker.AddEntity(entityMock.Object); + + tracker.TrackedEntities.Should().Contain(entityMock.Object); + } + + [Fact] + public async Task PersistEventsAsync_WithNoEntities_CompletesWithoutStoreCalls() + { + var tracker = new OutboxEntityEventTracker(_innerTracker, _outboxRouter); + + await tracker.PersistEventsAsync(); + + _storeMock.Verify(s => s.SaveAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task EmitTransactionalEventsAsync_ReturnsTrue() + { + // The router no longer reads from the store in RouteEventsAsync — it dispatches from + // the in-memory retained list. Since no events were buffered, the retained list is empty + // and RouteEventsAsync returns immediately without any store calls. + var tracker = new OutboxEntityEventTracker(_innerTracker, _outboxRouter); + + var result = await tracker.EmitTransactionalEventsAsync(); + + result.Should().BeTrue(); + } +} diff --git a/Tests/RCommon.Persistence.Tests/OutboxEventRouterTests.cs b/Tests/RCommon.Persistence.Tests/OutboxEventRouterTests.cs new file mode 100644 index 00000000..932ecf58 --- /dev/null +++ b/Tests/RCommon.Persistence.Tests/OutboxEventRouterTests.cs @@ -0,0 +1,186 @@ +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using Moq; +using RCommon.EventHandling.Producers; +using RCommon.Models.Events; +using RCommon.Persistence.Outbox; +using RCommon.Security.Claims; +using Xunit; + +namespace RCommon.Persistence.Tests; + +public record RouterTestEvent(string Data) : ISerializableEvent; + +public class OutboxEventRouterTests +{ + private readonly Mock _storeMock = new(); + private readonly Mock _guidGenMock = new(); + private readonly Mock _tenantMock = new(); + private readonly IOutboxSerializer _serializer = new JsonOutboxSerializer(); + private readonly Mock _serviceProviderMock = new(); + private readonly EventSubscriptionManager _subscriptionManager = new(); + + private OutboxEventRouter CreateRouter() + { + _guidGenMock.Setup(g => g.Create()).Returns(Guid.NewGuid()); + // _tenantMock is not setup here; Moq returns null by default for reference types. + // Individual tests that need a specific tenant can set it up before calling CreateRouter(). + return new OutboxEventRouter( + _storeMock.Object, + _serializer, + _guidGenMock.Object, + _tenantMock.Object, + _serviceProviderMock.Object, + _subscriptionManager, + NullLogger.Instance, + Options.Create(new OutboxOptions())); + } + + [Fact] + public void AddTransactionalEvent_BuffersWithoutCallingStore() + { + var router = CreateRouter(); + router.AddTransactionalEvent(new RouterTestEvent("test")); + _storeMock.Verify(s => s.SaveAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task PersistBufferedEventsAsync_WritesBufferedEventsToStore() + { + var router = CreateRouter(); + router.AddTransactionalEvent(new RouterTestEvent("event1")); + router.AddTransactionalEvent(new RouterTestEvent("event2")); + + await router.PersistBufferedEventsAsync(); + + _storeMock.Verify( + s => s.SaveAsync(It.IsAny(), It.IsAny()), + Times.Exactly(2)); + } + + [Fact] + public async Task PersistBufferedEventsAsync_ClearsBufferAfterPersistence() + { + var router = CreateRouter(); + router.AddTransactionalEvent(new RouterTestEvent("event1")); + await router.PersistBufferedEventsAsync(); + + // Second call should have nothing to persist + _storeMock.Invocations.Clear(); + await router.PersistBufferedEventsAsync(); + + _storeMock.Verify( + s => s.SaveAsync(It.IsAny(), It.IsAny()), + Times.Never); + } + + [Fact] + public async Task PersistBufferedEventsAsync_SetsCorrectMessageFields() + { + IOutboxMessage? captured = null; + _storeMock.Setup(s => s.SaveAsync(It.IsAny(), It.IsAny())) + .Callback((msg, _) => captured = msg); + _tenantMock.Setup(t => t.GetTenantId()).Returns("tenant-1"); + + var router = CreateRouter(); + router.AddTransactionalEvent(new RouterTestEvent("data")); + await router.PersistBufferedEventsAsync(); + + captured.Should().NotBeNull(); + captured!.EventType.Should().Contain("RouterTestEvent"); + captured.EventPayload.Should().Contain("data"); + captured.TenantId.Should().Be("tenant-1"); + captured.RetryCount.Should().Be(0); + captured.ProcessedAtUtc.Should().BeNull(); + captured.DeadLetteredAtUtc.Should().BeNull(); + } + + [Fact] + public async Task RouteEventsAsync_DispatchesRetainedEvents() + { + var producerMock = new Mock(); + _serviceProviderMock.Setup(sp => sp.GetService(typeof(IEnumerable))) + .Returns(new[] { producerMock.Object }); + + var router = CreateRouter(); + router.AddTransactionalEvent(new RouterTestEvent("x")); + await router.PersistBufferedEventsAsync(); + + await router.RouteEventsAsync(); + + producerMock.Verify( + p => p.ProduceEventAsync(It.IsAny(), It.IsAny()), + Times.Once); + _storeMock.Verify( + s => s.MarkProcessedAsync(It.IsAny(), It.IsAny()), + Times.Once); + } + + [Fact] + public async Task RouteEventsAsync_LogsWarningOnException_DoesNotMarkFailed() + { + var producerMock = new Mock(); + producerMock.Setup(p => p.ProduceEventAsync(It.IsAny(), It.IsAny())) + .ThrowsAsync(new Exception("broker down")); + _serviceProviderMock.Setup(sp => sp.GetService(typeof(IEnumerable))) + .Returns(new[] { producerMock.Object }); + + var router = CreateRouter(); + router.AddTransactionalEvent(new RouterTestEvent("x")); + await router.PersistBufferedEventsAsync(); + + await router.RouteEventsAsync(); + + _storeMock.Verify( + s => s.MarkFailedAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never); + _storeMock.Verify( + s => s.MarkProcessedAsync(It.IsAny(), It.IsAny()), + Times.Never); + } + + [Fact] + public async Task RouteEventsAsync_ClearsRetainedEventsAfterDispatch() + { + var producerMock = new Mock(); + _serviceProviderMock.Setup(sp => sp.GetService(typeof(IEnumerable))) + .Returns(new[] { producerMock.Object }); + + var router = CreateRouter(); + router.AddTransactionalEvent(new RouterTestEvent("x")); + await router.PersistBufferedEventsAsync(); + + await router.RouteEventsAsync(); + + // Second call should be a no-op: no retained events left + _storeMock.Invocations.Clear(); + producerMock.Invocations.Clear(); + await router.RouteEventsAsync(); + + producerMock.Verify( + p => p.ProduceEventAsync(It.IsAny(), It.IsAny()), + Times.Never); + _storeMock.Verify( + s => s.MarkProcessedAsync(It.IsAny(), It.IsAny()), + Times.Never); + } + + [Fact] + public async Task RouteEventsAsync_NoRetainedEvents_ReturnsImmediately() + { + var router = CreateRouter(); + + // No PersistBufferedEventsAsync called - no retained events + await router.RouteEventsAsync(); + + _storeMock.Verify( + s => s.MarkProcessedAsync(It.IsAny(), It.IsAny()), + Times.Never); + _storeMock.Verify( + s => s.ClaimAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never); + } +} diff --git a/Tests/RCommon.Persistence.Tests/OutboxProcessingServiceTests.cs b/Tests/RCommon.Persistence.Tests/OutboxProcessingServiceTests.cs new file mode 100644 index 00000000..a927571a --- /dev/null +++ b/Tests/RCommon.Persistence.Tests/OutboxProcessingServiceTests.cs @@ -0,0 +1,171 @@ +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using Moq; +using RCommon.EventHandling.Producers; +using RCommon.Models.Events; +using RCommon.Persistence.Inbox; +using RCommon.Persistence.Outbox; +using Xunit; + +namespace RCommon.Persistence.Tests; + +public record PollerTestEvent(string Data) : ISerializableEvent; + +public class OutboxProcessingServiceTests +{ + private readonly Mock _storeMock = new(); + private readonly Mock _producerMock = new(); + private readonly Mock _backoffMock = new(); + private readonly IOutboxSerializer _serializer = new JsonOutboxSerializer(); + private readonly EventSubscriptionManager _subscriptionManager = new(); + + private (OutboxProcessingService service, IServiceProvider provider) CreateService( + OutboxOptions? options = null, + Mock? inboxStoreMock = null) + { + var opts = options ?? new OutboxOptions { PollingInterval = TimeSpan.FromMilliseconds(50) }; + + var services = new ServiceCollection(); + services.AddSingleton(_storeMock.Object); + services.AddSingleton(_serializer); + services.AddSingleton(_producerMock.Object); + services.AddSingleton(_subscriptionManager); + services.AddSingleton(_backoffMock.Object); + + if (inboxStoreMock != null) + { + services.AddSingleton(inboxStoreMock.Object); + } + + var provider = services.BuildServiceProvider(); + + var service = new OutboxProcessingService( + provider, + Options.Create(opts), + NullLogger.Instance, + _backoffMock.Object); + + return (service, provider); + } + + [Fact] + public async Task ProcessBatchAsync_DispatchesAndMarksProcessed() + { + var @event = new PollerTestEvent("hello"); + var msg = new OutboxMessage + { + Id = Guid.NewGuid(), + EventType = _serializer.GetEventTypeName(@event), + EventPayload = _serializer.Serialize(@event), + CreatedAtUtc = DateTimeOffset.UtcNow + }; + _storeMock.Setup(s => s.ClaimAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new List { msg }); + + var (service, _) = CreateService(); + await service.ProcessBatchAsync(CancellationToken.None); + + _producerMock.Verify(p => p.ProduceEventAsync(It.IsAny(), It.IsAny()), Times.Once); + _storeMock.Verify(s => s.MarkProcessedAsync(msg.Id, It.IsAny()), Times.Once); + } + + [Fact] + public async Task ProcessBatchAsync_MarksFailedOnException() + { + var @event = new PollerTestEvent("fail"); + var msg = new OutboxMessage + { + Id = Guid.NewGuid(), + EventType = _serializer.GetEventTypeName(@event), + EventPayload = _serializer.Serialize(@event), + CreatedAtUtc = DateTimeOffset.UtcNow, + RetryCount = 0 + }; + _storeMock.Setup(s => s.ClaimAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new List { msg }); + _producerMock.Setup(p => p.ProduceEventAsync(It.IsAny(), It.IsAny())) + .ThrowsAsync(new Exception("transport error")); + _backoffMock.Setup(b => b.ComputeDelay(1)).Returns(TimeSpan.FromSeconds(10)); + + var (service, _) = CreateService(); + await service.ProcessBatchAsync(CancellationToken.None); + + _storeMock.Verify(s => s.MarkFailedAsync(msg.Id, It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); + } + + [Fact] + public async Task ProcessBatchAsync_DeadLettersWhenMaxRetriesExceeded() + { + var @event = new PollerTestEvent("dead"); + var msg = new OutboxMessage + { + Id = Guid.NewGuid(), + EventType = _serializer.GetEventTypeName(@event), + EventPayload = _serializer.Serialize(@event), + CreatedAtUtc = DateTimeOffset.UtcNow, + RetryCount = 5 + }; + _storeMock.Setup(s => s.ClaimAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new List { msg }); + _producerMock.Setup(p => p.ProduceEventAsync(It.IsAny(), It.IsAny())) + .ThrowsAsync(new Exception("still down")); + + var opts = new OutboxOptions { MaxRetries = 5, PollingInterval = TimeSpan.FromMilliseconds(50) }; + var (service, _) = CreateService(opts); + await service.ProcessBatchAsync(CancellationToken.None); + + _storeMock.Verify(s => s.MarkDeadLetteredAsync(msg.Id, It.IsAny()), Times.Once); + } + + [Fact] + public async Task ProcessBatchAsync_InboxRegistered_SkipsDuplicateMessage() + { + var @event = new PollerTestEvent("duplicate"); + var msg = new OutboxMessage + { + Id = Guid.NewGuid(), + EventType = _serializer.GetEventTypeName(@event), + EventPayload = _serializer.Serialize(@event), + CreatedAtUtc = DateTimeOffset.UtcNow, + RetryCount = 0 + }; + _storeMock.Setup(s => s.ClaimAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new List { msg }); + + var inboxMock = new Mock(); + inboxMock.Setup(i => i.ExistsAsync(msg.Id, It.IsAny(), It.IsAny())) + .ReturnsAsync(true); + + var (service, _) = CreateService(inboxStoreMock: inboxMock); + await service.ProcessBatchAsync(CancellationToken.None); + + // Should mark processed (as duplicate), but NOT dispatch + _storeMock.Verify(s => s.MarkProcessedAsync(msg.Id, It.IsAny()), Times.Once); + _producerMock.Verify(p => p.ProduceEventAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task ProcessBatchAsync_InboxNotRegistered_DispatchesNormally() + { + var @event = new PollerTestEvent("normal"); + var msg = new OutboxMessage + { + Id = Guid.NewGuid(), + EventType = _serializer.GetEventTypeName(@event), + EventPayload = _serializer.Serialize(@event), + CreatedAtUtc = DateTimeOffset.UtcNow, + RetryCount = 0 + }; + _storeMock.Setup(s => s.ClaimAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new List { msg }); + + // No inboxStoreMock — inbox not registered + var (service, _) = CreateService(); + await service.ProcessBatchAsync(CancellationToken.None); + + _producerMock.Verify(p => p.ProduceEventAsync(It.IsAny(), It.IsAny()), Times.Once); + _storeMock.Verify(s => s.MarkProcessedAsync(msg.Id, It.IsAny()), Times.Once); + } +} diff --git a/Tests/RCommon.Persistence.Tests/SagaOrchestratorTests.cs b/Tests/RCommon.Persistence.Tests/SagaOrchestratorTests.cs new file mode 100644 index 00000000..a676b170 --- /dev/null +++ b/Tests/RCommon.Persistence.Tests/SagaOrchestratorTests.cs @@ -0,0 +1,200 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Moq; +using RCommon.Models.Events; +using RCommon.Persistence.Sagas; +using RCommon.StateMachines; +using Xunit; + +namespace RCommon.Persistence.Tests; + +// Test enums +public enum TestSagaStep { Initial, StepOne, StepTwo, Completed } +public enum TestSagaTrigger { GoToOne, GoToTwo, Complete } + +// Test saga state +public class TestSagaData : SagaState +{ + public string? Payload { get; set; } +} + +// Test event +public record TestSagaEvent(Guid CorrelationId) : ISerializableEvent; + +// Concrete test saga +public class TestSaga : SagaOrchestrator +{ + public TestSaga( + ISagaStore store, + IStateMachineConfigurator configurator) + : base(store, configurator) { } + + protected override TestSagaStep InitialState => TestSagaStep.Initial; + + protected override void ConfigureStateMachine( + IStateMachineConfigurator configurator) + { + configurator.ForState(TestSagaStep.Initial) + .Permit(TestSagaTrigger.GoToOne, TestSagaStep.StepOne); + configurator.ForState(TestSagaStep.StepOne) + .Permit(TestSagaTrigger.GoToTwo, TestSagaStep.StepTwo); + } + + protected override TestSagaTrigger MapEventToTrigger(TEvent @event) + { + return TestSagaTrigger.GoToOne; + } + + public override Task CompensateAsync(TestSagaData state, CancellationToken ct) + { + state.IsFaulted = true; + state.FaultReason = "Compensated"; + return Task.CompletedTask; + } +} + +public class SagaOrchestratorTests +{ + [Fact] + public void SagaState_Has_Required_Properties() + { + var state = new TestSagaData + { + Id = Guid.NewGuid(), + CorrelationId = "order-123", + StartedAt = DateTimeOffset.UtcNow, + CurrentStep = "Initial", + Version = 1 + }; + + state.Id.Should().NotBeEmpty(); + state.CorrelationId.Should().Be("order-123"); + state.IsCompleted.Should().BeFalse(); + state.IsFaulted.Should().BeFalse(); + } + + [Fact] + public async Task HandleAsync_With_Null_CurrentStep_Uses_InitialState() + { + var mockStore = new Mock>(); + var mockConfigurator = new Mock>(); + var mockStateConfig = new Mock>(); + var mockMachine = new Mock>(); + + mockConfigurator.Setup(c => c.ForState(It.IsAny())) + .Returns(mockStateConfig.Object); + mockStateConfig.Setup(s => s.Permit(It.IsAny(), It.IsAny())) + .Returns(mockStateConfig.Object); + mockConfigurator.Setup(c => c.Build(It.IsAny())) + .Returns(mockMachine.Object); + mockMachine.Setup(m => m.CanFire(It.IsAny())).Returns(true); + mockMachine.Setup(m => m.CurrentState).Returns(TestSagaStep.StepOne); + + var saga = new TestSaga(mockStore.Object, mockConfigurator.Object); + var state = new TestSagaData { Id = Guid.NewGuid(), CurrentStep = null! }; + + await saga.HandleAsync(new TestSagaEvent(Guid.NewGuid()), state, CancellationToken.None); + + mockConfigurator.Verify(c => c.Build(TestSagaStep.Initial), Times.AtLeastOnce); + state.CurrentStep.Should().Be("StepOne"); + mockStore.Verify(s => s.SaveAsync(state, It.IsAny()), Times.Once); + } + + [Fact] + public async Task HandleAsync_Invalid_Trigger_Is_Ignored() + { + var mockStore = new Mock>(); + var mockConfigurator = new Mock>(); + var mockStateConfig = new Mock>(); + var mockMachine = new Mock>(); + + mockConfigurator.Setup(c => c.ForState(It.IsAny())) + .Returns(mockStateConfig.Object); + mockStateConfig.Setup(s => s.Permit(It.IsAny(), It.IsAny())) + .Returns(mockStateConfig.Object); + mockConfigurator.Setup(c => c.Build(It.IsAny())) + .Returns(mockMachine.Object); + mockMachine.Setup(m => m.CanFire(It.IsAny())).Returns(false); + + var saga = new TestSaga(mockStore.Object, mockConfigurator.Object); + var state = new TestSagaData { Id = Guid.NewGuid(), CurrentStep = "Initial" }; + + await saga.HandleAsync(new TestSagaEvent(Guid.NewGuid()), state, CancellationToken.None); + + mockMachine.Verify(m => m.FireAsync(It.IsAny(), It.IsAny()), Times.Never); + mockStore.Verify(s => s.SaveAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task HandleAsync_With_Known_State_Transitions_Correctly() + { + var mockStore = new Mock>(); + var mockConfigurator = new Mock>(); + var mockStateConfig = new Mock>(); + var mockMachine = new Mock>(); + + mockConfigurator.Setup(c => c.ForState(It.IsAny())) + .Returns(mockStateConfig.Object); + mockStateConfig.Setup(s => s.Permit(It.IsAny(), It.IsAny())) + .Returns(mockStateConfig.Object); + mockConfigurator.Setup(c => c.Build(TestSagaStep.Initial)) + .Returns(mockMachine.Object); + mockMachine.Setup(m => m.CanFire(TestSagaTrigger.GoToOne)).Returns(true); + mockMachine.Setup(m => m.CurrentState).Returns(TestSagaStep.StepOne); + + var saga = new TestSaga(mockStore.Object, mockConfigurator.Object); + var state = new TestSagaData { Id = Guid.NewGuid(), CurrentStep = "Initial" }; + + await saga.HandleAsync(new TestSagaEvent(Guid.NewGuid()), state, CancellationToken.None); + + mockConfigurator.Verify(c => c.Build(TestSagaStep.Initial), Times.AtLeastOnce); + state.CurrentStep.Should().Be("StepOne"); + mockStore.Verify(s => s.SaveAsync(state, It.IsAny()), Times.Once); + } + + [Fact] + public async Task HandleAsync_Called_Twice_Configures_StateMachine_Once() + { + var mockStore = new Mock>(); + var mockConfigurator = new Mock>(); + var mockStateConfig = new Mock>(); + var mockMachine = new Mock>(); + + mockConfigurator.Setup(c => c.ForState(It.IsAny())) + .Returns(mockStateConfig.Object); + mockStateConfig.Setup(s => s.Permit(It.IsAny(), It.IsAny())) + .Returns(mockStateConfig.Object); + mockConfigurator.Setup(c => c.Build(It.IsAny())) + .Returns(mockMachine.Object); + mockMachine.Setup(m => m.CanFire(It.IsAny())).Returns(true); + mockMachine.Setup(m => m.CurrentState).Returns(TestSagaStep.StepOne); + + var saga = new TestSaga(mockStore.Object, mockConfigurator.Object); + var state1 = new TestSagaData { Id = Guid.NewGuid(), CurrentStep = "Initial" }; + var state2 = new TestSagaData { Id = Guid.NewGuid(), CurrentStep = "Initial" }; + + await saga.HandleAsync(new TestSagaEvent(Guid.NewGuid()), state1, CancellationToken.None); + await saga.HandleAsync(new TestSagaEvent(Guid.NewGuid()), state2, CancellationToken.None); + + // ConfigureStateMachine calls ForState — should only happen once (lazy init) + // The TestSaga configures 2 states (Initial, StepOne), so ForState is called exactly 2 times total + mockConfigurator.Verify(c => c.ForState(It.IsAny()), Times.Exactly(2)); + } + + [Fact] + public async Task CompensateAsync_Sets_Fault_State() + { + var mockStore = new Mock>(); + var mockConfigurator = new Mock>(); + + var saga = new TestSaga(mockStore.Object, mockConfigurator.Object); + var state = new TestSagaData { Id = Guid.NewGuid() }; + + await saga.CompensateAsync(state, CancellationToken.None); + + state.IsFaulted.Should().BeTrue(); + state.FaultReason.Should().Be("Compensated"); + } +} diff --git a/Tests/RCommon.Persistence.Tests/UnitOfWorkCommitAsyncTests.cs b/Tests/RCommon.Persistence.Tests/UnitOfWorkCommitAsyncTests.cs new file mode 100644 index 00000000..07ec054a --- /dev/null +++ b/Tests/RCommon.Persistence.Tests/UnitOfWorkCommitAsyncTests.cs @@ -0,0 +1,137 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Moq; +using RCommon.Entities; +using RCommon.EventHandling; +using RCommon.Persistence.Transactions; +using Xunit; + +namespace RCommon.Persistence.Tests; + +public class UnitOfWorkCommitAsyncTests +{ + private readonly Mock> _mockLogger; + private readonly Mock _mockGuidGenerator; + private readonly Mock> _mockSettings; + private readonly UnitOfWorkSettings _settings; + + public UnitOfWorkCommitAsyncTests() + { + _mockLogger = new Mock>(); + _mockGuidGenerator = new Mock(); + _mockGuidGenerator.Setup(g => g.Create()).Returns(Guid.NewGuid()); + _settings = new UnitOfWorkSettings + { + DefaultIsolation = System.Transactions.IsolationLevel.ReadCommitted, + AutoCompleteScope = false + }; + _mockSettings = new Mock>(); + _mockSettings.Setup(s => s.Value).Returns(_settings); + } + + [Fact] + public async Task CommitAsync_Without_Tracker_Completes_Successfully() + { + using var uow = new UnitOfWork(_mockLogger.Object, _mockGuidGenerator.Object, _mockSettings.Object); + await uow.CommitAsync(); + uow.State.Should().Be(UnitOfWorkState.Completed); + } + + [Fact] + public async Task CommitAsync_With_Tracker_Dispatches_Events() + { + var mockTracker = new Mock(); + mockTracker.Setup(t => t.PersistEventsAsync(It.IsAny())).Returns(Task.CompletedTask); + mockTracker.Setup(t => t.EmitTransactionalEventsAsync(It.IsAny())).ReturnsAsync(true); + using var uow = new UnitOfWork( + _mockLogger.Object, _mockGuidGenerator.Object, _mockSettings.Object, mockTracker.Object); + await uow.CommitAsync(); + mockTracker.Verify(t => t.EmitTransactionalEventsAsync(It.IsAny()), Times.Once); + } + + [Fact] + public async Task CommitAsync_Logs_Warning_When_Dispatch_Returns_False() + { + var mockTracker = new Mock(); + mockTracker.Setup(t => t.PersistEventsAsync(It.IsAny())).Returns(Task.CompletedTask); + mockTracker.Setup(t => t.EmitTransactionalEventsAsync(It.IsAny())).ReturnsAsync(false); + using var uow = new UnitOfWork( + _mockLogger.Object, _mockGuidGenerator.Object, _mockSettings.Object, mockTracker.Object); + await uow.CommitAsync(); + _mockLogger.Verify( + x => x.Log( + LogLevel.Warning, + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny>()), + Times.Once); + } + + [Fact] + public void Commit_Obsolete_Still_Works_Without_Dispatch() + { + var mockTracker = new Mock(); + using var uow = new UnitOfWork( + _mockLogger.Object, _mockGuidGenerator.Object, _mockSettings.Object, mockTracker.Object); + #pragma warning disable CS0618 + uow.Commit(); + #pragma warning restore CS0618 + uow.State.Should().Be(UnitOfWorkState.Completed); + mockTracker.Verify(t => t.EmitTransactionalEventsAsync(It.IsAny()), Times.Never); + } + + [Fact] + public async Task CommitAsync_On_Disposed_UoW_Throws_ObjectDisposedException() + { + var uow = new UnitOfWork(_mockLogger.Object, _mockGuidGenerator.Object, _mockSettings.Object); + uow.Dispose(); + var act = () => uow.CommitAsync(); + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task CommitAsync_On_Already_Completed_UoW_Throws_UnitOfWorkException() + { + using var uow = new UnitOfWork(_mockLogger.Object, _mockGuidGenerator.Object, _mockSettings.Object); + await uow.CommitAsync(); + var act = () => uow.CommitAsync(); + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task CommitAsync_Then_Dispose_Does_Not_Double_Dispose_TransactionScope() + { + var uow = new UnitOfWork(_mockLogger.Object, _mockGuidGenerator.Object, _mockSettings.Object); + await uow.CommitAsync(); + var act = () => { uow.Dispose(); }; + act.Should().NotThrow("Dispose after CommitAsync must be safe (no double-dispose)"); + } + + [Fact] + public async Task CommitAsync_With_Tracker_Calls_PersistEventsAsync_Before_Commit() + { + var callOrder = new System.Collections.Generic.List(); + var mockTracker = new Mock(); + mockTracker + .Setup(t => t.PersistEventsAsync(It.IsAny())) + .Callback(() => callOrder.Add("PersistEventsAsync")) + .Returns(Task.CompletedTask); + mockTracker + .Setup(t => t.EmitTransactionalEventsAsync(It.IsAny())) + .Callback(() => callOrder.Add("EmitTransactionalEventsAsync")) + .ReturnsAsync(true); + + using var uow = new UnitOfWork( + _mockLogger.Object, _mockGuidGenerator.Object, _mockSettings.Object, mockTracker.Object); + await uow.CommitAsync(); + + callOrder.Should().ContainInOrder("PersistEventsAsync", "EmitTransactionalEventsAsync"); + mockTracker.Verify(t => t.PersistEventsAsync(It.IsAny()), Times.Once); + mockTracker.Verify(t => t.EmitTransactionalEventsAsync(It.IsAny()), Times.Once); + } +} diff --git a/Tests/RCommon.Persistence.Tests/UnitOfWorkOutboxTests.cs b/Tests/RCommon.Persistence.Tests/UnitOfWorkOutboxTests.cs new file mode 100644 index 00000000..6f82eab9 --- /dev/null +++ b/Tests/RCommon.Persistence.Tests/UnitOfWorkOutboxTests.cs @@ -0,0 +1,46 @@ +using FluentAssertions; +using Moq; +using RCommon.Entities; +using RCommon.EventHandling.Producers; +using RCommon.Models.Events; +using RCommon.Persistence.Outbox; +using Xunit; + +namespace RCommon.Persistence.Tests; + +public record UoWTestEvent(string Data) : ISerializableEvent; + +public class UnitOfWorkOutboxTests +{ + [Fact] + public async Task PersistEventsAsync_IsCalledBeforeCommit_ViaOutboxEntityEventTracker() + { + var storeMock = new Mock(); + var serializer = new JsonOutboxSerializer(); + var guidGenMock = new Mock(); + guidGenMock.Setup(g => g.Create()).Returns(Guid.NewGuid()); + var tenantMock = new Mock(); + + var serviceProviderMock = new Mock(); + var subscriptionManager = new EventSubscriptionManager(); + + var outboxRouter = new OutboxEventRouter( + storeMock.Object, + serializer, + guidGenMock.Object, + tenantMock.Object, + serviceProviderMock.Object, + subscriptionManager, + Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance, + Microsoft.Extensions.Options.Options.Create(new OutboxOptions())); + + var innerTracker = new InMemoryEntityEventTracker(outboxRouter); + var tracker = new OutboxEntityEventTracker(innerTracker, outboxRouter); + + // Simulate: PersistEventsAsync is called (Phase 1, pre-commit) + await tracker.PersistEventsAsync(); + + // With no entities tracked, no store calls expected — but should complete without error + storeMock.Verify(s => s.SaveAsync(It.IsAny(), It.IsAny()), Times.Never); + } +} diff --git a/Tests/RCommon.Stateless.Tests/RCommon.Stateless.Tests.csproj b/Tests/RCommon.Stateless.Tests/RCommon.Stateless.Tests.csproj new file mode 100644 index 00000000..fd40a828 --- /dev/null +++ b/Tests/RCommon.Stateless.Tests/RCommon.Stateless.Tests.csproj @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/Tests/RCommon.Stateless.Tests/StatelessConfiguratorTests.cs b/Tests/RCommon.Stateless.Tests/StatelessConfiguratorTests.cs new file mode 100644 index 00000000..46ba1aa5 --- /dev/null +++ b/Tests/RCommon.Stateless.Tests/StatelessConfiguratorTests.cs @@ -0,0 +1,64 @@ +using FluentAssertions; +using RCommon.Stateless; +using RCommon.StateMachines; +using Xunit; + +namespace RCommon.Stateless.Tests; + +public class StatelessConfiguratorTests +{ + [Fact] + public void ForState_ReturnsIStateConfigurator() + { + var configurator = new StatelessConfigurator(); + + var result = configurator.ForState(OrderState.Pending); + + result.Should().NotBeNull(); + result.Should().BeAssignableTo>(); + } + + [Fact] + public void Build_ReturnsIStateMachine() + { + var configurator = new StatelessConfigurator(); + configurator.ForState(OrderState.Pending) + .Permit(OrderTrigger.Approve, OrderState.Approved); + + var result = configurator.Build(OrderState.Pending); + + result.Should().NotBeNull(); + result.Should().BeAssignableTo>(); + } + + [Fact] + public void FluentChaining_Works() + { + var configurator = new StatelessConfigurator(); + + var act = () => configurator.ForState(OrderState.Pending) + .Permit(OrderTrigger.Approve, OrderState.Approved) + .OnEntry(ct => Task.CompletedTask) + .OnExit(ct => Task.CompletedTask); + + act.Should().NotThrow(); + } + + [Fact] + public async Task MultipleForStateCalls_ConfigureDifferentStates() + { + var configurator = new StatelessConfigurator(); + configurator.ForState(OrderState.Pending) + .Permit(OrderTrigger.Approve, OrderState.Approved); + configurator.ForState(OrderState.Approved) + .Permit(OrderTrigger.Ship, OrderState.Shipped); + + var machine = configurator.Build(OrderState.Pending); + + await machine.FireAsync(OrderTrigger.Approve); + machine.CurrentState.Should().Be(OrderState.Approved); + + await machine.FireAsync(OrderTrigger.Ship); + machine.CurrentState.Should().Be(OrderState.Shipped); + } +} diff --git a/Tests/RCommon.Stateless.Tests/StatelessDependencyInjectionTests.cs b/Tests/RCommon.Stateless.Tests/StatelessDependencyInjectionTests.cs new file mode 100644 index 00000000..e29f5e84 --- /dev/null +++ b/Tests/RCommon.Stateless.Tests/StatelessDependencyInjectionTests.cs @@ -0,0 +1,48 @@ +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; +using RCommon.Stateless; +using RCommon.StateMachines; +using Xunit; + +namespace RCommon.Stateless.Tests; + +public class StatelessDependencyInjectionTests +{ + private class TestRCommonBuilder : IRCommonBuilder + { + public IServiceCollection Services { get; } = new ServiceCollection(); + public IServiceCollection Configure() => Services; + public IRCommonBuilder WithDateTimeSystem(Action actions) => this; + public IRCommonBuilder WithSequentialGuidGenerator(Action actions) => this; + public IRCommonBuilder WithSimpleGuidGenerator() => this; + public IRCommonBuilder WithCommonFactory() + where TService : class + where TImplementation : class, TService => this; + } + + [Fact] + public void WithStatelessStateMachine_RegistersOpenGeneric() + { + var builder = new TestRCommonBuilder(); + builder.WithStatelessStateMachine(); + + var provider = builder.Services.BuildServiceProvider(); + var configurator = provider.GetService>(); + + configurator.Should().NotBeNull(); + configurator.Should().BeOfType>(); + } + + [Fact] + public void EachResolution_ReturnsNewInstance() + { + var builder = new TestRCommonBuilder(); + builder.WithStatelessStateMachine(); + + var provider = builder.Services.BuildServiceProvider(); + var first = provider.GetService>(); + var second = provider.GetService>(); + + first.Should().NotBeSameAs(second); + } +} diff --git a/Tests/RCommon.Stateless.Tests/StatelessStateMachineTests.cs b/Tests/RCommon.Stateless.Tests/StatelessStateMachineTests.cs new file mode 100644 index 00000000..766e1eb7 --- /dev/null +++ b/Tests/RCommon.Stateless.Tests/StatelessStateMachineTests.cs @@ -0,0 +1,189 @@ +using FluentAssertions; +using RCommon.Stateless; +using RCommon.StateMachines; +using Xunit; + +namespace RCommon.Stateless.Tests; + +public enum OrderState { Pending, Approved, Shipped, Completed, Cancelled } +public enum OrderTrigger { Approve, Ship, Complete, Cancel } + +public class StatelessStateMachineTests +{ + private static StatelessConfigurator CreateConfigurator() + { + var configurator = new StatelessConfigurator(); + configurator.ForState(OrderState.Pending) + .Permit(OrderTrigger.Approve, OrderState.Approved) + .Permit(OrderTrigger.Cancel, OrderState.Cancelled); + configurator.ForState(OrderState.Approved) + .Permit(OrderTrigger.Ship, OrderState.Shipped); + configurator.ForState(OrderState.Shipped) + .Permit(OrderTrigger.Complete, OrderState.Completed); + return configurator; + } + + [Fact] + public void Build_ReturnsCorrectInitialState() + { + var configurator = CreateConfigurator(); + var machine = configurator.Build(OrderState.Pending); + + machine.CurrentState.Should().Be(OrderState.Pending); + } + + [Fact] + public async Task FireAsync_TransitionsCorrectly() + { + var configurator = CreateConfigurator(); + var machine = configurator.Build(OrderState.Pending); + + await machine.FireAsync(OrderTrigger.Approve); + + machine.CurrentState.Should().Be(OrderState.Approved); + } + + [Fact] + public void CanFire_ReturnsTrue_ForPermittedTrigger() + { + var configurator = CreateConfigurator(); + var machine = configurator.Build(OrderState.Pending); + + machine.CanFire(OrderTrigger.Approve).Should().BeTrue(); + } + + [Fact] + public void CanFire_ReturnsFalse_ForUnpermittedTrigger() + { + var configurator = CreateConfigurator(); + var machine = configurator.Build(OrderState.Pending); + + machine.CanFire(OrderTrigger.Ship).Should().BeFalse(); + } + + [Fact] + public async Task FireAsync_ThrowsForUnpermittedTrigger() + { + var configurator = CreateConfigurator(); + var machine = configurator.Build(OrderState.Pending); + + Func act = () => machine.FireAsync(OrderTrigger.Ship); + + await act.Should().ThrowAsync(); + } + + [Fact] + public void PermittedTriggers_ReturnsCorrectSet() + { + var configurator = CreateConfigurator(); + var machine = configurator.Build(OrderState.Pending); + + machine.PermittedTriggers.Should().Contain(OrderTrigger.Approve) + .And.Contain(OrderTrigger.Cancel); + } + + [Fact] + public async Task PermitIf_AllowsTransitionWhenGuardTrue() + { + var configurator = new StatelessConfigurator(); + configurator.ForState(OrderState.Pending) + .PermitIf(OrderTrigger.Approve, OrderState.Approved, () => true); + + var machine = configurator.Build(OrderState.Pending); + await machine.FireAsync(OrderTrigger.Approve); + + machine.CurrentState.Should().Be(OrderState.Approved); + } + + [Fact] + public void PermitIf_BlocksTransitionWhenGuardFalse() + { + var configurator = new StatelessConfigurator(); + configurator.ForState(OrderState.Pending) + .PermitIf(OrderTrigger.Approve, OrderState.Approved, () => false); + + var machine = configurator.Build(OrderState.Pending); + + machine.CanFire(OrderTrigger.Approve).Should().BeFalse(); + } + + [Fact] + public async Task OnEntry_ExecutesDuringTransition() + { + var entryExecuted = false; + var configurator = new StatelessConfigurator(); + configurator.ForState(OrderState.Pending) + .Permit(OrderTrigger.Approve, OrderState.Approved); + configurator.ForState(OrderState.Approved) + .OnEntry(ct => + { + entryExecuted = true; + return Task.CompletedTask; + }); + + var machine = configurator.Build(OrderState.Pending); + await machine.FireAsync(OrderTrigger.Approve); + + entryExecuted.Should().BeTrue(); + } + + [Fact] + public async Task OnExit_ExecutesDuringTransition() + { + var exitExecuted = false; + var configurator = new StatelessConfigurator(); + configurator.ForState(OrderState.Pending) + .Permit(OrderTrigger.Approve, OrderState.Approved) + .OnExit(ct => + { + exitExecuted = true; + return Task.CompletedTask; + }); + + var machine = configurator.Build(OrderState.Pending); + await machine.FireAsync(OrderTrigger.Approve); + + exitExecuted.Should().BeTrue(); + } + + [Fact] + public void Build_CalledMultipleTimes_ProducesIndependentMachines() + { + var configurator = CreateConfigurator(); + + var machine1 = configurator.Build(OrderState.Pending); + var machine2 = configurator.Build(OrderState.Approved); + + machine1.CurrentState.Should().Be(OrderState.Pending); + machine2.CurrentState.Should().Be(OrderState.Approved); + } + + [Fact] + public async Task MultipleTransitions_InSequence() + { + var configurator = CreateConfigurator(); + var machine = configurator.Build(OrderState.Pending); + + await machine.FireAsync(OrderTrigger.Approve); + machine.CurrentState.Should().Be(OrderState.Approved); + + await machine.FireAsync(OrderTrigger.Ship); + machine.CurrentState.Should().Be(OrderState.Shipped); + + await machine.FireAsync(OrderTrigger.Complete); + machine.CurrentState.Should().Be(OrderState.Completed); + } + + [Fact] + public async Task CancellationToken_ThrowsWhenCancelled() + { + var configurator = CreateConfigurator(); + var machine = configurator.Build(OrderState.Pending); + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + Func act = () => machine.FireAsync(OrderTrigger.Approve, cts.Token); + + await act.Should().ThrowAsync(); + } +} diff --git a/Tests/RCommon.Wolverine.Outbox.Tests/RCommon.Wolverine.Outbox.Tests.csproj b/Tests/RCommon.Wolverine.Outbox.Tests/RCommon.Wolverine.Outbox.Tests.csproj new file mode 100644 index 00000000..aa27c5cc --- /dev/null +++ b/Tests/RCommon.Wolverine.Outbox.Tests/RCommon.Wolverine.Outbox.Tests.csproj @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/Tests/RCommon.Wolverine.Outbox.Tests/WolverineOutboxBuilderTests.cs b/Tests/RCommon.Wolverine.Outbox.Tests/WolverineOutboxBuilderTests.cs new file mode 100644 index 00000000..4bcf4e56 --- /dev/null +++ b/Tests/RCommon.Wolverine.Outbox.Tests/WolverineOutboxBuilderTests.cs @@ -0,0 +1,104 @@ +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; +using Moq; +using RCommon.Wolverine; +using RCommon.Wolverine.Outbox; +using Wolverine; +using Xunit; + +namespace RCommon.Wolverine.Outbox.Tests; + +public class WolverineOutboxBuilderTests +{ + [Fact] + public void Constructor_ThrowsOnNull() + { + var act = () => new WolverineOutboxBuilder(null!); + act.Should().Throw(); + } + + [Fact] + public void Constructor_WithValidOptions_Succeeds() + { + var opts = new WolverineOptions(); + var act = () => new WolverineOutboxBuilder(opts); + act.Should().NotThrow(); + } + + [Fact] + public void UseEntityFrameworkCoreTransactions_ReturnsSameBuilder() + { + var opts = new WolverineOptions(); + var builder = new WolverineOutboxBuilder(opts); + + var result = builder.UseEntityFrameworkCoreTransactions(); + + result.Should().BeSameAs(builder); + } + + [Fact] + public void UseEntityFrameworkCoreTransactions_DoesNotThrow() + { + var opts = new WolverineOptions(); + var builder = new WolverineOutboxBuilder(opts); + + var act = () => builder.UseEntityFrameworkCoreTransactions(); + + act.Should().NotThrow(); + } + + [Fact] + public void Builder_ImplementsIWolverineOutboxBuilder() + { + var opts = new WolverineOptions(); + var builder = new WolverineOutboxBuilder(opts); + + builder.Should().BeAssignableTo(); + } + + [Fact] + public void AddOutbox_WithNullConfigure_RegistersConfigureWolverine() + { + var services = new ServiceCollection(); + var mockBuilder = new Mock(); + mockBuilder.Setup(x => x.Services).Returns(services); + + mockBuilder.Object.AddOutbox(); + + // ConfigureWolverine registers at least one service descriptor + services.Count.Should().BeGreaterThan(0); + } + + [Fact] + public void AddOutbox_ReturnsSameBuilder() + { + var services = new ServiceCollection(); + var mockBuilder = new Mock(); + mockBuilder.Setup(x => x.Services).Returns(services); + + var result = mockBuilder.Object.AddOutbox(); + + result.Should().BeSameAs(mockBuilder.Object); + } + + [Fact] + public void AddOutbox_WithConfigure_InvokesConfigure() + { + var services = new ServiceCollection(); + var mockBuilder = new Mock(); + mockBuilder.Setup(x => x.Services).Returns(services); + + var configureCalled = false; + mockBuilder.Object.AddOutbox(outboxBuilder => + { + configureCalled = true; + outboxBuilder.UseEntityFrameworkCoreTransactions(); + }); + + // The configure action is deferred via ConfigureWolverine; confirm it was registered + services.Count.Should().BeGreaterThan(0); + // configureCalled will only be true if ConfigureWolverine invokes the action eagerly, + // which WolverineFx does not do (it defers to host startup). We verify registration happened. + _ = configureCalled; // suppress unused variable warning + } +} diff --git a/docs/superpowers/plans/2026-03-16-ddd-entity-abstractions.md b/docs/superpowers/plans/2026-03-16-ddd-entity-abstractions.md new file mode 100644 index 00000000..b717fabe --- /dev/null +++ b/docs/superpowers/plans/2026-03-16-ddd-entity-abstractions.md @@ -0,0 +1,1576 @@ +# DDD Entity Abstractions Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add AggregateRoot, DomainEntity, ValueObject, and IDomainEvent abstractions to the RCommon.Entities project with full test coverage. + +**Architecture:** New DDD types extend the existing BusinessEntity hierarchy. AggregateRoot inherits BusinessEntity and reuses the IEntityEventTracker pipeline for domain event dispatch. DomainEntity is a lightweight standalone base with identity equality. ValueObject is a C# abstract record. IDomainEvent extends ISerializableEvent for pipeline compatibility. + +**Tech Stack:** C# / .NET (net8.0, net9.0, net10.0), xUnit, FluentAssertions, Moq, Bogus + +**Spec:** `docs/superpowers/specs/2026-03-16-ddd-entity-abstractions-design.md` + +--- + +## File Structure + +### Source files (all in `Src/RCommon.Entities/`) + +| File | Action | Responsibility | +|------|--------|---------------| +| `IDomainEvent.cs` | Create | Interface extending ISerializableEvent with EventId + OccurredOn | +| `DomainEvent.cs` | Create | Abstract record implementing IDomainEvent with defaults | +| `IAggregateRoot.cs` | Create | Non-generic + generic interfaces for aggregate roots | +| `AggregateRoot.cs` | Create | Abstract base class extending BusinessEntity | +| `DomainEntity.cs` | Create | Lightweight entity base with identity equality, no event tracking | +| `ValueObject.cs` | Create | Abstract record for value objects | + +### Test files (all in `Tests/RCommon.Entities.Tests/`) + +| File | Action | Responsibility | +|------|--------|---------------| +| `DomainEventTests.cs` | Create | Tests for IDomainEvent/DomainEvent record behavior | +| `AggregateRootTests.cs` | Create | Tests for AggregateRoot domain events, versioning, dual-list sync | +| `DomainEntityTests.cs` | Create | Tests for identity equality, transient detection, operator overloads | +| `ValueObjectTests.cs` | Create | Tests for structural equality via records | + +### No existing files are modified. + +--- + +## Chunk 1: Domain Events (IDomainEvent + DomainEvent) + +### Task 1: IDomainEvent and DomainEvent — Write failing tests + +**Files:** +- Test: `Tests/RCommon.Entities.Tests/DomainEventTests.cs` + +- [ ] **Step 1: Create the test file with test stubs** + +```csharp +// Tests/RCommon.Entities.Tests/DomainEventTests.cs +using FluentAssertions; +using RCommon.Entities; +using RCommon.Models.Events; +using Xunit; + +namespace RCommon.Entities.Tests; + +/// +/// Unit tests for IDomainEvent interface and DomainEvent abstract record. +/// +public class DomainEventTests +{ + #region Test Domain Events + + /// + /// Concrete domain event for testing. + /// + private record TestOrderPlacedEvent(Guid OrderId, decimal Total) : DomainEvent; + + /// + /// Another concrete domain event for equality testing. + /// + private record TestOrderCancelledEvent(Guid OrderId, string Reason) : DomainEvent; + + #endregion + + #region IDomainEvent Contract Tests + + [Fact] + public void DomainEvent_Implements_IDomainEvent() + { + // Arrange & Act + var domainEvent = new TestOrderPlacedEvent(Guid.NewGuid(), 99.99m); + + // Assert + domainEvent.Should().BeAssignableTo(); + } + + [Fact] + public void DomainEvent_Implements_ISerializableEvent() + { + // Arrange & Act + var domainEvent = new TestOrderPlacedEvent(Guid.NewGuid(), 99.99m); + + // Assert + domainEvent.Should().BeAssignableTo(); + } + + #endregion + + #region Default Property Tests + + [Fact] + public void DomainEvent_EventId_IsAssignedByDefault() + { + // Arrange & Act + var domainEvent = new TestOrderPlacedEvent(Guid.NewGuid(), 99.99m); + + // Assert + domainEvent.EventId.Should().NotBe(Guid.Empty); + } + + [Fact] + public void DomainEvent_OccurredOn_IsAssignedByDefault() + { + // Arrange & Act + var before = DateTimeOffset.UtcNow; + var domainEvent = new TestOrderPlacedEvent(Guid.NewGuid(), 99.99m); + var after = DateTimeOffset.UtcNow; + + // Assert + domainEvent.OccurredOn.Should().BeOnOrAfter(before); + domainEvent.OccurredOn.Should().BeOnOrBefore(after); + } + + [Fact] + public void DomainEvent_TwoInstances_HaveDifferentEventIds() + { + // Arrange & Act + var event1 = new TestOrderPlacedEvent(Guid.NewGuid(), 99.99m); + var event2 = new TestOrderPlacedEvent(Guid.NewGuid(), 99.99m); + + // Assert + event1.EventId.Should().NotBe(event2.EventId); + } + + #endregion + + #region Init Property Override Tests + + [Fact] + public void DomainEvent_EventId_CanBeOverriddenViaInit() + { + // Arrange + var customId = Guid.NewGuid(); + + // Act + var domainEvent = new TestOrderPlacedEvent(Guid.NewGuid(), 99.99m) + { + EventId = customId + }; + + // Assert + domainEvent.EventId.Should().Be(customId); + } + + [Fact] + public void DomainEvent_OccurredOn_CanBeOverriddenViaInit() + { + // Arrange + var customTime = new DateTimeOffset(2024, 1, 15, 10, 30, 0, TimeSpan.Zero); + + // Act + var domainEvent = new TestOrderPlacedEvent(Guid.NewGuid(), 99.99m) + { + OccurredOn = customTime + }; + + // Assert + domainEvent.OccurredOn.Should().Be(customTime); + } + + #endregion + + #region Record Equality Tests + + [Fact] + public void DomainEvent_SameValues_AreEqual() + { + // Arrange + var orderId = Guid.NewGuid(); + var eventId = Guid.NewGuid(); + var occurredOn = DateTimeOffset.UtcNow; + + // Act + var event1 = new TestOrderPlacedEvent(orderId, 99.99m) { EventId = eventId, OccurredOn = occurredOn }; + var event2 = new TestOrderPlacedEvent(orderId, 99.99m) { EventId = eventId, OccurredOn = occurredOn }; + + // Assert + event1.Should().Be(event2); + } + + [Fact] + public void DomainEvent_DifferentValues_AreNotEqual() + { + // Arrange & Act + var event1 = new TestOrderPlacedEvent(Guid.NewGuid(), 99.99m); + var event2 = new TestOrderPlacedEvent(Guid.NewGuid(), 50.00m); + + // Assert + event1.Should().NotBe(event2); + } + + [Fact] + public void DomainEvent_DifferentTypes_AreNotEqual() + { + // Arrange + var orderId = Guid.NewGuid(); + + // Act + var placedEvent = new TestOrderPlacedEvent(orderId, 99.99m); + var cancelledEvent = new TestOrderCancelledEvent(orderId, "Changed mind"); + + // Assert + placedEvent.Should().NotBe(cancelledEvent); + } + + #endregion + + #region With-Expression Tests + + [Fact] + public void DomainEvent_WithExpression_CreatesModifiedCopy() + { + // Arrange + var original = new TestOrderPlacedEvent(Guid.NewGuid(), 99.99m); + + // Act + var modified = original with { Total = 149.99m }; + + // Assert + modified.Total.Should().Be(149.99m); + modified.OrderId.Should().Be(original.OrderId); + modified.EventId.Should().Be(original.EventId); + modified.OccurredOn.Should().Be(original.OccurredOn); + } + + #endregion +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `dotnet test Tests/RCommon.Entities.Tests/ --filter "FullyQualifiedName~DomainEventTests" --no-restore -v quiet` +Expected: Build failure — `DomainEvent` and `IDomainEvent` types do not exist yet. + +### Task 2: IDomainEvent and DomainEvent — Implement + +**Files:** +- Create: `Src/RCommon.Entities/IDomainEvent.cs` +- Create: `Src/RCommon.Entities/DomainEvent.cs` + +- [ ] **Step 3: Create IDomainEvent.cs** + +```csharp +// Src/RCommon.Entities/IDomainEvent.cs +using RCommon.Models.Events; + +namespace RCommon.Entities +{ + /// + /// Represents a domain event raised by an aggregate root. + /// Extends ISerializableEvent for compatibility with the existing event routing pipeline. + /// + public interface IDomainEvent : ISerializableEvent + { + /// + /// Unique identifier for this event instance. + /// + Guid EventId { get; } + + /// + /// The date and time when this event occurred. + /// + DateTimeOffset OccurredOn { get; } + } +} +``` + +- [ ] **Step 4: Create DomainEvent.cs** + +```csharp +// Src/RCommon.Entities/DomainEvent.cs +namespace RCommon.Entities +{ + /// + /// Abstract base record for domain events. Provides default values for EventId and OccurredOn. + /// Use as a base for all concrete domain events. + /// + public abstract record DomainEvent : IDomainEvent + { + public Guid EventId { get; init; } = Guid.NewGuid(); + public DateTimeOffset OccurredOn { get; init; } = DateTimeOffset.UtcNow; + } +} +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `dotnet test Tests/RCommon.Entities.Tests/ --filter "FullyQualifiedName~DomainEventTests" --no-restore -v quiet` +Expected: All 11 tests PASS. + +- [ ] **Step 6: Commit** + +```bash +git add Src/RCommon.Entities/IDomainEvent.cs Src/RCommon.Entities/DomainEvent.cs Tests/RCommon.Entities.Tests/DomainEventTests.cs +git commit -m "feat: add IDomainEvent interface and DomainEvent base record + +IDomainEvent extends ISerializableEvent with EventId and OccurredOn. +DomainEvent is an abstract record with sensible defaults." +``` + +--- + +## Chunk 2: ValueObject + +### Task 3: ValueObject — Write failing tests + +**Files:** +- Test: `Tests/RCommon.Entities.Tests/ValueObjectTests.cs` + +- [ ] **Step 7: Create the test file** + +```csharp +// Tests/RCommon.Entities.Tests/ValueObjectTests.cs +using FluentAssertions; +using RCommon.Entities; +using Xunit; + +namespace RCommon.Entities.Tests; + +/// +/// Unit tests for ValueObject abstract record. +/// +public class ValueObjectTests +{ + #region Test Value Objects + + private record Money(decimal Amount, string Currency) : ValueObject; + + private record Address(string Street, string City, string ZipCode) : ValueObject; + + #endregion + + #region Structural Equality Tests + + [Fact] + public void ValueObject_SameValues_AreEqual() + { + // Arrange & Act + var money1 = new Money(100.00m, "USD"); + var money2 = new Money(100.00m, "USD"); + + // Assert + money1.Should().Be(money2); + } + + [Fact] + public void ValueObject_DifferentValues_AreNotEqual() + { + // Arrange & Act + var money1 = new Money(100.00m, "USD"); + var money2 = new Money(200.00m, "USD"); + + // Assert + money1.Should().NotBe(money2); + } + + [Fact] + public void ValueObject_DifferentTypes_AreNotEqual() + { + // Arrange & Act — two different ValueObject subtypes + var money = new Money(100.00m, "USD"); + var address = new Address("123 Main St", "Springfield", "62701"); + + // Assert + money.Should().NotBe(address); + } + + [Fact] + public void ValueObject_SameValues_HaveSameHashCode() + { + // Arrange & Act + var money1 = new Money(100.00m, "USD"); + var money2 = new Money(100.00m, "USD"); + + // Assert + money1.GetHashCode().Should().Be(money2.GetHashCode()); + } + + [Fact] + public void ValueObject_DifferentValues_HaveDifferentHashCode() + { + // Arrange & Act + var money1 = new Money(100.00m, "USD"); + var money2 = new Money(200.00m, "EUR"); + + // Assert + money1.GetHashCode().Should().NotBe(money2.GetHashCode()); + } + + #endregion + + #region Operator Tests + + [Fact] + public void ValueObject_EqualityOperator_ReturnsTrueForSameValues() + { + // Arrange & Act + var money1 = new Money(50.00m, "GBP"); + var money2 = new Money(50.00m, "GBP"); + + // Assert + (money1 == money2).Should().BeTrue(); + } + + [Fact] + public void ValueObject_InequalityOperator_ReturnsTrueForDifferentValues() + { + // Arrange & Act + var money1 = new Money(50.00m, "GBP"); + var money2 = new Money(75.00m, "GBP"); + + // Assert + (money1 != money2).Should().BeTrue(); + } + + #endregion + + #region Immutability Tests + + [Fact] + public void ValueObject_WithExpression_CreatesModifiedCopy() + { + // Arrange + var original = new Money(100.00m, "USD"); + + // Act + var modified = original with { Amount = 200.00m }; + + // Assert + modified.Amount.Should().Be(200.00m); + modified.Currency.Should().Be("USD"); + original.Amount.Should().Be(100.00m, "original should be unchanged"); + } + + #endregion + + #region Interface Conformance Tests + + [Fact] + public void ValueObject_ConcreteType_IsAssignableToValueObject() + { + // Arrange & Act + var money = new Money(100.00m, "USD"); + + // Assert + money.Should().BeAssignableTo(); + } + + #endregion +} +``` + +- [ ] **Step 8: Run test to verify it fails** + +Run: `dotnet test Tests/RCommon.Entities.Tests/ --filter "FullyQualifiedName~ValueObjectTests" --no-restore -v quiet` +Expected: Build failure — `ValueObject` type does not exist yet. + +### Task 4: ValueObject — Implement + +**Files:** +- Create: `Src/RCommon.Entities/ValueObject.cs` + +- [ ] **Step 9: Create ValueObject.cs** + +```csharp +// Src/RCommon.Entities/ValueObject.cs +namespace RCommon.Entities +{ + /// + /// Abstract base record for value objects. Leverages C# record semantics for automatic + /// structural equality, immutability, and with-expression support. + /// + /// Derive concrete value objects from this type: + /// + /// public record Money(decimal Amount, string Currency) : ValueObject; + /// public record Address(string Street, string City, string ZipCode) : ValueObject; + /// + /// + public abstract record ValueObject; +} +``` + +- [ ] **Step 10: Run tests to verify they pass** + +Run: `dotnet test Tests/RCommon.Entities.Tests/ --filter "FullyQualifiedName~ValueObjectTests" --no-restore -v quiet` +Expected: All 9 tests PASS. + +- [ ] **Step 11: Commit** + +```bash +git add Src/RCommon.Entities/ValueObject.cs Tests/RCommon.Entities.Tests/ValueObjectTests.cs +git commit -m "feat: add ValueObject abstract record + +C# record-based value object with automatic structural equality, +immutability, and with-expression support." +``` + +--- + +## Chunk 3: DomainEntity + +### Task 5: DomainEntity — Write failing tests + +**Files:** +- Test: `Tests/RCommon.Entities.Tests/DomainEntityTests.cs` + +- [ ] **Step 12: Create the test file** + +```csharp +// Tests/RCommon.Entities.Tests/DomainEntityTests.cs +using Bogus; +using FluentAssertions; +using RCommon.Entities; +using Xunit; + +namespace RCommon.Entities.Tests; + +/// +/// Unit tests for DomainEntity{TKey} abstract class. +/// +public class DomainEntityTests +{ + private readonly Faker _faker; + + public DomainEntityTests() + { + _faker = new Faker(); + } + + #region Test Entities + + private class TestDomainEntityInt : DomainEntity + { + public string Name { get; set; } = string.Empty; + + public TestDomainEntityInt() { } + + public TestDomainEntityInt(int id) + { + Id = id; + } + } + + private class TestDomainEntityGuid : DomainEntity + { + public string Name { get; set; } = string.Empty; + + public TestDomainEntityGuid() { } + + public TestDomainEntityGuid(Guid id) + { + Id = id; + } + } + + private class TestDomainEntityString : DomainEntity + { + public string Name { get; set; } = string.Empty; + + public TestDomainEntityString() { } + + public TestDomainEntityString(string id) + { + Id = id; + } + } + + /// + /// A different entity type with the same key type, for cross-type equality tests. + /// + private class TestOtherDomainEntityInt : DomainEntity + { + public TestOtherDomainEntityInt(int id) + { + Id = id; + } + } + + #endregion + + #region Identity Tests + + [Fact] + public void DomainEntity_DefaultConstructor_IdIsDefault() + { + // Arrange & Act + var entity = new TestDomainEntityInt(); + + // Assert + entity.Id.Should().Be(default(int)); + } + + [Fact] + public void DomainEntity_ConstructorWithId_SetsId() + { + // Arrange + var id = _faker.Random.Int(1, 1000); + + // Act + var entity = new TestDomainEntityInt(id); + + // Assert + entity.Id.Should().Be(id); + } + + [Fact] + public void DomainEntity_GuidKey_SetsId() + { + // Arrange + var id = Guid.NewGuid(); + + // Act + var entity = new TestDomainEntityGuid(id); + + // Assert + entity.Id.Should().Be(id); + } + + [Fact] + public void DomainEntity_StringKey_SetsId() + { + // Arrange + var id = _faker.Random.AlphaNumeric(10); + + // Act + var entity = new TestDomainEntityString(id); + + // Assert + entity.Id.Should().Be(id); + } + + #endregion + + #region Transient Detection Tests + + [Fact] + public void IsTransient_DefaultIntId_ReturnsTrue() + { + // Arrange & Act + var entity = new TestDomainEntityInt(); + + // Assert + entity.IsTransient().Should().BeTrue(); + } + + [Fact] + public void IsTransient_DefaultGuidId_ReturnsTrue() + { + // Arrange & Act + var entity = new TestDomainEntityGuid(); + + // Assert + entity.IsTransient().Should().BeTrue(); + } + + [Fact] + public void IsTransient_NullStringId_ReturnsTrue() + { + // Arrange & Act + var entity = new TestDomainEntityString(); + + // Assert + entity.IsTransient().Should().BeTrue(); + } + + [Fact] + public void IsTransient_NonDefaultId_ReturnsFalse() + { + // Arrange & Act + var entity = new TestDomainEntityInt(42); + + // Assert + entity.IsTransient().Should().BeFalse(); + } + + [Fact] + public void IsTransient_NonEmptyGuidId_ReturnsFalse() + { + // Arrange & Act + var entity = new TestDomainEntityGuid(Guid.NewGuid()); + + // Assert + entity.IsTransient().Should().BeFalse(); + } + + #endregion + + #region Equality Tests + + [Fact] + public void Equals_SameId_ReturnsTrue() + { + // Arrange + var entity1 = new TestDomainEntityInt(42); + var entity2 = new TestDomainEntityInt(42); + + // Act & Assert + entity1.Equals(entity2).Should().BeTrue(); + } + + [Fact] + public void Equals_DifferentId_ReturnsFalse() + { + // Arrange + var entity1 = new TestDomainEntityInt(42); + var entity2 = new TestDomainEntityInt(99); + + // Act & Assert + entity1.Equals(entity2).Should().BeFalse(); + } + + [Fact] + public void Equals_SameReference_ReturnsTrue() + { + // Arrange + var entity = new TestDomainEntityInt(42); + + // Act & Assert + entity.Equals(entity).Should().BeTrue(); + } + + [Fact] + public void Equals_Null_ReturnsFalse() + { + // Arrange + var entity = new TestDomainEntityInt(42); + + // Act & Assert + entity.Equals(null).Should().BeFalse(); + } + + [Fact] + public void Equals_DifferentType_SameId_ReturnsFalse() + { + // Arrange + var entity1 = new TestDomainEntityInt(42); + var entity2 = new TestOtherDomainEntityInt(42); + + // Act & Assert + entity1.Equals(entity2).Should().BeFalse(); + } + + [Fact] + public void Equals_BothTransient_ReturnsFalse() + { + // Arrange — two transient entities should not be considered equal + var entity1 = new TestDomainEntityInt(); + var entity2 = new TestDomainEntityInt(); + + // Act & Assert + entity1.Equals(entity2).Should().BeFalse(); + } + + [Fact] + public void Equals_OneTransient_ReturnsFalse() + { + // Arrange + var entity1 = new TestDomainEntityInt(42); + var entity2 = new TestDomainEntityInt(); + + // Act & Assert + entity1.Equals(entity2).Should().BeFalse(); + } + + [Fact] + public void Equals_ObjectOverload_WorksCorrectly() + { + // Arrange + var entity1 = new TestDomainEntityInt(42); + object entity2 = new TestDomainEntityInt(42); + + // Act & Assert + entity1.Equals(entity2).Should().BeTrue(); + } + + [Fact] + public void Equals_NonDomainEntityObject_ReturnsFalse() + { + // Arrange + var entity = new TestDomainEntityInt(42); + var nonEntity = "not an entity"; + + // Act & Assert + entity.Equals(nonEntity).Should().BeFalse(); + } + + #endregion + + #region GetHashCode Tests + + [Fact] + public void GetHashCode_SameId_ReturnsSameHash() + { + // Arrange + var entity1 = new TestDomainEntityInt(42); + var entity2 = new TestDomainEntityInt(42); + + // Act & Assert + entity1.GetHashCode().Should().Be(entity2.GetHashCode()); + } + + [Fact] + public void GetHashCode_TransientEntity_ReturnsObjectHashCode() + { + // Arrange — transient entities use base.GetHashCode(), so two instances differ + var entity1 = new TestDomainEntityInt(); + var entity2 = new TestDomainEntityInt(); + + // Act & Assert — just verify they don't throw; values will differ + entity1.GetHashCode().Should().NotBe(0); + entity2.GetHashCode().Should().NotBe(0); + } + + #endregion + + #region Operator Tests + + [Fact] + public void EqualityOperator_SameId_ReturnsTrue() + { + // Arrange + var entity1 = new TestDomainEntityInt(42); + var entity2 = new TestDomainEntityInt(42); + + // Act & Assert + (entity1 == entity2).Should().BeTrue(); + } + + [Fact] + public void EqualityOperator_DifferentId_ReturnsFalse() + { + // Arrange + var entity1 = new TestDomainEntityInt(42); + var entity2 = new TestDomainEntityInt(99); + + // Act & Assert + (entity1 == entity2).Should().BeFalse(); + } + + [Fact] + public void EqualityOperator_BothNull_ReturnsTrue() + { + // Arrange + TestDomainEntityInt? entity1 = null; + TestDomainEntityInt? entity2 = null; + + // Act & Assert + (entity1 == entity2).Should().BeTrue(); + } + + [Fact] + public void EqualityOperator_OneNull_ReturnsFalse() + { + // Arrange + var entity1 = new TestDomainEntityInt(42); + TestDomainEntityInt? entity2 = null; + + // Act & Assert + (entity1 == entity2).Should().BeFalse(); + } + + [Fact] + public void InequalityOperator_DifferentId_ReturnsTrue() + { + // Arrange + var entity1 = new TestDomainEntityInt(42); + var entity2 = new TestDomainEntityInt(99); + + // Act & Assert + (entity1 != entity2).Should().BeTrue(); + } + + [Fact] + public void InequalityOperator_SameId_ReturnsFalse() + { + // Arrange + var entity1 = new TestDomainEntityInt(42); + var entity2 = new TestDomainEntityInt(42); + + // Act & Assert + (entity1 != entity2).Should().BeFalse(); + } + + #endregion + + #region IEquatable Tests + + [Fact] + public void DomainEntity_Implements_IEquatable() + { + // Arrange & Act + var entity = new TestDomainEntityInt(42); + + // Assert + entity.Should().BeAssignableTo>>(); + } + + #endregion + + #region Does NOT implement IBusinessEntity + + [Fact] + public void DomainEntity_DoesNotImplement_IBusinessEntity() + { + // Arrange & Act + var entity = new TestDomainEntityInt(42); + + // Assert — DomainEntity is intentionally NOT an IBusinessEntity + entity.Should().NotBeAssignableTo(); + } + + #endregion +} +``` + +- [ ] **Step 13: Run test to verify it fails** + +Run: `dotnet test Tests/RCommon.Entities.Tests/ --filter "FullyQualifiedName~DomainEntityTests" --no-restore -v quiet` +Expected: Build failure — `DomainEntity` type does not exist yet. + +### Task 6: DomainEntity — Implement + +**Files:** +- Create: `Src/RCommon.Entities/DomainEntity.cs` + +- [ ] **Step 14: Create DomainEntity.cs** + +```csharp +// Src/RCommon.Entities/DomainEntity.cs +namespace RCommon.Entities +{ + /// + /// Abstract base class for domain entities within an aggregate. Provides identity-based equality + /// but no event tracking — entities within an aggregate raise events through their aggregate root. + /// Because DomainEntity does not implement IBusinessEntity, the ObjectGraphWalker in + /// InMemoryEntityEventTracker will not traverse it. All domain events must be raised on the + /// aggregate root. + /// + /// The type of the entity's identity. + [Serializable] + public abstract class DomainEntity : IEquatable> + where TKey : IEquatable + { + /// + /// The unique identity of this entity. + /// + public virtual TKey Id { get; protected set; } = default!; + + public bool Equals(DomainEntity? other) + { + if (other is null) + return false; + + if (ReferenceEquals(this, other)) + return true; + + if (GetType() != other.GetType()) + return false; + + if (IsTransient() || other.IsTransient()) + return false; + + return Id.Equals(other.Id); + } + + public override bool Equals(object? obj) + => Equals(obj as DomainEntity); + + public override int GetHashCode() + { + var id = Id; + if (id is null || id.Equals(default(TKey))) + return base.GetHashCode(); + return id.GetHashCode(); + } + + /// + /// Returns true if this entity has not yet been assigned a persistent identity. + /// + public bool IsTransient() + => Id is null || Id.Equals(default); + + public static bool operator ==(DomainEntity? left, DomainEntity? right) + { + if (left is null && right is null) + return true; + if (left is null || right is null) + return false; + return left.Equals(right); + } + + public static bool operator !=(DomainEntity? left, DomainEntity? right) + => !(left == right); + } +} +``` + +- [ ] **Step 15: Run tests to verify they pass** + +Run: `dotnet test Tests/RCommon.Entities.Tests/ --filter "FullyQualifiedName~DomainEntityTests" --no-restore -v quiet` +Expected: All 28 tests PASS. + +- [ ] **Step 16: Commit** + +```bash +git add Src/RCommon.Entities/DomainEntity.cs Tests/RCommon.Entities.Tests/DomainEntityTests.cs +git commit -m "feat: add DomainEntity base class + +Lightweight entity with identity-based equality and IEquatable support. +No event tracking — child entities raise events through aggregate root." +``` + +--- + +## Chunk 4: AggregateRoot + +### Task 7: AggregateRoot — Write failing tests + +**Files:** +- Test: `Tests/RCommon.Entities.Tests/AggregateRootTests.cs` + +- [ ] **Step 17: Create the test file** + +```csharp +// Tests/RCommon.Entities.Tests/AggregateRootTests.cs +using Bogus; +using FluentAssertions; +using Moq; +using RCommon.Entities; +using RCommon.EventHandling.Producers; +using RCommon.Models.Events; +using Xunit; + +namespace RCommon.Entities.Tests; + +/// +/// Unit tests for AggregateRoot{TKey}, IAggregateRoot, and IAggregateRoot{TKey}. +/// +public class AggregateRootTests +{ + private readonly Faker _faker; + + public AggregateRootTests() + { + _faker = new Faker(); + } + + #region Test Types + + /// + /// Concrete aggregate root for testing with int key. + /// Exposes protected methods for test access. + /// + private class TestAggregateInt : AggregateRoot + { + public string Name { get; set; } = string.Empty; + + public TestAggregateInt() : base() { } + + public TestAggregateInt(int id) : base(id) { } + + /// + /// Public wrapper for the protected AddDomainEvent method. + /// + public void RaiseDomainEvent(IDomainEvent domainEvent) + => AddDomainEvent(domainEvent); + + /// + /// Public wrapper for the protected RemoveDomainEvent method. + /// + public void UndoDomainEvent(IDomainEvent domainEvent) + => RemoveDomainEvent(domainEvent); + + /// + /// Public wrapper for the protected IncrementVersion method. + /// + public void BumpVersion() + => IncrementVersion(); + } + + private class TestAggregateGuid : AggregateRoot + { + public TestAggregateGuid() : base() { } + + public TestAggregateGuid(Guid id) : base(id) { } + + public void RaiseDomainEvent(IDomainEvent domainEvent) + => AddDomainEvent(domainEvent); + } + + private record TestDomainEvent(string Message) : DomainEvent; + + private record TestOtherDomainEvent(int Code) : DomainEvent; + + #endregion + + #region Interface Conformance Tests + + [Fact] + public void AggregateRoot_Implements_IAggregateRoot() + { + // Arrange & Act + var aggregate = new TestAggregateInt(1); + + // Assert + aggregate.Should().BeAssignableTo(); + } + + [Fact] + public void AggregateRoot_Implements_IAggregateRootGeneric() + { + // Arrange & Act + var aggregate = new TestAggregateInt(1); + + // Assert + aggregate.Should().BeAssignableTo>(); + } + + [Fact] + public void AggregateRoot_Implements_IBusinessEntity() + { + // Arrange & Act + var aggregate = new TestAggregateInt(1); + + // Assert + aggregate.Should().BeAssignableTo(); + } + + [Fact] + public void AggregateRoot_Implements_IBusinessEntityGeneric() + { + // Arrange & Act + var aggregate = new TestAggregateInt(1); + + // Assert + aggregate.Should().BeAssignableTo>(); + } + + #endregion + + #region Identity Tests + + [Fact] + public void AggregateRoot_ConstructorWithId_SetsId() + { + // Arrange & Act + var aggregate = new TestAggregateInt(42); + + // Assert + aggregate.Id.Should().Be(42); + } + + [Fact] + public void AggregateRoot_DefaultConstructor_IdIsDefault() + { + // Arrange & Act + var aggregate = new TestAggregateInt(); + + // Assert + aggregate.Id.Should().Be(0); + } + + [Fact] + public void AggregateRoot_GuidKey_SetsId() + { + // Arrange + var id = Guid.NewGuid(); + + // Act + var aggregate = new TestAggregateGuid(id); + + // Assert + aggregate.Id.Should().Be(id); + } + + #endregion + + #region Version Tests + + [Fact] + public void AggregateRoot_DefaultVersion_IsZero() + { + // Arrange & Act + var aggregate = new TestAggregateInt(1); + + // Assert + aggregate.Version.Should().Be(0); + } + + [Fact] + public void IncrementVersion_IncrementsVersionByOne() + { + // Arrange + var aggregate = new TestAggregateInt(1); + + // Act + aggregate.BumpVersion(); + + // Assert + aggregate.Version.Should().Be(1); + } + + [Fact] + public void IncrementVersion_CalledMultipleTimes_VersionIncrementsCorrectly() + { + // Arrange + var aggregate = new TestAggregateInt(1); + + // Act + aggregate.BumpVersion(); + aggregate.BumpVersion(); + aggregate.BumpVersion(); + + // Assert + aggregate.Version.Should().Be(3); + } + + #endregion + + #region Domain Event Add/Remove/Clear Tests + + [Fact] + public void AddDomainEvent_AddsEventToDomainEvents() + { + // Arrange + var aggregate = new TestAggregateInt(1); + var domainEvent = new TestDomainEvent("test"); + + // Act + aggregate.RaiseDomainEvent(domainEvent); + + // Assert + aggregate.DomainEvents.Should().ContainSingle() + .Which.Should().Be(domainEvent); + } + + [Fact] + public void AddDomainEvent_MultipleEvents_AllPresent() + { + // Arrange + var aggregate = new TestAggregateInt(1); + var event1 = new TestDomainEvent("first"); + var event2 = new TestOtherDomainEvent(42); + + // Act + aggregate.RaiseDomainEvent(event1); + aggregate.RaiseDomainEvent(event2); + + // Assert + aggregate.DomainEvents.Should().HaveCount(2); + aggregate.DomainEvents.Should().Contain(event1); + aggregate.DomainEvents.Should().Contain(event2); + } + + [Fact] + public void RemoveDomainEvent_RemovesEventFromDomainEvents() + { + // Arrange + var aggregate = new TestAggregateInt(1); + var domainEvent = new TestDomainEvent("test"); + aggregate.RaiseDomainEvent(domainEvent); + + // Act + aggregate.UndoDomainEvent(domainEvent); + + // Assert + aggregate.DomainEvents.Should().BeEmpty(); + } + + [Fact] + public void ClearDomainEvents_RemovesAllEvents() + { + // Arrange + var aggregate = new TestAggregateInt(1); + aggregate.RaiseDomainEvent(new TestDomainEvent("first")); + aggregate.RaiseDomainEvent(new TestOtherDomainEvent(42)); + + // Act + aggregate.ClearDomainEvents(); + + // Assert + aggregate.DomainEvents.Should().BeEmpty(); + } + + [Fact] + public void DomainEvents_WhenEmpty_ReturnsEmptyCollection() + { + // Arrange & Act + var aggregate = new TestAggregateInt(1); + + // Assert + aggregate.DomainEvents.Should().BeEmpty(); + aggregate.DomainEvents.Should().NotBeNull(); + } + + #endregion + + #region Dual-List Sync Tests (DomainEvents + LocalEvents) + + [Fact] + public void AddDomainEvent_AlsoAppearsInLocalEvents() + { + // Arrange + var aggregate = new TestAggregateInt(1); + var domainEvent = new TestDomainEvent("test"); + + // Act + aggregate.RaiseDomainEvent(domainEvent); + + // Assert — event appears in both collections + aggregate.DomainEvents.Should().ContainSingle().Which.Should().Be(domainEvent); + aggregate.LocalEvents.Should().ContainSingle().Which.Should().Be(domainEvent); + } + + [Fact] + public void RemoveDomainEvent_AlsoRemovesFromLocalEvents() + { + // Arrange + var aggregate = new TestAggregateInt(1); + var domainEvent = new TestDomainEvent("test"); + aggregate.RaiseDomainEvent(domainEvent); + + // Act + aggregate.UndoDomainEvent(domainEvent); + + // Assert — event removed from both collections + aggregate.DomainEvents.Should().BeEmpty(); + aggregate.LocalEvents.Should().BeEmpty(); + } + + [Fact] + public void ClearDomainEvents_AlsoClearsLocalEvents() + { + // Arrange + var aggregate = new TestAggregateInt(1); + aggregate.RaiseDomainEvent(new TestDomainEvent("one")); + aggregate.RaiseDomainEvent(new TestDomainEvent("two")); + + // Act + aggregate.ClearDomainEvents(); + + // Assert — both collections cleared + aggregate.DomainEvents.Should().BeEmpty(); + aggregate.LocalEvents.Should().BeEmpty(); + } + + #endregion + + #region Event Pipeline Integration Tests + + [Fact] + public async Task DomainEvents_FlowThrough_EntityEventTracker() + { + // Arrange + var mockEventRouter = new Mock(); + mockEventRouter.Setup(x => x.RouteEventsAsync()).Returns(Task.CompletedTask); + var tracker = new InMemoryEntityEventTracker(mockEventRouter.Object); + + var aggregate = new TestAggregateInt(1); + aggregate.AllowEventTracking = true; + var domainEvent = new TestDomainEvent("integration test"); + aggregate.RaiseDomainEvent(domainEvent); + + // Act + tracker.AddEntity(aggregate); + await tracker.EmitTransactionalEventsAsync(); + + // Assert — the domain event (which IS-A ISerializableEvent) was routed + mockEventRouter.Verify( + x => x.AddTransactionalEvents(It.Is>( + events => events.Contains(domainEvent))), + Times.AtLeastOnce); + mockEventRouter.Verify(x => x.RouteEventsAsync(), Times.Once); + } + + #endregion + + #region Inherited BusinessEntity Behavior Tests + + [Fact] + public void AggregateRoot_GetKeys_ReturnsId() + { + // Arrange + var aggregate = new TestAggregateInt(42); + + // Act + var keys = aggregate.GetKeys(); + + // Assert + keys.Should().ContainSingle().Which.Should().Be(42); + } + + [Fact] + public void AggregateRoot_EntityEquals_SameId_ReturnsTrue() + { + // Arrange + var aggregate1 = new TestAggregateInt(42); + var aggregate2 = new TestAggregateInt(42); + + // Act & Assert + aggregate1.EntityEquals(aggregate2).Should().BeTrue(); + } + + #endregion +} +``` + +- [ ] **Step 18: Run test to verify it fails** + +Run: `dotnet test Tests/RCommon.Entities.Tests/ --filter "FullyQualifiedName~AggregateRootTests" --no-restore -v quiet` +Expected: Build failure — `AggregateRoot`, `IAggregateRoot`, `IAggregateRoot` types do not exist yet. + +### Task 8: AggregateRoot — Implement + +**Files:** +- Create: `Src/RCommon.Entities/IAggregateRoot.cs` +- Create: `Src/RCommon.Entities/AggregateRoot.cs` + +- [ ] **Step 19: Create IAggregateRoot.cs** + +```csharp +// Src/RCommon.Entities/IAggregateRoot.cs +namespace RCommon.Entities +{ + /// + /// Non-generic marker interface for aggregate roots. + /// Useful for infrastructure scenarios such as repository filtering, middleware, and generic constraints. + /// + public interface IAggregateRoot : IBusinessEntity + { + /// + /// The version number used for optimistic concurrency control. + /// + int Version { get; } + + /// + /// The collection of domain events raised by this aggregate that have not yet been dispatched. + /// + IReadOnlyCollection DomainEvents { get; } + } + + /// + /// Generic interface for aggregate roots in the domain model. + /// Extends IBusinessEntity to maintain compatibility with existing repository and event tracking infrastructure. + /// Note: The IEquatable constraint is stricter than IBusinessEntity<TKey> — this is intentional + /// because aggregate roots require identity equality for consistency guarantees. + /// + public interface IAggregateRoot : IAggregateRoot, IBusinessEntity + where TKey : IEquatable + { + } +} +``` + +- [ ] **Step 20: Create AggregateRoot.cs** + +```csharp +// Src/RCommon.Entities/AggregateRoot.cs +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace RCommon.Entities +{ + /// + /// Abstract base class for aggregate roots. Extends BusinessEntity to reuse event tracking, + /// key support, and entity equality. Adds versioning for optimistic concurrency and typed + /// domain event methods. + /// + /// The type of the aggregate's identity. + [Serializable] + public abstract class AggregateRoot : BusinessEntity, IAggregateRoot + where TKey : IEquatable + { + private readonly List _domainEvents = new(); + + /// + /// Version number for optimistic concurrency control. Incremented via . + /// Decorated with [ConcurrencyCheck] to signal ORM-level concurrency checking. + /// + [ConcurrencyCheck] + public virtual int Version { get; protected set; } + + /// + /// Returns the domain events that have been raised by this aggregate but not yet dispatched. + /// + [NotMapped] + public IReadOnlyCollection DomainEvents + => _domainEvents.AsReadOnly(); + + /// + /// Raises a domain event on this aggregate. The event is added to both the DomainEvents + /// collection and the base LocalEvents collection for dispatch via the event tracking pipeline. + /// + protected void AddDomainEvent(IDomainEvent domainEvent) + { + _domainEvents.Add(domainEvent); + AddLocalEvent(domainEvent); + } + + /// + /// Removes a previously raised domain event before it has been dispatched. + /// + protected void RemoveDomainEvent(IDomainEvent domainEvent) + { + _domainEvents.Remove(domainEvent); + RemoveLocalEvent(domainEvent); + } + + /// + /// Clears all pending domain events from this aggregate. + /// + public void ClearDomainEvents() + { + _domainEvents.Clear(); + ClearLocalEvents(); + } + + /// + /// Increments the version number for optimistic concurrency control. + /// Call this when the aggregate's state changes. + /// Note: This is not thread-safe. Aggregates are designed for single-threaded access. + /// + protected void IncrementVersion() + => Version++; + } +} +``` + +- [ ] **Step 21: Run tests to verify they pass** + +Run: `dotnet test Tests/RCommon.Entities.Tests/ --filter "FullyQualifiedName~AggregateRootTests" --no-restore -v quiet` +Expected: All 21 tests PASS. + +- [ ] **Step 22: Commit** + +```bash +git add Src/RCommon.Entities/IAggregateRoot.cs Src/RCommon.Entities/AggregateRoot.cs Tests/RCommon.Entities.Tests/AggregateRootTests.cs +git commit -m "feat: add AggregateRoot and IAggregateRoot interfaces + +Extends BusinessEntity with domain event management, versioning, +and optimistic concurrency. Domain events flow through existing +IEntityEventTracker pipeline via AddLocalEvent delegation." +``` + +--- + +## Chunk 5: Full Test Suite Verification + +### Task 9: Run all existing tests to confirm no regressions + +- [ ] **Step 23: Run the full RCommon.Entities.Tests suite** + +Run: `dotnet test Tests/RCommon.Entities.Tests/ -v quiet` +Expected: All tests pass (existing + new). Zero failures. + +- [ ] **Step 24: Build the entire solution to verify no compilation errors** + +Run: `dotnet build Src/RCommon.sln --no-restore -v quiet` +Expected: Build succeeded. 0 errors. + +- [ ] **Step 25: Final commit (if any formatting/cleanup needed)** + +Only commit if the build or tests required any adjustments. If everything passed cleanly, skip this step. diff --git a/docs/superpowers/plans/2026-03-17-ddd-infrastructure.md b/docs/superpowers/plans/2026-03-17-ddd-infrastructure.md new file mode 100644 index 00000000..023f8d51 --- /dev/null +++ b/docs/superpowers/plans/2026-03-17-ddd-infrastructure.md @@ -0,0 +1,1614 @@ +# DDD Infrastructure Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Implement IAggregateRepository, automatic domain event dispatch, read-model repositories, and saga/state machine infrastructure for RCommon's DDD support. + +**Architecture:** Four layered capabilities built bottom-up: (1) IAggregateRepository with compile-time aggregate enforcement and ORM implementations, (2) UnitOfWork post-commit event dispatch, (3) IReadModelRepository for CQRS query-side with IPagedResult, (4) IStateMachine abstraction + ISaga orchestration with ISagaStore persistence. Each part is independently testable and builds on existing repository/event infrastructure. + +**Tech Stack:** C# (.NET 8/9/10 multi-target), xUnit, FluentAssertions, Moq, EF Core, Dapper/Dommel, Linq2Db, MediatR + +**Spec:** `docs/superpowers/specs/2026-03-17-aggregate-repository-design.md` + +**Solution:** `Src/RCommon.sln` + +**Important:** Do NOT commit after implementation steps. The user will commit manually. + +--- + +## File Structure + +### Part 1: Aggregate Repository +| File | Action | Responsibility | +|------|--------|----------------| +| `Src/RCommon.Persistence/Crud/IAggregateRepository.cs` | Create | Interface with DDD constraints | +| `Src/RCommon.EfCore/Crud/EFCoreAggregateRepository.cs` | Create | EF Core implementation | +| `Src/RCommon.Dapper/Crud/DapperAggregateRepository.cs` | Create | Dapper implementation | +| `Src/RCommon.Linq2Db/Crud/Linq2DbAggregateRepository.cs` | Create | Linq2Db implementation | +| `Src/RCommon.EfCore/EFCorePerisistenceBuilder.cs` | Modify | Add open-generic DI registration | +| `Src/RCommon.Dapper/DapperPersistenceBuilder.cs` | Modify | Add open-generic DI registration | +| `Src/RCommon.Linq2Db/Linq2DbPersistenceBuilder.cs` | Modify | Add open-generic DI registration | +| `Tests/RCommon.Persistence.Tests/IAggregateRepositoryTests.cs` | Create | Interface constraint tests | + +### Part 2: Domain Event Dispatch +| File | Action | Responsibility | +|------|--------|----------------| +| `Src/RCommon.Persistence/Transactions/IUnitOfWork.cs` | Modify | Add CommitAsync, mark Commit obsolete | +| `Src/RCommon.Persistence/Transactions/UnitOfWork.cs` | Modify | Implement CommitAsync with post-commit dispatch | +| `Src/RCommon.Mediatr/Behaviors/UnitOfWorkBehavior.cs` | Modify | Migrate to CommitAsync | +| `Tests/RCommon.Persistence.Tests/UnitOfWorkCommitAsyncTests.cs` | Create | CommitAsync event dispatch tests | + +### Part 3: Read-Model Repositories +| File | Action | Responsibility | +|------|--------|----------------| +| `Src/RCommon.Models/IPagedResult.cs` | Create | Paged result interface | +| `Src/RCommon.Models/PagedResult.cs` | Create | Paged result implementation | +| `Src/RCommon.Persistence/IReadModel.cs` | Create | Marker interface | +| `Src/RCommon.Persistence/Crud/IReadModelRepository.cs` | Create | Read-model repository interface | +| `Src/RCommon.EfCore/Crud/EFCoreReadModelRepository.cs` | Create | EF Core read-model implementation | +| `Src/RCommon.Dapper/Crud/DapperReadModelRepository.cs` | Create | Dapper read-model implementation | +| `Src/RCommon.Linq2Db/Crud/Linq2DbReadModelRepository.cs` | Create | Linq2Db read-model implementation | +| `Tests/RCommon.Models.Tests/PagedResultTests.cs` | Create | PagedResult unit tests | +| `Tests/RCommon.Persistence.Tests/IReadModelRepositoryTests.cs` | Create | Interface constraint tests | + +### Part 4: State Machines + Sagas +| File | Action | Responsibility | +|------|--------|----------------| +| `Src/RCommon.Core/StateMachines/IStateMachine.cs` | Create | State machine abstraction | +| `Src/RCommon.Core/StateMachines/IStateMachineConfigurator.cs` | Create | Fluent configuration builder | +| `Src/RCommon.Core/StateMachines/IStateConfigurator.cs` | Create | Per-state configuration | +| `Src/RCommon.Persistence/Sagas/SagaState.cs` | Create | Saga state base class | +| `Src/RCommon.Persistence/Sagas/ISaga.cs` | Create | Saga orchestrator interface | +| `Src/RCommon.Persistence/Sagas/SagaOrchestrator.cs` | Create | Abstract orchestrator base | +| `Src/RCommon.Persistence/Sagas/ISagaStore.cs` | Create | Saga persistence interface | +| `Src/RCommon.Persistence/Sagas/InMemorySagaStore.cs` | Create | In-memory saga store | +| `Src/RCommon.EfCore/Sagas/EFCoreSagaStore.cs` | Create | EF Core saga store | +| `Src/RCommon.Dapper/Sagas/DapperSagaStore.cs` | Create | Dapper saga store | +| `Src/RCommon.Linq2Db/Sagas/Linq2DbSagaStore.cs` | Create | Linq2Db saga store | +| `Tests/RCommon.Core.Tests/StateMachineInterfaceTests.cs` | Create | Interface shape tests | +| `Tests/RCommon.Persistence.Tests/SagaOrchestratorTests.cs` | Create | Orchestrator unit tests | +| `Tests/RCommon.Persistence.Tests/InMemorySagaStoreTests.cs` | Create | In-memory store tests | + +--- + +## Chunk 1: Aggregate Repository + Domain Event Dispatch + +### Task 1: IAggregateRepository Interface + +**Files:** +- Create: `Src/RCommon.Persistence/Crud/IAggregateRepository.cs` +- Test: `Tests/RCommon.Persistence.Tests/IAggregateRepositoryTests.cs` + +**Context:** The interface constrains `TAggregate` to `IAggregateRoot` (defined in `Src/RCommon.Entities/IAggregateRoot.cs`). It inherits `INamedDataSource` (defined in `Src/RCommon.Persistence/INamedDataSource.cs`) for multi-database targeting. It does NOT inherit from any existing repository interface. + +- [ ] **Step 1: Write the interface constraint test** + +```csharp +// Tests/RCommon.Persistence.Tests/IAggregateRepositoryTests.cs +using System; +using System.Reflection; +using FluentAssertions; +using RCommon.Entities; +using RCommon.Persistence.Crud; +using Xunit; + +namespace RCommon.Persistence.Tests; + +public class IAggregateRepositoryTests +{ + [Fact] + public void Interface_Has_IAggregateRoot_Constraint_On_TAggregate() + { + var type = typeof(IAggregateRepository<,>); + var genericArgs = type.GetGenericArguments(); + var tAggregate = genericArgs[0]; + var constraints = tAggregate.GetGenericParameterConstraints(); + + constraints.Should().Contain(t => t.IsGenericType + && t.GetGenericTypeDefinition() == typeof(IAggregateRoot<>), + "TAggregate must be constrained to IAggregateRoot"); + } + + [Fact] + public void Interface_Has_IEquatable_Constraint_On_TKey() + { + var type = typeof(IAggregateRepository<,>); + var genericArgs = type.GetGenericArguments(); + var tKey = genericArgs[1]; + var constraints = tKey.GetGenericParameterConstraints(); + + constraints.Should().Contain(t => t.IsGenericType + && t.GetGenericTypeDefinition() == typeof(IEquatable<>), + "TKey must be constrained to IEquatable"); + } + + [Fact] + public void Interface_Inherits_INamedDataSource() + { + var type = typeof(IAggregateRepository<,>); + type.GetInterfaces().Should().Contain(typeof(INamedDataSource)); + } + + [Fact] + public void Interface_Does_Not_Inherit_ILinqRepository() + { + var type = typeof(IAggregateRepository<,>); + var interfaces = type.GetInterfaces(); + interfaces.Should().NotContain(i => i.Name.Contains("ILinqRepository")); + interfaces.Should().NotContain(i => i.Name.Contains("IGraphRepository")); + interfaces.Should().NotContain(i => i.Name.Contains("IReadOnlyRepository")); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `dotnet test Tests/RCommon.Persistence.Tests/ --filter "FullyQualifiedName~IAggregateRepositoryTests" -v minimal` +Expected: FAIL — `IAggregateRepository<,>` type does not exist yet. + +- [ ] **Step 3: Create the IAggregateRepository interface** + +```csharp +// Src/RCommon.Persistence/Crud/IAggregateRepository.cs +using System; +using System.Linq.Expressions; +using System.Threading; +using System.Threading.Tasks; +using RCommon.Entities; + +namespace RCommon.Persistence.Crud; + +/// +/// DDD-constrained repository for aggregate roots. Provides only aggregate-appropriate +/// operations: load by ID, find by specification, existence check, add, update, delete, +/// and eager loading. Does not expose IQueryable or collection queries. +/// +public interface IAggregateRepository : INamedDataSource + where TAggregate : class, IAggregateRoot + where TKey : IEquatable +{ + Task GetByIdAsync(TKey id, CancellationToken cancellationToken = default); + Task FindAsync(ISpecification specification, CancellationToken cancellationToken = default); + Task ExistsAsync(TKey id, CancellationToken cancellationToken = default); + + Task AddAsync(TAggregate aggregate, CancellationToken cancellationToken = default); + Task UpdateAsync(TAggregate aggregate, CancellationToken cancellationToken = default); + Task DeleteAsync(TAggregate aggregate, CancellationToken cancellationToken = default); + + IAggregateRepository Include( + Expression> path); + IAggregateRepository ThenInclude( + Expression> path); +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `dotnet test Tests/RCommon.Persistence.Tests/ --filter "FullyQualifiedName~IAggregateRepositoryTests" -v minimal` +Expected: All 4 tests PASS. + +- [ ] **Step 5: Verify solution builds** + +Run: `dotnet build Src/RCommon.sln --no-restore -v minimal` +Expected: Build succeeded, 0 errors. + +--- + +### Task 2: EFCore Aggregate Repository + +**Files:** +- Create: `Src/RCommon.EfCore/Crud/EFCoreAggregateRepository.cs` +- Modify: `Src/RCommon.EfCore/EFCorePerisistenceBuilder.cs` + +**Context:** Inherits from `GraphRepositoryBase` (in `Src/RCommon.Persistence/Crud/GraphRepositoryBase.cs`) for infrastructure reuse. The constructor signature matches `EFCoreRepository` exactly: `IDataStoreFactory`, `ILoggerFactory`, `IEntityEventTracker`, `IOptions`, `ITenantIdAccessor`. Refer to `Src/RCommon.EfCore/Crud/EFCoreRepository.cs` for all implementation patterns (ObjectSet, ObjectContext, FilteredRepositoryQuery, SaveAsync, Include chains, soft-delete). + +- [ ] **Step 1: Create EFCoreAggregateRepository** + +```csharp +// Src/RCommon.EfCore/Crud/EFCoreAggregateRepository.cs +using System; +using System.Linq; +using System.Linq.Expressions; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Query; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using RCommon.Entities; +using RCommon.Persistence; +using RCommon.Persistence.Crud; +using RCommon.Security.Claims; + +namespace RCommon.Persistence.EFCore.Crud; + +public class EFCoreAggregateRepository + : GraphRepositoryBase, IAggregateRepository + where TAggregate : class, IAggregateRoot + where TKey : IEquatable +{ + private IQueryable? _repositoryQuery; + private IIncludableQueryable? _includableQueryable; + private readonly IDataStoreFactory _dataStoreFactory; + + public EFCoreAggregateRepository( + IDataStoreFactory dataStoreFactory, + ILoggerFactory loggerFactory, + IEntityEventTracker eventTracker, + IOptions defaultDataStoreOptions, + ITenantIdAccessor tenantIdAccessor) + : base(dataStoreFactory, eventTracker, defaultDataStoreOptions, tenantIdAccessor) + { + _dataStoreFactory = dataStoreFactory; + Logger = loggerFactory.CreateLogger(GetType().Name); + } + + // -- Implement all abstract members from GraphRepositoryBase/LinqRepositoryBase -- + // These delegate to the EFCore DbContext, following the same patterns as EFCoreRepository. + // Refer to Src/RCommon.EfCore/Crud/EFCoreRepository.cs for the full implementation of each. + // Key members to implement: + // - ObjectSet (DbSet), ObjectContext (RCommonDbContext) + // - RepositoryQuery, FindQuery overloads, FindCore + // - AddAsync, AddRangeAsync, UpdateAsync, DeleteAsync, DeleteManyAsync overloads + // - Include (IEagerLoadableQueryable), ThenInclude (IEagerLoadableQueryable) + // - Tracking property, SaveAsync + // - GetCountAsync, GetTotalCountAsync, AnyAsync, FindAsync(pk), FindSingleOrDefaultAsync + + // -- IAggregateRepository explicit interface implementation -- + + async Task IAggregateRepository.GetByIdAsync( + TKey id, CancellationToken cancellationToken) + { + return await FilteredRepositoryQuery + .FirstOrDefaultAsync(e => e.Id.Equals(id), cancellationToken) + .ConfigureAwait(false); + } + + async Task IAggregateRepository.FindAsync( + ISpecification specification, CancellationToken cancellationToken) + { + return await FilteredRepositoryQuery + .Where(specification.Predicate) + .FirstOrDefaultAsync(cancellationToken) + .ConfigureAwait(false); + } + + async Task IAggregateRepository.ExistsAsync( + TKey id, CancellationToken cancellationToken) + { + return await FilteredRepositoryQuery + .AnyAsync(e => e.Id.Equals(id), cancellationToken) + .ConfigureAwait(false); + } + + async Task IAggregateRepository.AddAsync( + TAggregate aggregate, CancellationToken cancellationToken) + { + EventTracker.AddEntity(aggregate); + await ObjectSet.AddAsync(aggregate, cancellationToken).ConfigureAwait(false); + await SaveAsync(cancellationToken).ConfigureAwait(false); + } + + async Task IAggregateRepository.UpdateAsync( + TAggregate aggregate, CancellationToken cancellationToken) + { + EventTracker.AddEntity(aggregate); + ObjectSet.Update(aggregate); + await SaveAsync(cancellationToken).ConfigureAwait(false); + } + + async Task IAggregateRepository.DeleteAsync( + TAggregate aggregate, CancellationToken cancellationToken) + { + EventTracker.AddEntity(aggregate); + if (SoftDeleteHelper.IsSoftDeletable()) + { + SoftDeleteHelper.MarkAsDeleted(aggregate); + ObjectSet.Update(aggregate); + } + else + { + ObjectSet.Remove(aggregate); + } + await SaveAsync(cancellationToken).ConfigureAwait(false); + } + + IAggregateRepository IAggregateRepository.Include( + Expression> path) + { + // Build the include chain, then return this for fluent chaining + Include(Expression.Lambda>( + Expression.Convert(path.Body, typeof(object)), path.Parameters)); + return this; + } + + IAggregateRepository IAggregateRepository.ThenInclude( + Expression> path) + { + // Delegate to base ThenInclude and return this + ThenInclude(Expression.Lambda>( + path.Body, Expression.Parameter(typeof(object), path.Parameters[0].Name))); + return this; + } + + // Note: The full class must also implement all abstract members inherited from + // GraphRepositoryBase → LinqRepositoryBase. Copy the implementation patterns + // from EFCoreRepository.cs (ObjectSet, ObjectContext, RepositoryQuery, FindQuery, + // FindCore, SaveAsync, Tracking, all Add/Update/Delete/Find overloads, Include/ThenInclude). +} +``` + +**Implementation note:** The concrete class is large because `GraphRepositoryBase` has ~25 abstract members. Copy the implementation from `EFCoreRepository.cs` for all inherited abstract members. The IAggregateRepository methods above are the *new* explicit interface implementations. The key difference from `EFCoreRepository` is the `IAggregateRoot` constraint and the explicit interface implementations. + +- [ ] **Step 2: Add DI registration to EFCorePerisistenceBuilder** + +In `Src/RCommon.EfCore/EFCorePerisistenceBuilder.cs`, add this line in the constructor after the existing `IGraphRepository<>` registration: + +```csharp +services.AddTransient(typeof(IAggregateRepository<,>), typeof(EFCoreAggregateRepository<,>)); +``` + +You'll need to add `using RCommon.Persistence.Crud;` if not already present. + +- [ ] **Step 3: Verify solution builds** + +Run: `dotnet build Src/RCommon.sln --no-restore -v minimal` +Expected: Build succeeded, 0 errors. If there are errors from missing abstract member implementations, implement them following `EFCoreRepository.cs` patterns. + +--- + +### Task 3: Dapper Aggregate Repository + +**Files:** +- Create: `Src/RCommon.Dapper/Crud/DapperAggregateRepository.cs` +- Modify: `Src/RCommon.Dapper/DapperPersistenceBuilder.cs` + +**Context:** Inherits from `SqlRepositoryBase` (in `Src/RCommon.Persistence/Crud/SqlRepositoryBase.cs`). Constructor matches `DapperRepository`: `IDataStoreFactory`, `ILoggerFactory`, `IEntityEventTracker`, `IOptions`, `ITenantIdAccessor`. Uses Dommel extension methods for CRUD. Refer to `Src/RCommon.Dapper/Crud/DapperRepository.cs` for all patterns. **Namespace:** `RCommon.Persistence.Dapper.Crud` (matching `DapperRepository`). + +- [ ] **Step 1: Create DapperAggregateRepository** + +Follow the same structure as EFCoreAggregateRepository but using Dommel patterns from DapperRepository.cs. Use namespace `RCommon.Persistence.Dapper.Crud`: +- `GetByIdAsync` → `db.GetAsync(id)` +- `FindAsync` → `db.SelectAsync(spec.Predicate).FirstOrDefault()` +- `ExistsAsync` → `db.GetAsync(id) != null` +- `AddAsync` → `db.InsertAsync(aggregate)` + `EventTracker.AddEntity(aggregate)` +- `UpdateAsync` → `db.UpdateAsync(aggregate)` + `EventTracker.AddEntity(aggregate)` +- `DeleteAsync` → soft-delete check + `db.DeleteAsync(aggregate)` + `EventTracker.AddEntity(aggregate)` +- `Include/ThenInclude` → no-op, return `this` + +All operations use the `await using (var db = DataStore.GetDbConnection())` try-finally pattern from DapperRepository. + +- [ ] **Step 2: Add DI registration to DapperPersistenceBuilder** + +In `Src/RCommon.Dapper/DapperPersistenceBuilder.cs` constructor, add: + +```csharp +services.AddTransient(typeof(IAggregateRepository<,>), typeof(DapperAggregateRepository<,>)); +``` + +- [ ] **Step 3: Verify solution builds** + +Run: `dotnet build Src/RCommon.sln --no-restore -v minimal` + +--- + +### Task 4: Linq2Db Aggregate Repository + +**Files:** +- Create: `Src/RCommon.Linq2Db/Crud/Linq2DbAggregateRepository.cs` +- Modify: `Src/RCommon.Linq2Db/Linq2DbPersistenceBuilder.cs` + +**Context:** Inherits from `LinqRepositoryBase`. Constructor matches `Linq2DbRepository`. Uses Linq2Db's `DataConnection` and `ITable`. Refer to `Src/RCommon.Linq2Db/Crud/Linq2DbRepository.cs` for all patterns. **Namespace:** `RCommon.Persistence.Linq2Db.Crud` (matching `Linq2DbRepository`). + +- [ ] **Step 1: Create Linq2DbAggregateRepository** + +Follow patterns from Linq2DbRepository.cs. Use namespace `RCommon.Persistence.Linq2Db.Crud`: +- `GetByIdAsync` → `Table.FirstOrDefaultAsync(e => e.Id.Equals(id))` +- `FindAsync` → `FilteredRepositoryQuery.Where(spec.Predicate).FirstOrDefaultAsync()` +- `ExistsAsync` → `FilteredRepositoryQuery.AnyAsync(e => e.Id.Equals(id))` +- `AddAsync` → `DataConnection.InsertAsync(aggregate)` + `EventTracker.AddEntity(aggregate)` +- `UpdateAsync` → `DataConnection.UpdateAsync(aggregate)` + `EventTracker.AddEntity(aggregate)` +- `DeleteAsync` → soft-delete check + `DataConnection.DeleteAsync(aggregate)` + `EventTracker.AddEntity(aggregate)` +- `Include` → `RepositoryQuery.LoadWith(path)`, return `this` +- `ThenInclude` → `_includableQueryable.ThenLoad(path)`, return `this` + +- [ ] **Step 2: Add DI registration to Linq2DbPersistenceBuilder** + +In `Src/RCommon.Linq2Db/Linq2DbPersistenceBuilder.cs` constructor, add: + +```csharp +services.AddTransient(typeof(IAggregateRepository<,>), typeof(Linq2DbAggregateRepository<,>)); +``` + +- [ ] **Step 3: Verify solution builds** + +Run: `dotnet build Src/RCommon.sln --no-restore -v minimal` + +--- + +### Task 5: IUnitOfWork CommitAsync + Event Dispatch + +**Files:** +- Modify: `Src/RCommon.Persistence/Transactions/IUnitOfWork.cs` +- Modify: `Src/RCommon.Persistence/Transactions/UnitOfWork.cs` +- Test: `Tests/RCommon.Persistence.Tests/UnitOfWorkCommitAsyncTests.cs` + +**Context:** `IUnitOfWork` is at `Src/RCommon.Persistence/Transactions/IUnitOfWork.cs`. `UnitOfWork` is at `Src/RCommon.Persistence/Transactions/UnitOfWork.cs`. The existing `Commit()` calls `TransactionScope.Complete()`. The new `CommitAsync()` also disposes the scope (actual commit) then dispatches events via `IEntityEventTracker.EmitTransactionalEventsAsync()`. `IEntityEventTracker` is in `Src/RCommon.Entities/IEntityEventTracker.cs`. `UnitOfWorkFactory` at `Src/RCommon.Persistence/Transactions/UnitOfWorkFactory.cs` creates instances via `_serviceProvider.GetService()`. + +- [ ] **Step 1: Write CommitAsync tests** + +```csharp +// Tests/RCommon.Persistence.Tests/UnitOfWorkCommitAsyncTests.cs +using System; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Moq; +using RCommon.Entities; +using RCommon.EventHandling; +using RCommon.Persistence.Transactions; +using Xunit; + +namespace RCommon.Persistence.Tests; + +public class UnitOfWorkCommitAsyncTests +{ + private readonly Mock> _mockLogger; + private readonly Mock _mockGuidGenerator; + private readonly Mock> _mockSettings; + private readonly UnitOfWorkSettings _settings; + + public UnitOfWorkCommitAsyncTests() + { + _mockLogger = new Mock>(); + _mockGuidGenerator = new Mock(); + _mockGuidGenerator.Setup(g => g.Create()).Returns(Guid.NewGuid()); + _settings = new UnitOfWorkSettings + { + DefaultIsolation = System.Transactions.IsolationLevel.ReadCommitted, + AutoCompleteScope = false + }; + _mockSettings = new Mock>(); + _mockSettings.Setup(s => s.Value).Returns(_settings); + } + + [Fact] + public async Task CommitAsync_Without_Tracker_Completes_Successfully() + { + using var uow = new UnitOfWork(_mockLogger.Object, _mockGuidGenerator.Object, _mockSettings.Object); + + await uow.CommitAsync(); + + uow.State.Should().Be(UnitOfWorkState.Completed); + } + + [Fact] + public async Task CommitAsync_With_Tracker_Dispatches_Events() + { + var mockTracker = new Mock(); + mockTracker.Setup(t => t.EmitTransactionalEventsAsync()).ReturnsAsync(true); + + using var uow = new UnitOfWork( + _mockLogger.Object, _mockGuidGenerator.Object, _mockSettings.Object, mockTracker.Object); + + await uow.CommitAsync(); + + mockTracker.Verify(t => t.EmitTransactionalEventsAsync(), Times.Once); + } + + [Fact] + public async Task CommitAsync_Logs_Warning_When_Dispatch_Returns_False() + { + var mockTracker = new Mock(); + mockTracker.Setup(t => t.EmitTransactionalEventsAsync()).ReturnsAsync(false); + + using var uow = new UnitOfWork( + _mockLogger.Object, _mockGuidGenerator.Object, _mockSettings.Object, mockTracker.Object); + + await uow.CommitAsync(); + + // Verify warning was logged (the LogWarning call) + _mockLogger.Verify( + x => x.Log( + LogLevel.Warning, + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny>()), + Times.Once); + } + + [Fact] + public void Commit_Obsolete_Still_Works_Without_Dispatch() + { + var mockTracker = new Mock(); + + using var uow = new UnitOfWork( + _mockLogger.Object, _mockGuidGenerator.Object, _mockSettings.Object, mockTracker.Object); + + #pragma warning disable CS0618 // Obsolete + uow.Commit(); + #pragma warning restore CS0618 + + uow.State.Should().Be(UnitOfWorkState.Completed); + mockTracker.Verify(t => t.EmitTransactionalEventsAsync(), Times.Never); + } + + [Fact] + public async Task CommitAsync_On_Disposed_UoW_Throws_ObjectDisposedException() + { + var uow = new UnitOfWork(_mockLogger.Object, _mockGuidGenerator.Object, _mockSettings.Object); + uow.Dispose(); + + var act = () => uow.CommitAsync(); + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task CommitAsync_On_Already_Completed_UoW_Throws_UnitOfWorkException() + { + using var uow = new UnitOfWork(_mockLogger.Object, _mockGuidGenerator.Object, _mockSettings.Object); + await uow.CommitAsync(); // first commit + + var act = () => uow.CommitAsync(); + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task CommitAsync_Then_Dispose_Does_Not_Double_Dispose_TransactionScope() + { + // CommitAsync disposes TransactionScope internally; Dispose() must not throw + var uow = new UnitOfWork(_mockLogger.Object, _mockGuidGenerator.Object, _mockSettings.Object); + + await uow.CommitAsync(); + + var act = () => { uow.Dispose(); }; + act.Should().NotThrow("Dispose after CommitAsync must be safe (no double-dispose)"); + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `dotnet test Tests/RCommon.Persistence.Tests/ --filter "FullyQualifiedName~UnitOfWorkCommitAsyncTests" -v minimal` +Expected: FAIL — `CommitAsync` method does not exist yet. + +- [ ] **Step 3: Add CommitAsync to IUnitOfWork** + +In `Src/RCommon.Persistence/Transactions/IUnitOfWork.cs`, add above the existing `Commit()` method: + +```csharp +Task CommitAsync(CancellationToken cancellationToken = default); +``` + +Mark the existing `Commit()` with `[Obsolete("Use CommitAsync instead for automatic domain event dispatch.")]`. Add `using System.Threading;` and `using System.Threading.Tasks;` if not present. + +- [ ] **Step 4: Implement CommitAsync in UnitOfWork** + +In `Src/RCommon.Persistence/Transactions/UnitOfWork.cs`: + +1. Add field: `private readonly IEntityEventTracker? _eventTracker;` and `private bool _transactionScopeDisposed;` +2. Add `IEntityEventTracker? eventTracker = null` as the last parameter to both constructor overloads. Store it: `_eventTracker = eventTracker;` +3. Add the `using RCommon.Entities;` import. +4. Add the `CommitAsync` method (see spec lines 267-298 for exact implementation). +5. Mark existing `Commit()` with `[Obsolete]` attribute. +6. In `Dispose(bool disposing)`, find the `finally` block (existing line 131) where `_transactionScope.Dispose()` is called. Wrap that call with `if (!_transactionScopeDisposed)`. This prevents double-disposal when `CommitAsync` has already disposed the scope — note the `return` at line 116 is inside a `try`, so the `finally` block still executes. + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `dotnet test Tests/RCommon.Persistence.Tests/ --filter "FullyQualifiedName~UnitOfWorkCommitAsyncTests" -v minimal` +Expected: All 7 tests PASS. + +- [ ] **Step 6: Verify solution builds** + +Run: `dotnet build Src/RCommon.sln --no-restore -v minimal` + +--- + +### Task 6: UnitOfWorkBehavior Migration + +**Files:** +- Modify: `Src/RCommon.Mediatr/Behaviors/UnitOfWorkBehavior.cs` + +**Context:** Both `UnitOfWorkRequestBehavior` and `UnitOfWorkRequestWithResponseBehavior` currently call `unitOfWork.Commit()` synchronously inside an async `Handle` method. Change to `await unitOfWork.CommitAsync(cancellationToken).ConfigureAwait(false)`. + +- [ ] **Step 1: Update UnitOfWorkRequestBehavior** + +In `Src/RCommon.Mediatr/Behaviors/UnitOfWorkBehavior.cs`, in the `UnitOfWorkRequestBehavior.Handle` method, replace: + +```csharp +unitOfWork.Commit(); +``` + +with: + +```csharp +await unitOfWork.CommitAsync(cancellationToken).ConfigureAwait(false); +``` + +- [ ] **Step 2: Update UnitOfWorkRequestWithResponseBehavior** + +Same change in the second class `UnitOfWorkRequestWithResponseBehavior.Handle`. + +- [ ] **Step 3: Verify solution builds** + +Run: `dotnet build Src/RCommon.sln --no-restore -v minimal` + +- [ ] **Step 4: Run existing MediatR tests** + +Run: `dotnet test Tests/RCommon.Mediatr.Tests/ -v minimal` +Expected: All existing tests PASS (backward compatibility preserved). + +--- + +## Chunk 2: Read-Model Repositories + +**Prerequisite:** Before Task 8, add a project reference from `RCommon.Persistence` to `RCommon.Models`. In `Src/RCommon.Persistence/RCommon.Persistence.csproj`, add inside the `` with other project references: + +```xml + +``` + +Then run `dotnet restore Src/RCommon.sln` to update the dependency graph. + +### Task 7: IPagedResult + PagedResult + +**Files:** +- Create: `Src/RCommon.Models/IPagedResult.cs` +- Create: `Src/RCommon.Models/PagedResult.cs` +- Test: `Tests/RCommon.Models.Tests/PagedResultTests.cs` + +**Context:** These go in `RCommon.Models` (namespace `RCommon.Models`). `PagedResult` uses `Guard.Against` from `RCommon.Core` — check if `RCommon.Models` references `RCommon.Core`. If not, use a simple `if` check with `throw` instead. + +- [ ] **Step 1: Write PagedResult tests** + +```csharp +// Tests/RCommon.Models.Tests/PagedResultTests.cs +using System; +using System.Collections.Generic; +using FluentAssertions; +using RCommon.Models; +using Xunit; + +namespace RCommon.Models.Tests; + +public class PagedResultTests +{ + [Fact] + public void Constructor_Sets_Properties() + { + var items = new List { "a", "b", "c" }; + var result = new PagedResult(items, 10, 1, 5); + + result.Items.Should().BeEquivalentTo(items); + result.TotalCount.Should().Be(10); + result.PageNumber.Should().Be(1); + result.PageSize.Should().Be(5); + } + + [Fact] + public void TotalPages_Rounds_Up() + { + var result = new PagedResult(new List(), 11, 1, 5); + result.TotalPages.Should().Be(3); // ceil(11/5) = 3 + } + + [Fact] + public void TotalPages_Exact_Division() + { + var result = new PagedResult(new List(), 10, 1, 5); + result.TotalPages.Should().Be(2); + } + + [Fact] + public void HasNextPage_True_When_Not_Last_Page() + { + var result = new PagedResult(new List(), 10, 1, 5); + result.HasNextPage.Should().BeTrue(); + } + + [Fact] + public void HasNextPage_False_On_Last_Page() + { + var result = new PagedResult(new List(), 10, 2, 5); + result.HasNextPage.Should().BeFalse(); + } + + [Fact] + public void HasPreviousPage_False_On_First_Page() + { + var result = new PagedResult(new List(), 10, 1, 5); + result.HasPreviousPage.Should().BeFalse(); + } + + [Fact] + public void HasPreviousPage_True_On_Page_2() + { + var result = new PagedResult(new List(), 10, 2, 5); + result.HasPreviousPage.Should().BeTrue(); + } + + [Fact] + public void Constructor_Throws_When_PageSize_Zero() + { + var act = () => new PagedResult(new List(), 10, 1, 0); + act.Should().Throw(); + } + + [Fact] + public void Constructor_Throws_When_PageSize_Negative() + { + var act = () => new PagedResult(new List(), 10, 1, -1); + act.Should().Throw(); + } + + [Fact] + public void Empty_Result_Has_Zero_TotalPages() + { + var result = new PagedResult(new List(), 0, 1, 10); + result.TotalPages.Should().Be(0); + result.HasNextPage.Should().BeFalse(); + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `dotnet test Tests/RCommon.Models.Tests/ --filter "FullyQualifiedName~PagedResultTests" -v minimal` +Expected: FAIL — types don't exist yet. + +- [ ] **Step 3: Create IPagedResult** + +```csharp +// Src/RCommon.Models/IPagedResult.cs +using System.Collections.Generic; + +namespace RCommon.Models; + +public interface IPagedResult +{ + IReadOnlyList Items { get; } + long TotalCount { get; } + int PageNumber { get; } + int PageSize { get; } + int TotalPages { get; } + bool HasNextPage { get; } + bool HasPreviousPage { get; } +} +``` + +- [ ] **Step 4: Create PagedResult** + +```csharp +// Src/RCommon.Models/PagedResult.cs +using System; +using System.Collections.Generic; + +namespace RCommon.Models; + +public class PagedResult : IPagedResult +{ + public IReadOnlyList Items { get; } + public long TotalCount { get; } + public int PageNumber { get; } + public int PageSize { get; } + public int TotalPages => PageSize > 0 ? (int)Math.Ceiling((double)TotalCount / PageSize) : 0; + public bool HasNextPage => PageNumber < TotalPages; + public bool HasPreviousPage => PageNumber > 1; + + public PagedResult(IReadOnlyList items, long totalCount, int pageNumber, int pageSize) + { + if (pageSize <= 0) + throw new ArgumentOutOfRangeException(nameof(pageSize), "PageSize must be greater than zero."); + Items = items; + TotalCount = totalCount; + PageNumber = pageNumber; + PageSize = pageSize; + } +} +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `dotnet test Tests/RCommon.Models.Tests/ --filter "FullyQualifiedName~PagedResultTests" -v minimal` +Expected: All 10 tests PASS. + +--- + +### Task 8: IReadModel + IReadModelRepository + +**Files:** +- Create: `Src/RCommon.Persistence/IReadModel.cs` +- Create: `Src/RCommon.Persistence/Crud/IReadModelRepository.cs` +- Test: `Tests/RCommon.Persistence.Tests/IReadModelRepositoryTests.cs` + +- [ ] **Step 1: Write interface constraint tests** + +```csharp +// Tests/RCommon.Persistence.Tests/IReadModelRepositoryTests.cs +using System; +using FluentAssertions; +using RCommon.Persistence; +using RCommon.Persistence.Crud; +using Xunit; + +namespace RCommon.Persistence.Tests; + +public class IReadModelRepositoryTests +{ + [Fact] + public void Interface_Has_IReadModel_Constraint() + { + var type = typeof(IReadModelRepository<>); + var tReadModel = type.GetGenericArguments()[0]; + var constraints = tReadModel.GetGenericParameterConstraints(); + + constraints.Should().Contain(typeof(IReadModel)); + } + + [Fact] + public void Interface_Inherits_INamedDataSource() + { + var type = typeof(IReadModelRepository<>); + type.GetInterfaces().Should().Contain(typeof(INamedDataSource)); + } + + [Fact] + public void Interface_Has_Class_Constraint() + { + var type = typeof(IReadModelRepository<>); + var tReadModel = type.GetGenericArguments()[0]; + var attrs = tReadModel.GenericParameterAttributes; + + attrs.HasFlag(System.Reflection.GenericParameterAttributes.ReferenceTypeConstraint) + .Should().BeTrue(); + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `dotnet test Tests/RCommon.Persistence.Tests/ --filter "FullyQualifiedName~IReadModelRepositoryTests" -v minimal` + +- [ ] **Step 3: Create IReadModel marker** + +```csharp +// Src/RCommon.Persistence/IReadModel.cs +namespace RCommon.Persistence; + +/// +/// Marker interface for read-model/projection types used in CQRS query-side repositories. +/// Read models are optimized for querying and do not participate in domain event tracking. +/// +public interface IReadModel { } +``` + +- [ ] **Step 4: Create IReadModelRepository** + +```csharp +// Src/RCommon.Persistence/Crud/IReadModelRepository.cs +using System; +using System.Collections.Generic; +using System.Linq.Expressions; +using System.Threading; +using System.Threading.Tasks; +using RCommon; +using RCommon.Models; + +namespace RCommon.Persistence.Crud; + +public interface IReadModelRepository : INamedDataSource + where TReadModel : class, IReadModel +{ + Task FindAsync( + ISpecification specification, + CancellationToken cancellationToken = default); + + Task> FindAllAsync( + ISpecification specification, + CancellationToken cancellationToken = default); + + Task> GetPagedAsync( + IPagedSpecification specification, + CancellationToken cancellationToken = default); + + Task GetCountAsync( + ISpecification specification, + CancellationToken cancellationToken = default); + + Task AnyAsync( + ISpecification specification, + CancellationToken cancellationToken = default); + + IReadModelRepository Include( + Expression> path); +} +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `dotnet test Tests/RCommon.Persistence.Tests/ --filter "FullyQualifiedName~IReadModelRepositoryTests" -v minimal` +Expected: All 3 tests PASS. + +--- + +### Task 9: EFCore Read-Model Repository + DI + +**Files:** +- Create: `Src/RCommon.EfCore/Crud/EFCoreReadModelRepository.cs` +- Modify: `Src/RCommon.EfCore/EFCorePerisistenceBuilder.cs` + +**Context:** Uses **composition** (not inheritance from LinqRepositoryBase) because `IReadModel` does not extend `IBusinessEntity`. Wraps `DbContext` + `DbSet` directly. Resolves data store via `IDataStoreFactory`. **Namespace:** `RCommon.Persistence.EFCore.Crud` (matching `EFCoreRepository`). + +- [ ] **Step 1: Create EFCoreReadModelRepository** + +The class wraps `RCommonDbContext` and `DbSet`. It implements `IReadModelRepository`. It uses `IDataStoreFactory` for data store resolution. Read models typically don't use soft-delete/tenant filters. + +Key implementation: +- Constructor: `IDataStoreFactory dataStoreFactory, ILoggerFactory loggerFactory, IOptions defaultDataStoreOptions` +- `FindAsync` → `DbSet.Where(spec.Predicate).FirstOrDefaultAsync()` +- `FindAllAsync` → `DbSet.Where(spec.Predicate).ToListAsync()` +- `GetPagedAsync` → query with `Skip`/`Take` + `CountAsync` wrapped in `PagedResult` +- `GetCountAsync` → `DbSet.Where(spec.Predicate).LongCountAsync()` +- `AnyAsync` → `DbSet.Where(spec.Predicate).AnyAsync()` +- `Include` → `DbSet.Include(path)`, return `this` + +- [ ] **Step 2: Add DI registration** + +In `Src/RCommon.EfCore/EFCorePerisistenceBuilder.cs` constructor, add: + +```csharp +services.AddTransient(typeof(IReadModelRepository<>), typeof(EFCoreReadModelRepository<>)); +``` + +- [ ] **Step 3: Verify solution builds** + +Run: `dotnet build Src/RCommon.sln -v minimal` + +**Note on tests:** Concrete read-model repository implementations require integration tests with real ORM contexts (in-memory DbContext, etc.), which belong in the per-ORM integration test projects. The spec testing strategy (Part 3, items 2-3) calls for `FindAsync`, `FindAllAsync`, `GetPagedAsync`, `GetCountAsync`, and `AnyAsync` tests per ORM. These integration tests should be added to `Tests/RCommon.EfCore.Tests/` when integration test infrastructure is available. For the initial implementation, the interface constraint tests (Task 8) and PagedResult unit tests (Task 7) provide the core coverage. + +--- + +### Task 10: Dapper + Linq2Db Read-Model Repositories + DI + +**Files:** +- Create: `Src/RCommon.Dapper/Crud/DapperReadModelRepository.cs` +- Create: `Src/RCommon.Linq2Db/Crud/Linq2DbReadModelRepository.cs` +- Modify: `Src/RCommon.Dapper/DapperPersistenceBuilder.cs` +- Modify: `Src/RCommon.Linq2Db/Linq2DbPersistenceBuilder.cs` + +- [ ] **Step 1: Create DapperReadModelRepository** + +Uses composition wrapping `IDbConnection` via Dommel. Same query pattern as `DapperRepository` but without event tracking or write operations. **Namespace:** `RCommon.Persistence.Dapper.Crud`. + +- [ ] **Step 2: Create Linq2DbReadModelRepository** + +Uses composition wrapping `IDataContext.GetTable()`. Same query pattern as `Linq2DbRepository` but without event tracking or write operations. **Namespace:** `RCommon.Persistence.Linq2Db.Crud`. + +- [ ] **Step 3: Add DI registrations** + +In `DapperPersistenceBuilder` constructor: +```csharp +services.AddTransient(typeof(IReadModelRepository<>), typeof(DapperReadModelRepository<>)); +``` + +In `Linq2DbPersistenceBuilder` constructor: +```csharp +services.AddTransient(typeof(IReadModelRepository<>), typeof(Linq2DbReadModelRepository<>)); +``` + +- [ ] **Step 4: Verify solution builds** + +Run: `dotnet build Src/RCommon.sln -v minimal` + +**Note on tests:** Same as Task 9 — concrete Dapper/Linq2Db read-model repository tests require integration test infrastructure and should be added to `Tests/RCommon.Dapper.Tests/` and `Tests/RCommon.Linq2Db.Tests/` respectively. + +--- + +## Chunk 3: State Machines + Sagas + +### Task 11: State Machine Interfaces + +**Files:** +- Create: `Src/RCommon.Core/StateMachines/IStateMachine.cs` +- Create: `Src/RCommon.Core/StateMachines/IStateMachineConfigurator.cs` +- Create: `Src/RCommon.Core/StateMachines/IStateConfigurator.cs` +- Test: `Tests/RCommon.Core.Tests/StateMachineInterfaceTests.cs` + +**Context:** These are pure interfaces in `RCommon.Core/StateMachines/` with namespace `RCommon.StateMachines` (RCommon.Core strips `.Core` from namespace via csproj). Constraints are `where TState : struct, Enum` and `where TTrigger : struct, Enum`. + +- [ ] **Step 1: Write interface tests** + +```csharp +// Tests/RCommon.Core.Tests/StateMachineInterfaceTests.cs +using System; +using System.Linq; +using FluentAssertions; +using RCommon.StateMachines; +using Xunit; + +namespace RCommon.Core.Tests; + +public class StateMachineInterfaceTests +{ + [Fact] + public void IStateMachine_Has_Struct_And_Enum_Constraints() + { + var type = typeof(IStateMachine<,>); + var tState = type.GetGenericArguments()[0]; + var tTrigger = type.GetGenericArguments()[1]; + + tState.GenericParameterAttributes.HasFlag( + System.Reflection.GenericParameterAttributes.NotNullableValueTypeConstraint) + .Should().BeTrue("TState must be struct"); + tState.GetGenericParameterConstraints().Should().Contain(typeof(Enum)); + + tTrigger.GenericParameterAttributes.HasFlag( + System.Reflection.GenericParameterAttributes.NotNullableValueTypeConstraint) + .Should().BeTrue("TTrigger must be struct"); + tTrigger.GetGenericParameterConstraints().Should().Contain(typeof(Enum)); + } + + [Fact] + public void IStateMachine_Has_Required_Members() + { + var type = typeof(IStateMachine<,>); + type.GetProperty("CurrentState").Should().NotBeNull(); + type.GetProperty("PermittedTriggers").Should().NotBeNull(); + type.GetMethod("CanFire").Should().NotBeNull(); + type.GetMethods().Where(m => m.Name == "FireAsync").Should().HaveCountGreaterOrEqualTo(2, + "should have FireAsync and FireAsync overloads"); + } + + [Fact] + public void IStateMachineConfigurator_Has_ForState_And_Build() + { + var type = typeof(IStateMachineConfigurator<,>); + type.GetMethod("ForState").Should().NotBeNull(); + type.GetMethod("Build").Should().NotBeNull(); + } + + [Fact] + public void IStateConfigurator_Has_Required_Members() + { + var type = typeof(IStateConfigurator<,>); + type.GetMethod("Permit").Should().NotBeNull(); + type.GetMethod("OnEntry").Should().NotBeNull(); + type.GetMethod("OnExit").Should().NotBeNull(); + type.GetMethod("PermitIf").Should().NotBeNull(); + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `dotnet test Tests/RCommon.Core.Tests/ --filter "FullyQualifiedName~StateMachineInterfaceTests" -v minimal` + +- [ ] **Step 3: Create the three interface files** + +Create `IStateMachine.cs`, `IStateMachineConfigurator.cs`, and `IStateConfigurator.cs` in `Src/RCommon.Core/StateMachines/` with the exact definitions from the spec (lines 545-576). Namespace: `RCommon.StateMachines`. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `dotnet test Tests/RCommon.Core.Tests/ --filter "FullyQualifiedName~StateMachineInterfaceTests" -v minimal` +Expected: All 4 tests PASS. + +--- + +### Task 12: Saga Infrastructure (SagaState, ISaga, ISagaStore, SagaOrchestrator) + +**Files:** +- Create: `Src/RCommon.Persistence/Sagas/SagaState.cs` +- Create: `Src/RCommon.Persistence/Sagas/ISaga.cs` +- Create: `Src/RCommon.Persistence/Sagas/ISagaStore.cs` +- Create: `Src/RCommon.Persistence/Sagas/SagaOrchestrator.cs` +- Test: `Tests/RCommon.Persistence.Tests/SagaOrchestratorTests.cs` + +**Context:** All saga types live in `Src/RCommon.Persistence/Sagas/` with namespace `RCommon.Persistence.Sagas`. `SagaOrchestrator` references `IStateMachineConfigurator` and `IStateMachine` from `RCommon.StateMachines` (in RCommon.Core, which RCommon.Persistence already references). `ISaga.HandleAsync` constrains `TEvent` to `ISerializableEvent` from `RCommon.Models`. + +- [ ] **Step 1: Write SagaOrchestrator tests** + +```csharp +// Tests/RCommon.Persistence.Tests/SagaOrchestratorTests.cs +using System; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Moq; +using RCommon.Models.Events; +using RCommon.Persistence.Sagas; +using RCommon.StateMachines; +using Xunit; + +namespace RCommon.Persistence.Tests; + +// Test enums +public enum TestSagaStep { Initial, StepOne, StepTwo, Completed } +public enum TestSagaTrigger { GoToOne, GoToTwo, Complete } + +// Test saga state +public class TestSagaData : SagaState +{ + public string? Payload { get; set; } +} + +// Test event +public record TestSagaEvent(Guid CorrelationId) : ISerializableEvent; + +// Concrete test saga +public class TestSaga : SagaOrchestrator +{ + public TestSaga( + ISagaStore store, + IStateMachineConfigurator configurator) + : base(store, configurator) { } + + protected override TestSagaStep InitialState => TestSagaStep.Initial; + + protected override void ConfigureStateMachine( + IStateMachineConfigurator configurator) + { + configurator.ForState(TestSagaStep.Initial) + .Permit(TestSagaTrigger.GoToOne, TestSagaStep.StepOne); + configurator.ForState(TestSagaStep.StepOne) + .Permit(TestSagaTrigger.GoToTwo, TestSagaStep.StepTwo); + } + + protected override TestSagaTrigger MapEventToTrigger(TEvent @event) + { + return TestSagaTrigger.GoToOne; + } + + public override Task CompensateAsync(TestSagaData state, CancellationToken ct) + { + state.IsFaulted = true; + state.FaultReason = "Compensated"; + return Task.CompletedTask; + } +} + +public class SagaOrchestratorTests +{ + [Fact] + public void SagaState_Has_Required_Properties() + { + var state = new TestSagaData + { + Id = Guid.NewGuid(), + CorrelationId = "order-123", + StartedAt = DateTimeOffset.UtcNow, + CurrentStep = "Initial", + Version = 1 + }; + + state.Id.Should().NotBeEmpty(); + state.CorrelationId.Should().Be("order-123"); + state.IsCompleted.Should().BeFalse(); + state.IsFaulted.Should().BeFalse(); + } + + [Fact] + public async Task HandleAsync_With_Null_CurrentStep_Uses_InitialState() + { + var mockStore = new Mock>(); + var mockConfigurator = new Mock>(); + var mockStateConfig = new Mock>(); + var mockMachine = new Mock>(); + + mockConfigurator.Setup(c => c.ForState(It.IsAny())) + .Returns(mockStateConfig.Object); + mockStateConfig.Setup(s => s.Permit(It.IsAny(), It.IsAny())) + .Returns(mockStateConfig.Object); + mockConfigurator.Setup(c => c.Build(It.IsAny())) + .Returns(mockMachine.Object); + mockMachine.Setup(m => m.CanFire(It.IsAny())).Returns(true); + mockMachine.Setup(m => m.CurrentState).Returns(TestSagaStep.StepOne); + + var saga = new TestSaga(mockStore.Object, mockConfigurator.Object); + var state = new TestSagaData { Id = Guid.NewGuid(), CurrentStep = null! }; + + await saga.HandleAsync(new TestSagaEvent(Guid.NewGuid()), state, CancellationToken.None); + + // Should have built with InitialState since CurrentStep was null + mockConfigurator.Verify(c => c.Build(TestSagaStep.Initial), Times.AtLeastOnce); + state.CurrentStep.Should().Be("StepOne"); + mockStore.Verify(s => s.SaveAsync(state, It.IsAny()), Times.Once); + } + + [Fact] + public async Task HandleAsync_Invalid_Trigger_Is_Ignored() + { + var mockStore = new Mock>(); + var mockConfigurator = new Mock>(); + var mockStateConfig = new Mock>(); + var mockMachine = new Mock>(); + + mockConfigurator.Setup(c => c.ForState(It.IsAny())) + .Returns(mockStateConfig.Object); + mockStateConfig.Setup(s => s.Permit(It.IsAny(), It.IsAny())) + .Returns(mockStateConfig.Object); + mockConfigurator.Setup(c => c.Build(It.IsAny())) + .Returns(mockMachine.Object); + mockMachine.Setup(m => m.CanFire(It.IsAny())).Returns(false); + + var saga = new TestSaga(mockStore.Object, mockConfigurator.Object); + var state = new TestSagaData { Id = Guid.NewGuid(), CurrentStep = "Initial" }; + + await saga.HandleAsync(new TestSagaEvent(Guid.NewGuid()), state, CancellationToken.None); + + // CanFire returned false, so FireAsync and SaveAsync should NOT be called + mockMachine.Verify(m => m.FireAsync(It.IsAny(), It.IsAny()), Times.Never); + mockStore.Verify(s => s.SaveAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task HandleAsync_With_Known_State_Transitions_Correctly() + { + var mockStore = new Mock>(); + var mockConfigurator = new Mock>(); + var mockStateConfig = new Mock>(); + var mockMachine = new Mock>(); + + mockConfigurator.Setup(c => c.ForState(It.IsAny())) + .Returns(mockStateConfig.Object); + mockStateConfig.Setup(s => s.Permit(It.IsAny(), It.IsAny())) + .Returns(mockStateConfig.Object); + mockConfigurator.Setup(c => c.Build(TestSagaStep.Initial)) + .Returns(mockMachine.Object); + mockMachine.Setup(m => m.CanFire(TestSagaTrigger.GoToOne)).Returns(true); + mockMachine.Setup(m => m.CurrentState).Returns(TestSagaStep.StepOne); + + var saga = new TestSaga(mockStore.Object, mockConfigurator.Object); + var state = new TestSagaData { Id = Guid.NewGuid(), CurrentStep = "Initial" }; + + await saga.HandleAsync(new TestSagaEvent(Guid.NewGuid()), state, CancellationToken.None); + + // Build should be called with the current state (Initial), not just from EnsureConfigured + mockConfigurator.Verify(c => c.Build(TestSagaStep.Initial), Times.AtLeastOnce); + state.CurrentStep.Should().Be("StepOne"); + mockStore.Verify(s => s.SaveAsync(state, It.IsAny()), Times.Once); + } + + [Fact] + public async Task HandleAsync_Called_Twice_Configures_StateMachine_Once() + { + var mockStore = new Mock>(); + var mockConfigurator = new Mock>(); + var mockStateConfig = new Mock>(); + var mockMachine = new Mock>(); + + mockConfigurator.Setup(c => c.ForState(It.IsAny())) + .Returns(mockStateConfig.Object); + mockStateConfig.Setup(s => s.Permit(It.IsAny(), It.IsAny())) + .Returns(mockStateConfig.Object); + mockConfigurator.Setup(c => c.Build(It.IsAny())) + .Returns(mockMachine.Object); + mockMachine.Setup(m => m.CanFire(It.IsAny())).Returns(true); + mockMachine.Setup(m => m.CurrentState).Returns(TestSagaStep.StepOne); + + var saga = new TestSaga(mockStore.Object, mockConfigurator.Object); + var state1 = new TestSagaData { Id = Guid.NewGuid(), CurrentStep = "Initial" }; + var state2 = new TestSagaData { Id = Guid.NewGuid(), CurrentStep = "Initial" }; + + await saga.HandleAsync(new TestSagaEvent(Guid.NewGuid()), state1, CancellationToken.None); + await saga.HandleAsync(new TestSagaEvent(Guid.NewGuid()), state2, CancellationToken.None); + + // ConfigureStateMachine calls ForState — should only happen once (lazy init) + // The TestSaga configures 2 states (Initial, StepOne), so ForState is called exactly 2 times total + mockConfigurator.Verify(c => c.ForState(It.IsAny()), Times.Exactly(2)); + } + + [Fact] + public async Task CompensateAsync_Sets_Fault_State() + { + var mockStore = new Mock>(); + var mockConfigurator = new Mock>(); + + var saga = new TestSaga(mockStore.Object, mockConfigurator.Object); + var state = new TestSagaData { Id = Guid.NewGuid() }; + + await saga.CompensateAsync(state, CancellationToken.None); + + state.IsFaulted.Should().BeTrue(); + state.FaultReason.Should().Be("Compensated"); + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `dotnet test Tests/RCommon.Persistence.Tests/ --filter "FullyQualifiedName~SagaOrchestratorTests" -v minimal` + +- [ ] **Step 3: Create SagaState.cs** + +```csharp +// Src/RCommon.Persistence/Sagas/SagaState.cs +using System; + +namespace RCommon.Persistence.Sagas; + +public abstract class SagaState + where TKey : IEquatable +{ + public TKey Id { get; set; } = default!; + public string CorrelationId { get; set; } = default!; + public DateTimeOffset StartedAt { get; set; } + public DateTimeOffset? CompletedAt { get; set; } + public string CurrentStep { get; set; } = default!; + public bool IsCompleted { get; set; } + public bool IsFaulted { get; set; } + public string? FaultReason { get; set; } + public int Version { get; set; } +} +``` + +- [ ] **Step 4: Create ISaga.cs** + +```csharp +// Src/RCommon.Persistence/Sagas/ISaga.cs +using System; +using System.Threading; +using System.Threading.Tasks; +using RCommon.Models.Events; + +namespace RCommon.Persistence.Sagas; + +public interface ISaga + where TState : SagaState + where TKey : IEquatable +{ + Task HandleAsync(TEvent @event, TState state, CancellationToken ct = default) + where TEvent : ISerializableEvent; + Task CompensateAsync(TState state, CancellationToken ct = default); +} +``` + +- [ ] **Step 5: Create ISagaStore.cs** + +```csharp +// Src/RCommon.Persistence/Sagas/ISagaStore.cs +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace RCommon.Persistence.Sagas; + +public interface ISagaStore + where TState : SagaState + where TKey : IEquatable +{ + Task FindByCorrelationIdAsync(string correlationId, CancellationToken ct = default); + Task GetByIdAsync(TKey id, CancellationToken ct = default); + Task SaveAsync(TState state, CancellationToken ct = default); + Task DeleteAsync(TState state, CancellationToken ct = default); +} +``` + +- [ ] **Step 6: Create SagaOrchestrator.cs** + +Use the exact implementation from the spec (lines 632-710). Namespace: `RCommon.Persistence.Sagas`. References `RCommon.StateMachines` for `IStateMachineConfigurator` and `IStateMachine`. + +- [ ] **Step 7: Run tests to verify they pass** + +Run: `dotnet test Tests/RCommon.Persistence.Tests/ --filter "FullyQualifiedName~SagaOrchestratorTests" -v minimal` +Expected: All 7 tests PASS. + +--- + +### Task 13: InMemorySagaStore + +**Files:** +- Create: `Src/RCommon.Persistence/Sagas/InMemorySagaStore.cs` +- Test: `Tests/RCommon.Persistence.Tests/InMemorySagaStoreTests.cs` + +- [ ] **Step 1: Write InMemorySagaStore tests** + +```csharp +// Tests/RCommon.Persistence.Tests/InMemorySagaStoreTests.cs +using System; +using System.Threading.Tasks; +using FluentAssertions; +using RCommon.Persistence.Sagas; +using Xunit; + +namespace RCommon.Persistence.Tests; + +public class TestSagaState : SagaState +{ + public string? Data { get; set; } +} + +public class InMemorySagaStoreTests +{ + [Fact] + public async Task SaveAsync_And_GetByIdAsync_RoundTrips() + { + var store = new InMemorySagaStore(); + var state = new TestSagaState { Id = Guid.NewGuid(), CorrelationId = "c1", Data = "test" }; + + await store.SaveAsync(state); + + var loaded = await store.GetByIdAsync(state.Id); + loaded.Should().BeSameAs(state); + } + + [Fact] + public async Task FindByCorrelationIdAsync_Returns_Matching_State() + { + var store = new InMemorySagaStore(); + var state = new TestSagaState { Id = Guid.NewGuid(), CorrelationId = "order-456" }; + + await store.SaveAsync(state); + + var found = await store.FindByCorrelationIdAsync("order-456"); + found.Should().BeSameAs(state); + } + + [Fact] + public async Task FindByCorrelationIdAsync_Returns_Null_When_Not_Found() + { + var store = new InMemorySagaStore(); + + var found = await store.FindByCorrelationIdAsync("nonexistent"); + found.Should().BeNull(); + } + + [Fact] + public async Task DeleteAsync_Removes_State() + { + var store = new InMemorySagaStore(); + var state = new TestSagaState { Id = Guid.NewGuid(), CorrelationId = "c1" }; + + await store.SaveAsync(state); + await store.DeleteAsync(state); + + var loaded = await store.GetByIdAsync(state.Id); + loaded.Should().BeNull(); + } + + [Fact] + public async Task SaveAsync_Updates_Existing_State() + { + var store = new InMemorySagaStore(); + var state = new TestSagaState { Id = Guid.NewGuid(), CorrelationId = "c1", Data = "v1" }; + + await store.SaveAsync(state); + state.Data = "v2"; + await store.SaveAsync(state); + + var loaded = await store.GetByIdAsync(state.Id); + loaded!.Data.Should().Be("v2"); + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `dotnet test Tests/RCommon.Persistence.Tests/ --filter "FullyQualifiedName~InMemorySagaStoreTests" -v minimal` + +- [ ] **Step 3: Create InMemorySagaStore** + +```csharp +// Src/RCommon.Persistence/Sagas/InMemorySagaStore.cs +using System; +using System.Collections.Concurrent; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +namespace RCommon.Persistence.Sagas; + +public class InMemorySagaStore : ISagaStore + where TState : SagaState + where TKey : IEquatable +{ + private readonly ConcurrentDictionary _store = new(); + + public Task GetByIdAsync(TKey id, CancellationToken ct = default) + { + _store.TryGetValue(id, out var state); + return Task.FromResult(state); + } + + public Task FindByCorrelationIdAsync(string correlationId, CancellationToken ct = default) + { + var state = _store.Values.FirstOrDefault(s => s.CorrelationId == correlationId); + return Task.FromResult(state); + } + + public Task SaveAsync(TState state, CancellationToken ct = default) + { + _store.AddOrUpdate(state.Id, state, (_, _) => state); + return Task.CompletedTask; + } + + public Task DeleteAsync(TState state, CancellationToken ct = default) + { + _store.TryRemove(state.Id, out _); + return Task.CompletedTask; + } +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `dotnet test Tests/RCommon.Persistence.Tests/ --filter "FullyQualifiedName~InMemorySagaStoreTests" -v minimal` +Expected: All 5 tests PASS. + +**Note:** `InMemorySagaStore` does NOT implement optimistic concurrency checking (version-based). The `ConcurrentDictionary.AddOrUpdate` always succeeds regardless of `Version`. Optimistic concurrency is the responsibility of ORM saga stores (EFCore uses EF concurrency tokens, Dapper/Linq2Db use explicit version checks). The in-memory store is intended for development/testing only. + +--- + +### Task 14: ORM Saga Stores + DI Registration + +**Files:** +- Create: `Src/RCommon.EfCore/Sagas/EFCoreSagaStore.cs` +- Create: `Src/RCommon.Dapper/Sagas/DapperSagaStore.cs` +- Create: `Src/RCommon.Linq2Db/Sagas/Linq2DbSagaStore.cs` +- Modify: `Src/RCommon.EfCore/EFCorePerisistenceBuilder.cs` +- Modify: `Src/RCommon.Dapper/DapperPersistenceBuilder.cs` +- Modify: `Src/RCommon.Linq2Db/Linq2DbPersistenceBuilder.cs` + +**Context:** Each ORM saga store implements `ISagaStore` using its ORM's data access patterns. EFCore uses `DbContext.Set()`, Dapper uses Dommel extensions, Linq2Db uses `DataConnection.GetTable()`. Register as Scoped. **Namespaces:** `RCommon.Persistence.EFCore.Sagas`, `RCommon.Persistence.Dapper.Sagas`, `RCommon.Persistence.Linq2Db.Sagas` (following the existing ORM namespace conventions). + +**Note:** The default `InMemorySagaStore` registration should be added in the core persistence DI configuration so that saga stores work without an ORM. If there is a `DefaultPersistenceBuilder` or similar, add: `services.AddScoped(typeof(ISagaStore<,>), typeof(InMemorySagaStore<,>));`. The ORM builders then override this default with their specific implementations. + +- [ ] **Step 1: Create EFCoreSagaStore** + +Uses `IDataStoreFactory` to resolve `RCommonDbContext`. Implements `ISagaStore` via `DbContext.Set()` queries. `FindByCorrelationIdAsync` uses `FirstOrDefaultAsync(s => s.CorrelationId == correlationId)`. `SaveAsync` uses `AddAsync` or `Update` based on whether entity is tracked. `GetByIdAsync` uses `FindAsync(id)`. `DeleteAsync` uses `Remove(state)` + `SaveChangesAsync()`. + +- [ ] **Step 2: Create DapperSagaStore** + +Uses Dommel extensions. `GetByIdAsync` → `db.GetAsync(id)`. `FindByCorrelationIdAsync` → `db.SelectAsync(s => s.CorrelationId == correlationId).FirstOrDefault()`. `SaveAsync` → `db.UpdateAsync(state)` (or `InsertAsync` for new). `DeleteAsync` → `db.DeleteAsync(state)`. + +- [ ] **Step 3: Create Linq2DbSagaStore** + +Uses Linq2Db `DataConnection`. `GetByIdAsync` → `table.FirstOrDefaultAsync(s => s.Id.Equals(id))`. `FindByCorrelationIdAsync` → `table.FirstOrDefaultAsync(s => s.CorrelationId == correlationId)`. `SaveAsync` → `InsertOrReplaceAsync`. `DeleteAsync` → `DeleteAsync`. + +- [ ] **Step 4: Add DI registrations to all three builders** + +In each builder constructor, add (after the `InMemorySagaStore` default registration if applicable): + +```csharp +// EFCorePerisistenceBuilder +services.AddScoped(typeof(ISagaStore<,>), typeof(EFCoreSagaStore<,>)); + +// DapperPersistenceBuilder +services.AddScoped(typeof(ISagaStore<,>), typeof(DapperSagaStore<,>)); + +// Linq2DbPersistenceBuilder +services.AddScoped(typeof(ISagaStore<,>), typeof(Linq2DbSagaStore<,>)); +``` + +- [ ] **Step 5: Verify solution builds** + +Run: `dotnet build Src/RCommon.sln -v minimal` +Expected: Build succeeded, 0 errors. + +**Note on tests:** ORM saga store implementations require integration tests with real ORM contexts. These should be added to `Tests/RCommon.EfCore.Tests/`, `Tests/RCommon.Dapper.Tests/`, and `Tests/RCommon.Linq2Db.Tests/` respectively when integration test infrastructure is available. The spec testing strategy (Part 4, item 3) calls for `FindByCorrelationIdAsync`, `SaveAsync`, and concurrent-save-with-stale-version tests per ORM. + +--- + +### Task 15: Final Verification + +- [ ] **Step 1: Full solution build** + +Run: `dotnet build Src/RCommon.sln -v minimal` +Expected: Build succeeded, 0 errors. + +- [ ] **Step 2: Run all tests** + +Run: `dotnet test Src/RCommon.sln -v minimal` +Expected: All tests pass, including new tests and all existing tests (backward compatibility). + +- [ ] **Step 3: Run new tests only** + +```bash +dotnet test Tests/RCommon.Persistence.Tests/ --filter "FullyQualifiedName~IAggregateRepositoryTests|FullyQualifiedName~UnitOfWorkCommitAsyncTests|FullyQualifiedName~IReadModelRepositoryTests|FullyQualifiedName~SagaOrchestratorTests|FullyQualifiedName~InMemorySagaStoreTests" -v normal +dotnet test Tests/RCommon.Models.Tests/ --filter "FullyQualifiedName~PagedResultTests" -v normal +dotnet test Tests/RCommon.Core.Tests/ --filter "FullyQualifiedName~StateMachineInterfaceTests" -v normal +``` + +Expected: All new tests pass. diff --git a/docs/superpowers/plans/2026-03-21-transactional-outbox.md b/docs/superpowers/plans/2026-03-21-transactional-outbox.md new file mode 100644 index 00000000..cce2a981 --- /dev/null +++ b/docs/superpowers/plans/2026-03-21-transactional-outbox.md @@ -0,0 +1,2436 @@ +# Transactional Outbox Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a transactional outbox pattern that persists domain events within the same DB transaction, guaranteeing at-least-once delivery via immediate dispatch + background poller. + +**Architecture:** Replace `IEventRouter` with `OutboxEventRouter` that buffers events in memory, persists them to `IOutboxStore` pre-commit, and dispatches post-commit. A background `OutboxProcessingService` polls for undelivered messages. MassTransit and Wolverine get thin wrapper projects that delegate to their native outbox implementations. + +**Tech Stack:** .NET (net8.0/net9.0/net10.0), EF Core, Dapper, Linq2Db, MassTransit 8.5.8, WolverineFx 5.13.0, System.Text.Json, xUnit 2.9.3, FluentAssertions 8.2.0, Moq 4.20.72 + +**Spec:** `docs/superpowers/specs/2026-03-21-transactional-outbox-design.md` + +--- + +## File Map + +### New files in existing projects + +| Project | File | Responsibility | +|---------|------|---------------| +| `RCommon.Persistence` | `Outbox/IOutboxMessage.cs` | Interface for outbox message entity | +| `RCommon.Persistence` | `Outbox/OutboxMessage.cs` | Concrete outbox message entity | +| `RCommon.Persistence` | `Outbox/IOutboxStore.cs` | Persistence abstraction for outbox CRUD | +| `RCommon.Persistence` | `Outbox/IOutboxSerializer.cs` | Serialization abstraction | +| `RCommon.Persistence` | `Outbox/JsonOutboxSerializer.cs` | Default System.Text.Json serializer | +| `RCommon.Persistence` | `Outbox/OutboxOptions.cs` | Configuration options | +| `RCommon.Persistence` | `Outbox/OutboxEventRouter.cs` | IEventRouter impl that buffers → persists → dispatches | +| `RCommon.Persistence` | `Outbox/OutboxEntityEventTracker.cs` | Decorator over InMemoryEntityEventTracker | +| `RCommon.Persistence` | `Outbox/OutboxProcessingService.cs` | Background IHostedService poller | +| `RCommon.Persistence` | `Outbox/OutboxPersistenceBuilderExtensions.cs` | `AddOutbox()` extension on IPersistenceBuilder | +| `RCommon.EfCore` | `Outbox/EFCoreOutboxStore.cs` | EF Core IOutboxStore implementation | +| `RCommon.EfCore` | `Outbox/OutboxMessageConfiguration.cs` | EF Core entity type configuration | +| `RCommon.EfCore` | `Outbox/ModelBuilderExtensions.cs` | `AddOutboxMessages()` convenience extension | +| `RCommon.Dapper` | `Outbox/DapperOutboxStore.cs` | Dapper IOutboxStore via raw SQL | +| `RCommon.Linq2Db` | `Outbox/Linq2DbOutboxStore.cs` | Linq2Db IOutboxStore implementation | + +### Modified files in existing projects + +| File | Change | +|------|--------| +| `Src/RCommon.Entities/IEntityEventTracker.cs` | Add `PersistEventsAsync(CT)`, add CT to `EmitTransactionalEventsAsync` | +| `Src/RCommon.Entities/InMemoryEntityEventTracker.cs` | Implement `PersistEventsAsync` as no-op, propagate CT | +| `Src/RCommon.Persistence/Transactions/UnitOfWork.cs` | Two-phase CommitAsync: persist → commit → dispatch | +| `Src/RCommon.Persistence/RCommon.Persistence.csproj` | Add `Microsoft.Extensions.Hosting.Abstractions` PackageReference | + +### New projects + +| Project | Key files | +|---------|-----------| +| `Src/RCommon.MassTransit.Outbox/` | `IMassTransitOutboxBuilder.cs`, `MassTransitOutboxBuilder.cs`, `MassTransitOutboxBuilderExtensions.cs`, `RCommon.MassTransit.Outbox.csproj` | +| `Src/RCommon.Wolverine.Outbox/` | `IWolverineOutboxBuilder.cs`, `WolverineOutboxBuilder.cs`, `WolverineOutboxBuilderExtensions.cs`, `RCommon.Wolverine.Outbox.csproj` | +| `Tests/RCommon.MassTransit.Outbox.Tests/` | `MassTransitOutboxBuilderTests.cs` | +| `Tests/RCommon.Wolverine.Outbox.Tests/` | `WolverineOutboxBuilderTests.cs` | + +### Test files (additions to existing test projects) + +| Project | File | +|---------|------| +| `Tests/RCommon.Persistence.Tests/` | `JsonOutboxSerializerTests.cs` | +| `Tests/RCommon.Persistence.Tests/` | `OutboxEventRouterTests.cs` | +| `Tests/RCommon.Persistence.Tests/` | `OutboxEntityEventTrackerTests.cs` | +| `Tests/RCommon.Persistence.Tests/` | `OutboxProcessingServiceTests.cs` | +| `Tests/RCommon.Persistence.Tests/` | `UnitOfWorkOutboxTests.cs` | +| `Tests/RCommon.Persistence.Tests/` | `OutboxConcurrencyTests.cs` | +| `Tests/RCommon.EfCore.Tests/` | `EFCoreOutboxStoreTests.cs` | +| `Tests/RCommon.Dapper.Tests/` | `DapperOutboxStoreTests.cs` | +| `Tests/RCommon.Linq2Db.Tests/` | `Linq2DbOutboxStoreTests.cs` | + +--- + +## Task 1: Core Outbox Abstractions — Interfaces & Entities + +**Files:** +- Create: `Src/RCommon.Persistence/Outbox/IOutboxMessage.cs` +- Create: `Src/RCommon.Persistence/Outbox/OutboxMessage.cs` +- Create: `Src/RCommon.Persistence/Outbox/IOutboxStore.cs` +- Create: `Src/RCommon.Persistence/Outbox/IOutboxSerializer.cs` +- Create: `Src/RCommon.Persistence/Outbox/OutboxOptions.cs` + +- [ ] **Step 1: Create IOutboxMessage interface** + +```csharp +// Src/RCommon.Persistence/Outbox/IOutboxMessage.cs +namespace RCommon.Persistence.Outbox; + +public interface IOutboxMessage +{ + Guid Id { get; } + string EventType { get; } + string EventPayload { get; } + DateTimeOffset CreatedAtUtc { get; } + DateTimeOffset? ProcessedAtUtc { get; set; } + DateTimeOffset? DeadLetteredAtUtc { get; set; } + string? ErrorMessage { get; set; } + int RetryCount { get; set; } + string? CorrelationId { get; set; } + string? TenantId { get; set; } +} +``` + +- [ ] **Step 2: Create OutboxMessage concrete entity** + +```csharp +// Src/RCommon.Persistence/Outbox/OutboxMessage.cs +namespace RCommon.Persistence.Outbox; + +public class OutboxMessage : IOutboxMessage +{ + public Guid Id { get; set; } + public string EventType { get; set; } = string.Empty; + public string EventPayload { get; set; } = string.Empty; + public DateTimeOffset CreatedAtUtc { get; set; } + public DateTimeOffset? ProcessedAtUtc { get; set; } + public DateTimeOffset? DeadLetteredAtUtc { get; set; } + public string? ErrorMessage { get; set; } + public int RetryCount { get; set; } + public string? CorrelationId { get; set; } + public string? TenantId { get; set; } +} +``` + +- [ ] **Step 3: Create IOutboxStore interface** + +```csharp +// Src/RCommon.Persistence/Outbox/IOutboxStore.cs +namespace RCommon.Persistence.Outbox; + +public interface IOutboxStore +{ + Task SaveAsync(IOutboxMessage message, CancellationToken cancellationToken = default); + Task> GetPendingAsync(int batchSize, CancellationToken cancellationToken = default); + Task MarkProcessedAsync(Guid messageId, CancellationToken cancellationToken = default); + Task MarkFailedAsync(Guid messageId, string error, CancellationToken cancellationToken = default); + Task MarkDeadLetteredAsync(Guid messageId, CancellationToken cancellationToken = default); + Task DeleteProcessedAsync(TimeSpan olderThan, CancellationToken cancellationToken = default); + Task DeleteDeadLetteredAsync(TimeSpan olderThan, CancellationToken cancellationToken = default); +} +``` + +- [ ] **Step 4: Create IOutboxSerializer interface** + +```csharp +// Src/RCommon.Persistence/Outbox/IOutboxSerializer.cs +using RCommon.Models.Events; + +namespace RCommon.Persistence.Outbox; + +public interface IOutboxSerializer +{ + string Serialize(ISerializableEvent @event); + string GetEventTypeName(ISerializableEvent @event); + ISerializableEvent Deserialize(string eventType, string payload); +} +``` + +- [ ] **Step 5: Create OutboxOptions** + +```csharp +// Src/RCommon.Persistence/Outbox/OutboxOptions.cs +namespace RCommon.Persistence.Outbox; + +public class OutboxOptions +{ + public TimeSpan PollingInterval { get; set; } = TimeSpan.FromSeconds(5); + public int BatchSize { get; set; } = 100; + public int MaxRetries { get; set; } = 5; + public TimeSpan CleanupAge { get; set; } = TimeSpan.FromDays(7); + public string TableName { get; set; } = "__OutboxMessages"; +} +``` + +- [ ] **Step 6: Build to verify compilation** + +Run: `dotnet build Src/RCommon.Persistence/RCommon.Persistence.csproj` +Expected: Build succeeded. 0 errors. + +- [ ] **Step 7: Commit** + +```bash +git add Src/RCommon.Persistence/Outbox/ +git commit -m "feat: add outbox core abstractions (IOutboxMessage, IOutboxStore, IOutboxSerializer, OutboxOptions)" +``` + +--- + +## Task 2: JsonOutboxSerializer + Tests + +**Files:** +- Create: `Src/RCommon.Persistence/Outbox/JsonOutboxSerializer.cs` +- Create: `Tests/RCommon.Persistence.Tests/JsonOutboxSerializerTests.cs` + +- [ ] **Step 1: Write failing tests for JsonOutboxSerializer** + +```csharp +// Tests/RCommon.Persistence.Tests/JsonOutboxSerializerTests.cs +using FluentAssertions; +using RCommon.Models.Events; +using RCommon.Persistence.Outbox; +using System.Text.Json; +using Xunit; + +namespace RCommon.Persistence.Tests; + +public record SerializerTestEvent(string Name, int Value) : ISerializableEvent; + +public class JsonOutboxSerializerTests +{ + private readonly JsonOutboxSerializer _serializer = new(); + + [Fact] + public void Serialize_ReturnsValidJson() + { + var @event = new SerializerTestEvent("OrderCreated", 42); + var json = _serializer.Serialize(@event); + var doc = JsonDocument.Parse(json); + doc.RootElement.GetProperty("Name").GetString().Should().Be("OrderCreated"); + doc.RootElement.GetProperty("Value").GetInt32().Should().Be(42); + } + + [Fact] + public void GetEventTypeName_ReturnsShortAssemblyQualifiedName() + { + var @event = new SerializerTestEvent("Test", 1); + var typeName = _serializer.GetEventTypeName(@event); + // Should contain type name and assembly, but not version/culture/token + typeName.Should().Contain("TestEvent"); + typeName.Should().Contain(","); + } + + [Fact] + public void Deserialize_RoundTrips() + { + var original = new SerializerTestEvent("OrderCreated", 42); + var json = _serializer.Serialize(original); + var typeName = _serializer.GetEventTypeName(original); + var deserialized = _serializer.Deserialize(typeName, json); + deserialized.Should().BeOfType(); + var typed = (TestEvent)deserialized; + typed.Name.Should().Be("OrderCreated"); + typed.Value.Should().Be(42); + } + + [Fact] + public void Deserialize_ThrowsForUnknownType() + { + var act = () => _serializer.Deserialize("NonExistent.Type, FakeAssembly", "{}"); + act.Should().Throw(); + } + + [Fact] + public void Deserialize_ThrowsForNonSerializableEventType() + { + // string implements nothing related to ISerializableEvent + var typeName = typeof(string).AssemblyQualifiedName!; + var act = () => _serializer.Deserialize(typeName, "\"hello\""); + act.Should().Throw(); + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `dotnet test Tests/RCommon.Persistence.Tests/ --filter "FullyQualifiedName~JsonOutboxSerializerTests" --no-build 2>&1 || echo "Expected: build failure (JsonOutboxSerializer not found)"` +Expected: Build failure — `JsonOutboxSerializer` does not exist yet. + +- [ ] **Step 3: Implement JsonOutboxSerializer** + +```csharp +// Src/RCommon.Persistence/Outbox/JsonOutboxSerializer.cs +using System.Text.Json; +using RCommon.Models.Events; + +namespace RCommon.Persistence.Outbox; + +public class JsonOutboxSerializer : IOutboxSerializer +{ + public string Serialize(ISerializableEvent @event) + { + Guard.IsNotNull(@event, nameof(@event)); + return JsonSerializer.Serialize(@event, @event.GetType()); + } + + public string GetEventTypeName(ISerializableEvent @event) + { + Guard.IsNotNull(@event, nameof(@event)); + var type = @event.GetType(); + return $"{type.FullName}, {type.Assembly.GetName().Name}"; + } + + public ISerializableEvent Deserialize(string eventType, string payload) + { + Guard.IsNotNull(eventType, nameof(eventType)); + Guard.IsNotNull(payload, nameof(payload)); + + var type = Type.GetType(eventType) + ?? throw new InvalidOperationException($"Cannot resolve type '{eventType}'."); + + if (!typeof(ISerializableEvent).IsAssignableFrom(type)) + { + throw new InvalidOperationException( + $"Type '{eventType}' does not implement ISerializableEvent."); + } + + var result = JsonSerializer.Deserialize(payload, type) + ?? throw new InvalidOperationException( + $"Deserialization of '{eventType}' returned null."); + + return (ISerializableEvent)result; + } +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `dotnet test Tests/RCommon.Persistence.Tests/ --filter "FullyQualifiedName~JsonOutboxSerializerTests"` +Expected: 5 passed, 0 failed. + +- [ ] **Step 5: Commit** + +```bash +git add Src/RCommon.Persistence/Outbox/JsonOutboxSerializer.cs Tests/RCommon.Persistence.Tests/JsonOutboxSerializerTests.cs +git commit -m "feat: add JsonOutboxSerializer with round-trip serialization and type safety" +``` + +--- + +## Task 3: IEntityEventTracker Interface Changes + UnitOfWork Two-Phase + +**Files:** +- Modify: `Src/RCommon.Entities/IEntityEventTracker.cs` +- Modify: `Src/RCommon.Entities/InMemoryEntityEventTracker.cs` +- Modify: `Src/RCommon.Persistence/Transactions/UnitOfWork.cs` +- Modify: `Src/RCommon.Persistence/RCommon.Persistence.csproj` + +- [ ] **Step 1: Add PersistEventsAsync to IEntityEventTracker and add CT to EmitTransactionalEventsAsync** + +Modify `Src/RCommon.Entities/IEntityEventTracker.cs`: +- Change `Task EmitTransactionalEventsAsync();` → `Task EmitTransactionalEventsAsync(CancellationToken cancellationToken = default);` +- Add: `Task PersistEventsAsync(CancellationToken cancellationToken = default);` + +- [ ] **Step 2: Implement in InMemoryEntityEventTracker** + +Modify `Src/RCommon.Entities/InMemoryEntityEventTracker.cs`: +- Add `PersistEventsAsync` as no-op: `public Task PersistEventsAsync(CancellationToken cancellationToken = default) => Task.CompletedTask;` +- Update `EmitTransactionalEventsAsync` signature to accept `CancellationToken cancellationToken = default` +- Pass `cancellationToken` to `_eventRouter.RouteEventsAsync(cancellationToken)` + +- [ ] **Step 3: Add Microsoft.Extensions.Hosting.Abstractions to RCommon.Persistence.csproj** + +Add to `Src/RCommon.Persistence/RCommon.Persistence.csproj` inside an ``: +```xml + + + +``` + +- [ ] **Step 4: Update UnitOfWork.CommitAsync to two-phase flow** + +Modify `Src/RCommon.Persistence/Transactions/UnitOfWork.cs` — replace the body of `CommitAsync` with: +```csharp +public async Task CommitAsync(CancellationToken cancellationToken = default) +{ + Guard.Against(_state == UnitOfWorkState.Disposed, + "Cannot commit a disposed UnitOfWorkScope instance."); + Guard.Against(_state == UnitOfWorkState.Completed, + "This unit of work scope has been marked completed."); + + _state = UnitOfWorkState.CommitAttempted; + + // Phase 1: persist events to outbox (within active transaction) + if (_eventTracker != null) + { + await _eventTracker.PersistEventsAsync(cancellationToken).ConfigureAwait(false); + } + + // Phase 2: commit transaction (domain writes + outbox writes atomically) + _transactionScope.Complete(); + _transactionScope.Dispose(); + _transactionScopeDisposed = true; + _state = UnitOfWorkState.Completed; + + // Phase 3: immediate dispatch attempt (best-effort, failures handled by poller) + if (_eventTracker != null) + { + var dispatched = await _eventTracker + .EmitTransactionalEventsAsync(cancellationToken) + .ConfigureAwait(false); + + if (!dispatched) + { + _logger.LogWarning( + "UnitOfWork {TransactionId}: domain event dispatch returned false.", + TransactionId); + } + } +} +``` + +- [ ] **Step 5: Build entire solution to verify no compilation errors** + +Run: `dotnet build Src/RCommon.sln` +Expected: Build succeeded. 0 errors. (All projects that implement `IEntityEventTracker` must compile.) + +- [ ] **Step 6: Run existing tests to verify no regressions** + +Run: `dotnet test Tests/RCommon.Persistence.Tests/ && dotnet test Tests/RCommon.Core.Tests/ && dotnet test Tests/RCommon.Mediatr.Tests/` +Expected: All existing tests pass (PersistEventsAsync is no-op, CT has default value). + +- [ ] **Step 7: Commit** + +```bash +git add Src/RCommon.Entities/IEntityEventTracker.cs Src/RCommon.Entities/InMemoryEntityEventTracker.cs Src/RCommon.Persistence/Transactions/UnitOfWork.cs Src/RCommon.Persistence/RCommon.Persistence.csproj +git commit -m "feat: two-phase UnitOfWork commit with PersistEventsAsync and CancellationToken propagation" +``` + +--- + +## Task 4: OutboxEventRouter + Tests + +**Files:** +- Create: `Src/RCommon.Persistence/Outbox/OutboxEventRouter.cs` +- Create: `Tests/RCommon.Persistence.Tests/OutboxEventRouterTests.cs` + +- [ ] **Step 1: Write failing tests for OutboxEventRouter** + +```csharp +// Tests/RCommon.Persistence.Tests/OutboxEventRouterTests.cs +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using Moq; +using RCommon.EventHandling.Producers; +using RCommon.Models.Events; +using RCommon.Persistence.Outbox; +using RCommon.Security.Claims; +using Xunit; + +namespace RCommon.Persistence.Tests; + +public record RouterTestEvent(string Data) : ISerializableEvent; + +public class OutboxEventRouterTests +{ + private readonly Mock _storeMock = new(); + private readonly Mock _guidGenMock = new(); + private readonly Mock _tenantMock = new(); + private readonly IOutboxSerializer _serializer = new JsonOutboxSerializer(); + private readonly Mock _serviceProviderMock = new(); + private readonly EventSubscriptionManager _subscriptionManager = new(); + + private OutboxEventRouter CreateRouter() + { + _guidGenMock.Setup(g => g.Create()).Returns(Guid.NewGuid()); + _tenantMock.Setup(t => t.GetTenantId()).Returns((string?)null); + return new OutboxEventRouter( + _storeMock.Object, + _serializer, + _guidGenMock.Object, + _tenantMock.Object, + _serviceProviderMock.Object, + _subscriptionManager, + NullLogger.Instance, + Options.Create(new OutboxOptions())); + } + + [Fact] + public void AddTransactionalEvent_BuffersWithoutCallingStore() + { + var router = CreateRouter(); + router.AddTransactionalEvent(new RouterTestEvent("test")); + _storeMock.Verify(s => s.SaveAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task PersistBufferedEventsAsync_WritesBufferedEventsToStore() + { + var router = CreateRouter(); + router.AddTransactionalEvent(new RouterTestEvent("event1")); + router.AddTransactionalEvent(new RouterTestEvent("event2")); + + await router.PersistBufferedEventsAsync(); + + _storeMock.Verify( + s => s.SaveAsync(It.IsAny(), It.IsAny()), + Times.Exactly(2)); + } + + [Fact] + public async Task PersistBufferedEventsAsync_ClearsBufferAfterPersistence() + { + var router = CreateRouter(); + router.AddTransactionalEvent(new RouterTestEvent("event1")); + await router.PersistBufferedEventsAsync(); + + // Second call should have nothing to persist + _storeMock.Invocations.Clear(); + await router.PersistBufferedEventsAsync(); + + _storeMock.Verify( + s => s.SaveAsync(It.IsAny(), It.IsAny()), + Times.Never); + } + + [Fact] + public async Task PersistBufferedEventsAsync_SetsCorrectMessageFields() + { + IOutboxMessage? captured = null; + _storeMock.Setup(s => s.SaveAsync(It.IsAny(), It.IsAny())) + .Callback((msg, _) => captured = msg); + _tenantMock.Setup(t => t.GetTenantId()).Returns("tenant-1"); + + var router = CreateRouter(); + router.AddTransactionalEvent(new RouterTestEvent("data")); + await router.PersistBufferedEventsAsync(); + + captured.Should().NotBeNull(); + captured!.EventType.Should().Contain("RouterTestEvent"); + captured.EventPayload.Should().Contain("data"); + captured.TenantId.Should().Be("tenant-1"); + captured.RetryCount.Should().Be(0); + captured.ProcessedAtUtc.Should().BeNull(); + captured.DeadLetteredAtUtc.Should().BeNull(); + } + + [Fact] + public async Task RouteEventsAsync_DispatchesPendingFromStore() + { + var msg = new OutboxMessage + { + Id = Guid.NewGuid(), + EventType = _serializer.GetEventTypeName(new RouterTestEvent("x")), + EventPayload = _serializer.Serialize(new RouterTestEvent("x")), + CreatedAtUtc = DateTimeOffset.UtcNow + }; + _storeMock.Setup(s => s.GetPendingAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new List { msg }); + + var producerMock = new Mock(); + _serviceProviderMock.Setup(sp => sp.GetService(typeof(IEnumerable))) + .Returns(new[] { producerMock.Object }); + + var router = CreateRouter(); + await router.RouteEventsAsync(); + + _storeMock.Verify(s => s.MarkProcessedAsync(msg.Id, It.IsAny()), Times.Once); + } + + [Fact] + public async Task RouteEventsAsync_MarksFailedOnException() + { + var msg = new OutboxMessage + { + Id = Guid.NewGuid(), + EventType = _serializer.GetEventTypeName(new RouterTestEvent("x")), + EventPayload = _serializer.Serialize(new RouterTestEvent("x")), + CreatedAtUtc = DateTimeOffset.UtcNow + }; + _storeMock.Setup(s => s.GetPendingAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new List { msg }); + + var producerMock = new Mock(); + producerMock.Setup(p => p.ProduceEventAsync(It.IsAny(), It.IsAny())) + .ThrowsAsync(new Exception("broker down")); + _serviceProviderMock.Setup(sp => sp.GetService(typeof(IEnumerable))) + .Returns(new[] { producerMock.Object }); + + var router = CreateRouter(); + await router.RouteEventsAsync(); + + _storeMock.Verify(s => s.MarkFailedAsync(msg.Id, It.IsAny(), It.IsAny()), Times.Once); + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `dotnet test Tests/RCommon.Persistence.Tests/ --filter "FullyQualifiedName~OutboxEventRouterTests" --no-build 2>&1 || echo "Expected: build failure"` +Expected: Build failure — `OutboxEventRouter` does not exist yet. + +- [ ] **Step 3: Implement OutboxEventRouter** + +```csharp +// Src/RCommon.Persistence/Outbox/OutboxEventRouter.cs +using System.Collections.Concurrent; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using RCommon.EventHandling.Producers; +using RCommon.Models.Events; +using RCommon.Security.Claims; + +namespace RCommon.Persistence.Outbox; + +public class OutboxEventRouter : IEventRouter +{ + private readonly IOutboxStore _outboxStore; + private readonly IOutboxSerializer _serializer; + private readonly IGuidGenerator _guidGenerator; + private readonly ITenantIdAccessor _tenantIdAccessor; + private readonly IServiceProvider _serviceProvider; + private readonly EventSubscriptionManager _subscriptionManager; + private readonly ILogger _logger; + private readonly OutboxOptions _options; + private readonly ConcurrentQueue _buffer = new(); + + public OutboxEventRouter( + IOutboxStore outboxStore, + IOutboxSerializer serializer, + IGuidGenerator guidGenerator, + ITenantIdAccessor tenantIdAccessor, + IServiceProvider serviceProvider, + EventSubscriptionManager subscriptionManager, + ILogger logger, + IOptions options) + { + _outboxStore = outboxStore ?? throw new ArgumentNullException(nameof(outboxStore)); + _serializer = serializer ?? throw new ArgumentNullException(nameof(serializer)); + _guidGenerator = guidGenerator ?? throw new ArgumentNullException(nameof(guidGenerator)); + _tenantIdAccessor = tenantIdAccessor ?? throw new ArgumentNullException(nameof(tenantIdAccessor)); + _serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider)); + _subscriptionManager = subscriptionManager ?? throw new ArgumentNullException(nameof(subscriptionManager)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _options = options?.Value ?? throw new ArgumentNullException(nameof(options)); + } + + public void AddTransactionalEvent(ISerializableEvent serializableEvent) + { + Guard.IsNotNull(serializableEvent, nameof(serializableEvent)); + _buffer.Enqueue(serializableEvent); + } + + public void AddTransactionalEvents(IEnumerable serializableEvents) + { + Guard.IsNotNull(serializableEvents, nameof(serializableEvents)); + foreach (var e in serializableEvents) + { + AddTransactionalEvent(e); + } + } + + public async Task PersistBufferedEventsAsync(CancellationToken cancellationToken = default) + { + var events = new List(); + while (_buffer.TryDequeue(out var e)) + { + events.Add(e); + } + + foreach (var @event in events) + { + var message = new OutboxMessage + { + Id = _guidGenerator.Create(), + EventType = _serializer.GetEventTypeName(@event), + EventPayload = _serializer.Serialize(@event), + CreatedAtUtc = DateTimeOffset.UtcNow, + TenantId = _tenantIdAccessor.GetTenantId() + // Note: CorrelationId population is left for a future enhancement (V2) + // when a correlation ID accessor is available in the framework + }; + + _logger.LogDebug("Persisting outbox message {Id} for event {EventType}", message.Id, message.EventType); + await _outboxStore.SaveAsync(message, cancellationToken).ConfigureAwait(false); + } + } + + public async Task RouteEventsAsync(CancellationToken cancellationToken = default) + { + var pending = await _outboxStore.GetPendingAsync(_options.BatchSize, cancellationToken).ConfigureAwait(false); + + if (pending.Count == 0) return; + + _logger.LogInformation("OutboxEventRouter dispatching {Count} pending messages", pending.Count); + + var producers = _serviceProvider.GetServices(); + + foreach (var message in pending) + { + try + { + var @event = _serializer.Deserialize(message.EventType, message.EventPayload); + var filteredProducers = _subscriptionManager.HasSubscriptions + ? _subscriptionManager.GetProducersForEvent(producers, @event.GetType()) + : producers; + + foreach (var producer in filteredProducers) + { + await producer.ProduceEventAsync(@event, cancellationToken).ConfigureAwait(false); + } + + await _outboxStore.MarkProcessedAsync(message.Id, cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to dispatch outbox message {Id}", message.Id); + await _outboxStore.MarkFailedAsync(message.Id, ex.Message, cancellationToken).ConfigureAwait(false); + } + } + } + + public async Task RouteEventsAsync(IEnumerable transactionalEvents, CancellationToken cancellationToken = default) + { + Guard.IsNotNull(transactionalEvents, nameof(transactionalEvents)); + + var producers = _serviceProvider.GetServices(); + + foreach (var @event in transactionalEvents) + { + var filteredProducers = _subscriptionManager.HasSubscriptions + ? _subscriptionManager.GetProducersForEvent(producers, @event.GetType()) + : producers; + + foreach (var producer in filteredProducers) + { + await producer.ProduceEventAsync(@event, cancellationToken).ConfigureAwait(false); + } + } + } +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `dotnet test Tests/RCommon.Persistence.Tests/ --filter "FullyQualifiedName~OutboxEventRouterTests"` +Expected: 6 passed, 0 failed. + +- [ ] **Step 5: Commit** + +```bash +git add Src/RCommon.Persistence/Outbox/OutboxEventRouter.cs Tests/RCommon.Persistence.Tests/OutboxEventRouterTests.cs +git commit -m "feat: add OutboxEventRouter with buffer-persist-dispatch pattern" +``` + +--- + +## Task 5: OutboxEntityEventTracker + Tests + +**Files:** +- Create: `Src/RCommon.Persistence/Outbox/OutboxEntityEventTracker.cs` +- Create: `Tests/RCommon.Persistence.Tests/OutboxEntityEventTrackerTests.cs` + +- [ ] **Step 1: Write failing tests** + +```csharp +// Tests/RCommon.Persistence.Tests/OutboxEntityEventTrackerTests.cs +using FluentAssertions; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using Moq; +using RCommon.Entities; +using RCommon.EventHandling.Producers; +using RCommon.Models.Events; +using RCommon.Persistence.Outbox; +using RCommon.Security.Claims; +using Xunit; + +namespace RCommon.Persistence.Tests; + +public record TrackerTestEvent(string Data) : ISerializableEvent; + +public class OutboxEntityEventTrackerTests +{ + private readonly Mock _storeMock = new(); + private readonly Mock _guidGenMock = new(); + private readonly OutboxEventRouter _outboxRouter; + private readonly InMemoryEntityEventTracker _innerTracker; + + public OutboxEntityEventTrackerTests() + { + _guidGenMock.Setup(g => g.Create()).Returns(Guid.NewGuid()); + var tenantMock = new Mock(); + var serviceProviderMock = new Mock(); + + _outboxRouter = new OutboxEventRouter( + _storeMock.Object, + new JsonOutboxSerializer(), + _guidGenMock.Object, + tenantMock.Object, + serviceProviderMock.Object, + new EventSubscriptionManager(), + NullLogger.Instance, + Options.Create(new OutboxOptions())); + + _innerTracker = new InMemoryEntityEventTracker(_outboxRouter); + } + + [Fact] + public void AddEntity_DelegatesToInnerTracker() + { + var tracker = new OutboxEntityEventTracker(_innerTracker, _outboxRouter); + var entityMock = new Mock(); + entityMock.Setup(e => e.AllowEventTracking).Returns(true); + + tracker.AddEntity(entityMock.Object); + + tracker.TrackedEntities.Should().Contain(entityMock.Object); + } + + [Fact] + public async Task PersistEventsAsync_WithNoEntities_CompletesWithoutStoreCalls() + { + var tracker = new OutboxEntityEventTracker(_innerTracker, _outboxRouter); + + await tracker.PersistEventsAsync(); + + _storeMock.Verify(s => s.SaveAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task EmitTransactionalEventsAsync_ReturnsTrue() + { + _storeMock.Setup(s => s.GetPendingAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new List()); + + var tracker = new OutboxEntityEventTracker(_innerTracker, _outboxRouter); + + var result = await tracker.EmitTransactionalEventsAsync(); + + result.Should().BeTrue(); + } +} + +- [ ] **Step 2: Implement OutboxEntityEventTracker** + +```csharp +// Src/RCommon.Persistence/Outbox/OutboxEntityEventTracker.cs +using RCommon.Entities; +using RCommon.EventHandling.Producers; + +namespace RCommon.Persistence.Outbox; + +public class OutboxEntityEventTracker : IEntityEventTracker +{ + private readonly InMemoryEntityEventTracker _inner; + private readonly OutboxEventRouter _outboxRouter; + + public OutboxEntityEventTracker(InMemoryEntityEventTracker inner, OutboxEventRouter outboxRouter) + { + _inner = inner ?? throw new ArgumentNullException(nameof(inner)); + _outboxRouter = outboxRouter ?? throw new ArgumentNullException(nameof(outboxRouter)); + } + + public void AddEntity(IBusinessEntity entity) => _inner.AddEntity(entity); + + public ICollection TrackedEntities => _inner.TrackedEntities; + + public async Task PersistEventsAsync(CancellationToken cancellationToken = default) + { + // Walk entity graph and collect events into the router buffer + foreach (var entity in _inner.TrackedEntities) + { + var entityGraph = entity.TraverseGraphFor(); + foreach (var graphEntity in entityGraph) + { + _outboxRouter.AddTransactionalEvents(graphEntity.LocalEvents); + } + } + + // Flush buffer to outbox store (within the active transaction) + await _outboxRouter.PersistBufferedEventsAsync(cancellationToken).ConfigureAwait(false); + } + + public async Task EmitTransactionalEventsAsync(CancellationToken cancellationToken = default) + { + await _outboxRouter.RouteEventsAsync(cancellationToken).ConfigureAwait(false); + return true; + } +} +``` + +- [ ] **Step 3: Run tests and iterate** + +Run: `dotnet test Tests/RCommon.Persistence.Tests/ --filter "FullyQualifiedName~OutboxEntityEventTrackerTests"` +Expected: All tests pass. Adjust mocking approach if needed. + +- [ ] **Step 4: Commit** + +```bash +git add Src/RCommon.Persistence/Outbox/OutboxEntityEventTracker.cs Tests/RCommon.Persistence.Tests/OutboxEntityEventTrackerTests.cs +git commit -m "feat: add OutboxEntityEventTracker decorator for two-phase event persistence" +``` + +--- + +## Task 6: OutboxProcessingService + Tests + +**Files:** +- Create: `Src/RCommon.Persistence/Outbox/OutboxProcessingService.cs` +- Create: `Tests/RCommon.Persistence.Tests/OutboxProcessingServiceTests.cs` + +- [ ] **Step 1: Write failing tests** + +Key behaviors to test: +1. Service creates a scope per polling iteration +2. Resolves `IOutboxStore` and dispatches pending messages +3. Marks messages as processed on success +4. Marks messages as failed on dispatch exception +5. Marks messages as dead-lettered when `RetryCount >= MaxRetries` +6. Calls cleanup methods periodically + +```csharp +// Tests/RCommon.Persistence.Tests/OutboxProcessingServiceTests.cs +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using Moq; +using RCommon.EventHandling.Producers; +using RCommon.Models.Events; +using RCommon.Persistence.Outbox; +using Xunit; + +namespace RCommon.Persistence.Tests; + +public record PollerTestEvent(string Data) : ISerializableEvent; + +public class OutboxProcessingServiceTests +{ + private readonly Mock _storeMock = new(); + private readonly Mock _producerMock = new(); + private readonly IOutboxSerializer _serializer = new JsonOutboxSerializer(); + private readonly EventSubscriptionManager _subscriptionManager = new(); + + private (OutboxProcessingService service, IServiceProvider provider) CreateService(OutboxOptions? options = null) + { + var opts = options ?? new OutboxOptions { PollingInterval = TimeSpan.FromMilliseconds(50) }; + + var services = new ServiceCollection(); + services.AddSingleton(_storeMock.Object); + services.AddSingleton(_serializer); + services.AddSingleton(_producerMock.Object); + services.AddSingleton(_subscriptionManager); + var provider = services.BuildServiceProvider(); + + var service = new OutboxProcessingService( + provider, + Options.Create(opts), + NullLogger.Instance); + + return (service, provider); + } + + [Fact] + public async Task ProcessBatchAsync_DispatchesAndMarksProcessed() + { + var @event = new PollerTestEvent("hello"); + var msg = new OutboxMessage + { + Id = Guid.NewGuid(), + EventType = _serializer.GetEventTypeName(@event), + EventPayload = _serializer.Serialize(@event), + CreatedAtUtc = DateTimeOffset.UtcNow + }; + _storeMock.Setup(s => s.GetPendingAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new List { msg }); + + var (service, _) = CreateService(); + await service.ProcessBatchAsync(CancellationToken.None); + + _producerMock.Verify(p => p.ProduceEventAsync(It.IsAny(), It.IsAny()), Times.Once); + _storeMock.Verify(s => s.MarkProcessedAsync(msg.Id, It.IsAny()), Times.Once); + } + + [Fact] + public async Task ProcessBatchAsync_MarksFailedOnException() + { + var @event = new PollerTestEvent("fail"); + var msg = new OutboxMessage + { + Id = Guid.NewGuid(), + EventType = _serializer.GetEventTypeName(@event), + EventPayload = _serializer.Serialize(@event), + CreatedAtUtc = DateTimeOffset.UtcNow, + RetryCount = 0 + }; + _storeMock.Setup(s => s.GetPendingAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new List { msg }); + _producerMock.Setup(p => p.ProduceEventAsync(It.IsAny(), It.IsAny())) + .ThrowsAsync(new Exception("transport error")); + + var (service, _) = CreateService(); + await service.ProcessBatchAsync(CancellationToken.None); + + _storeMock.Verify(s => s.MarkFailedAsync(msg.Id, It.IsAny(), It.IsAny()), Times.Once); + } + + [Fact] + public async Task ProcessBatchAsync_DeadLettersWhenMaxRetriesExceeded() + { + var @event = new PollerTestEvent("dead"); + var msg = new OutboxMessage + { + Id = Guid.NewGuid(), + EventType = _serializer.GetEventTypeName(@event), + EventPayload = _serializer.Serialize(@event), + CreatedAtUtc = DateTimeOffset.UtcNow, + RetryCount = 5 + }; + _storeMock.Setup(s => s.GetPendingAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new List { msg }); + _producerMock.Setup(p => p.ProduceEventAsync(It.IsAny(), It.IsAny())) + .ThrowsAsync(new Exception("still down")); + + var opts = new OutboxOptions { MaxRetries = 5, PollingInterval = TimeSpan.FromMilliseconds(50) }; + var (service, _) = CreateService(opts); + await service.ProcessBatchAsync(CancellationToken.None); + + _storeMock.Verify(s => s.MarkDeadLetteredAsync(msg.Id, It.IsAny()), Times.Once); + } +} +``` + +- [ ] **Step 2: Implement OutboxProcessingService** + +```csharp +// Src/RCommon.Persistence/Outbox/OutboxProcessingService.cs +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using RCommon.EventHandling.Producers; +using RCommon.Models.Events; + +namespace RCommon.Persistence.Outbox; + +public class OutboxProcessingService : BackgroundService +{ + private readonly IServiceProvider _serviceProvider; + private readonly OutboxOptions _options; + private readonly ILogger _logger; + + public OutboxProcessingService( + IServiceProvider serviceProvider, + IOptions options, + ILogger logger) + { + _serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider)); + _options = options?.Value ?? throw new ArgumentNullException(nameof(options)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + _logger.LogInformation("OutboxProcessingService started. Polling every {Interval}s", _options.PollingInterval.TotalSeconds); + + while (!stoppingToken.IsCancellationRequested) + { + try + { + await ProcessBatchAsync(stoppingToken).ConfigureAwait(false); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + _logger.LogError(ex, "OutboxProcessingService encountered an error during polling"); + } + + await Task.Delay(_options.PollingInterval, stoppingToken).ConfigureAwait(false); + } + } + + public async Task ProcessBatchAsync(CancellationToken cancellationToken) + { + using var scope = _serviceProvider.CreateScope(); + var store = scope.ServiceProvider.GetRequiredService(); + var serializer = scope.ServiceProvider.GetRequiredService(); + var producers = scope.ServiceProvider.GetServices(); + var subscriptionManager = scope.ServiceProvider.GetRequiredService(); + + var pending = await store.GetPendingAsync(_options.BatchSize, cancellationToken).ConfigureAwait(false); + + foreach (var message in pending) + { + try + { + if (message.RetryCount >= _options.MaxRetries) + { + _logger.LogWarning("Outbox message {Id} exceeded max retries ({Max}). Dead-lettering.", + message.Id, _options.MaxRetries); + await store.MarkDeadLetteredAsync(message.Id, cancellationToken).ConfigureAwait(false); + continue; + } + + var @event = serializer.Deserialize(message.EventType, message.EventPayload); + var filteredProducers = subscriptionManager.HasSubscriptions + ? subscriptionManager.GetProducersForEvent(producers, @event.GetType()) + : producers; + + foreach (var producer in filteredProducers) + { + await producer.ProduceEventAsync(@event, cancellationToken).ConfigureAwait(false); + } + + await store.MarkProcessedAsync(message.Id, cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + _logger.LogWarning(ex, "Failed to dispatch outbox message {Id} (retry {Retry})", + message.Id, message.RetryCount); + + if (message.RetryCount + 1 >= _options.MaxRetries) + { + await store.MarkDeadLetteredAsync(message.Id, cancellationToken).ConfigureAwait(false); + } + else + { + await store.MarkFailedAsync(message.Id, ex.Message, cancellationToken).ConfigureAwait(false); + } + } + } + + // Periodic cleanup + await store.DeleteProcessedAsync(_options.CleanupAge, cancellationToken).ConfigureAwait(false); + await store.DeleteDeadLetteredAsync(_options.CleanupAge, cancellationToken).ConfigureAwait(false); + } +} +``` + +- [ ] **Step 3: Run tests** + +Run: `dotnet test Tests/RCommon.Persistence.Tests/ --filter "FullyQualifiedName~OutboxProcessingServiceTests"` +Expected: 3 passed, 0 failed. + +- [ ] **Step 4: Commit** + +```bash +git add Src/RCommon.Persistence/Outbox/OutboxProcessingService.cs Tests/RCommon.Persistence.Tests/OutboxProcessingServiceTests.cs +git commit -m "feat: add OutboxProcessingService background poller with retry and dead-letter support" +``` + +--- + +## Task 7: Builder Extension (AddOutbox) + UnitOfWork Integration Test + +**Files:** +- Create: `Src/RCommon.Persistence/Outbox/OutboxPersistenceBuilderExtensions.cs` +- Create: `Tests/RCommon.Persistence.Tests/UnitOfWorkOutboxTests.cs` + +- [ ] **Step 1: Implement AddOutbox extension** + +```csharp +// Src/RCommon.Persistence/Outbox/OutboxPersistenceBuilderExtensions.cs +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using RCommon.Entities; +using RCommon.EventHandling.Producers; +using RCommon.Persistence.Outbox; + +namespace RCommon; + +public static class OutboxPersistenceBuilderExtensions +{ + public static IPersistenceBuilder AddOutbox( + this IPersistenceBuilder builder, + Action? configure = null) + where TOutboxStore : class, IOutboxStore + { + // Outbox store (scoped — participates in per-request transaction) + builder.Services.AddScoped(); + + // Serializer (singleton, replaceable) + builder.Services.TryAddSingleton(); + + // Outbox event router (scoped — replaces InMemoryTransactionalEventRouter) + builder.Services.AddScoped(); + builder.Services.AddScoped(sp => sp.GetRequiredService()); + + // Entity event tracker decorator (scoped — replaces InMemoryEntityEventTracker) + builder.Services.AddScoped(); + builder.Services.AddScoped(); + + // Background processing service (singleton) + builder.Services.AddHostedService(); + + // Options + if (configure != null) + { + builder.Services.Configure(configure); + } + else + { + builder.Services.Configure(_ => { }); + } + + return builder; + } +} +``` + +- [ ] **Step 2: Write UnitOfWork integration test** + +```csharp +// Tests/RCommon.Persistence.Tests/UnitOfWorkOutboxTests.cs +using FluentAssertions; +using Moq; +using RCommon.Entities; +using RCommon.EventHandling.Producers; +using RCommon.Models.Events; +using RCommon.Persistence.Outbox; +using Xunit; + +namespace RCommon.Persistence.Tests; + +public record UoWTestEvent(string Data) : ISerializableEvent; + +public class UnitOfWorkOutboxTests +{ + [Fact] + public async Task PersistEventsAsync_IsCalledBeforeCommit_ViaOutboxEntityEventTracker() + { + // Verify the OutboxEntityEventTracker PersistEventsAsync → OutboxEventRouter.PersistBufferedEventsAsync flow + var storeMock = new Mock(); + var serializer = new JsonOutboxSerializer(); + var guidGenMock = new Mock(); + guidGenMock.Setup(g => g.Create()).Returns(Guid.NewGuid()); + var tenantMock = new Mock(); + + var serviceProviderMock = new Mock(); + var subscriptionManager = new EventSubscriptionManager(); + + var outboxRouter = new OutboxEventRouter( + storeMock.Object, + serializer, + guidGenMock.Object, + tenantMock.Object, + serviceProviderMock.Object, + subscriptionManager, + Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance, + Microsoft.Extensions.Options.Options.Create(new OutboxOptions())); + + var innerTracker = new InMemoryEntityEventTracker(outboxRouter); + var tracker = new OutboxEntityEventTracker(innerTracker, outboxRouter); + + // Simulate: PersistEventsAsync is called (Phase 1, pre-commit) + await tracker.PersistEventsAsync(); + + // With no entities tracked, no store calls expected — but should complete without error + storeMock.Verify(s => s.SaveAsync(It.IsAny(), It.IsAny()), Times.Never); + } +} +``` + +- [ ] **Step 3: Run all persistence tests** + +Run: `dotnet test Tests/RCommon.Persistence.Tests/` +Expected: All tests pass (existing + new). + +- [ ] **Step 4: Build entire solution** + +Run: `dotnet build Src/RCommon.sln` +Expected: 0 errors. + +- [ ] **Step 5: Write concurrency and edge case tests** + +```csharp +// Tests/RCommon.Persistence.Tests/OutboxConcurrencyTests.cs +using FluentAssertions; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using Moq; +using RCommon.EventHandling.Producers; +using RCommon.Models.Events; +using RCommon.Persistence.Outbox; +using RCommon.Security.Claims; +using Xunit; + +namespace RCommon.Persistence.Tests; + +public record ConcurrencyTestEvent(string Data) : ISerializableEvent; + +public class OutboxConcurrencyTests +{ + [Fact] + public async Task DeadLetterMessages_ExcludedFromGetPending() + { + // Verifies dead-lettered messages are excluded from future GetPendingAsync + var storeMock = new Mock(); + var deadLetteredMsg = new OutboxMessage + { + Id = Guid.NewGuid(), EventType = "T", EventPayload = "{}", + CreatedAtUtc = DateTimeOffset.UtcNow, DeadLetteredAtUtc = DateTimeOffset.UtcNow + }; + storeMock.Setup(s => s.GetPendingAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new List()); // Dead-lettered excluded at store level + + // Verify store contract: GetPendingAsync should never return dead-lettered messages + var pending = await storeMock.Object.GetPendingAsync(100); + pending.Should().NotContain(m => m.DeadLetteredAtUtc.HasValue); + } + + [Fact] + public async Task EmptyBuffer_PersistBufferedEventsAsync_NoStoreCalls() + { + var storeMock = new Mock(); + var guidGenMock = new Mock(); + var tenantMock = new Mock(); + var serviceProviderMock = new Mock(); + + var router = new OutboxEventRouter( + storeMock.Object, new JsonOutboxSerializer(), + guidGenMock.Object, tenantMock.Object, + serviceProviderMock.Object, new EventSubscriptionManager(), + NullLogger.Instance, + Options.Create(new OutboxOptions())); + + // No events buffered — persist should be a no-op + await router.PersistBufferedEventsAsync(); + storeMock.Verify(s => s.SaveAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task RouteEventsAsync_NoPending_CompletesQuickly() + { + var storeMock = new Mock(); + storeMock.Setup(s => s.GetPendingAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new List()); + var guidGenMock = new Mock(); + var tenantMock = new Mock(); + var serviceProviderMock = new Mock(); + + var router = new OutboxEventRouter( + storeMock.Object, new JsonOutboxSerializer(), + guidGenMock.Object, tenantMock.Object, + serviceProviderMock.Object, new EventSubscriptionManager(), + NullLogger.Instance, + Options.Create(new OutboxOptions())); + + // No pending messages — should return immediately with no producer calls + await router.RouteEventsAsync(); + storeMock.Verify(s => s.MarkProcessedAsync(It.IsAny(), It.IsAny()), Times.Never); + } +} +``` + +- [ ] **Step 6: Run all persistence tests** + +Run: `dotnet test Tests/RCommon.Persistence.Tests/` +Expected: All tests pass (existing + new). + +- [ ] **Step 7: Build entire solution** + +Run: `dotnet build Src/RCommon.sln` +Expected: 0 errors. + +- [ ] **Step 8: Commit** + +```bash +git add Src/RCommon.Persistence/Outbox/OutboxPersistenceBuilderExtensions.cs Tests/RCommon.Persistence.Tests/UnitOfWorkOutboxTests.cs Tests/RCommon.Persistence.Tests/OutboxConcurrencyTests.cs +git commit -m "feat: add AddOutbox builder extension, UnitOfWork integration, and concurrency tests" +``` + +--- + +## Task 8: EF Core Outbox Store + Tests + +**Files:** +- Create: `Src/RCommon.EfCore/Outbox/EFCoreOutboxStore.cs` +- Create: `Src/RCommon.EfCore/Outbox/OutboxMessageConfiguration.cs` +- Create: `Src/RCommon.EfCore/Outbox/ModelBuilderExtensions.cs` +- Create: `Tests/RCommon.EfCore.Tests/EFCoreOutboxStoreTests.cs` + +**Pattern:** EF Core repositories resolve their `RCommonDbContext` via `IDataStoreFactory.Resolve(dataStoreName)`. The outbox store follows the same pattern, using `DefaultDataStoreOptions.DefaultDataStoreName` for the store name. + +**Atomicity note:** `EFCoreOutboxStore.SaveAsync()` calls `SaveChangesAsync()` after adding the `OutboxMessage` to the change tracker. By this point in the flow (Phase 1 of `UnitOfWork.CommitAsync`), domain entity changes have already been flushed by the repositories (each repository calls `SaveChangesAsync` in its own Add/Update/Delete methods). The outbox's `SaveChangesAsync` only flushes the outbox message row. Both the domain writes and outbox writes are within the same `TransactionScope`, so they commit atomically when `TransactionScope.Complete()` is called. + +- [ ] **Step 1: Create OutboxMessageConfiguration (EF Core entity type config)** + +```csharp +// Src/RCommon.EfCore/Outbox/OutboxMessageConfiguration.cs +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using RCommon.Persistence.Outbox; + +namespace RCommon.Persistence.EFCore.Outbox; + +public class OutboxMessageConfiguration : IEntityTypeConfiguration +{ + private readonly string _tableName; + + public OutboxMessageConfiguration(string tableName = "__OutboxMessages") + { + _tableName = tableName; + } + + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable(_tableName); + builder.HasKey(x => x.Id); + builder.Property(x => x.EventType).HasMaxLength(1024).IsRequired(); + builder.Property(x => x.EventPayload).IsRequired(); + builder.Property(x => x.CreatedAtUtc).IsRequired(); + builder.Property(x => x.CorrelationId).HasMaxLength(256); + builder.Property(x => x.TenantId).HasMaxLength(256); + + builder.HasIndex(x => new { x.ProcessedAtUtc, x.DeadLetteredAtUtc, x.CreatedAtUtc }) + .HasDatabaseName("IX_OutboxMessages_Pending"); + } +} +``` + +- [ ] **Step 2: Create ModelBuilder extension** + +```csharp +// Src/RCommon.EfCore/Outbox/ModelBuilderExtensions.cs +using Microsoft.EntityFrameworkCore; + +namespace RCommon.Persistence.EFCore.Outbox; + +public static class ModelBuilderExtensions +{ + public static ModelBuilder AddOutboxMessages(this ModelBuilder modelBuilder, string tableName = "__OutboxMessages") + { + modelBuilder.ApplyConfiguration(new OutboxMessageConfiguration(tableName)); + return modelBuilder; + } +} +``` + +- [ ] **Step 3: Create EFCoreOutboxStore using IDataStoreFactory** + +```csharp +// Src/RCommon.EfCore/Outbox/EFCoreOutboxStore.cs +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; +using RCommon.Persistence.Outbox; + +namespace RCommon.Persistence.EFCore.Outbox; + +public class EFCoreOutboxStore : IOutboxStore +{ + private readonly IDataStoreFactory _dataStoreFactory; + private readonly string _dataStoreName; + private readonly int _maxRetries; + + public EFCoreOutboxStore( + IDataStoreFactory dataStoreFactory, + IOptions defaultDataStoreOptions, + IOptions outboxOptions) + { + _dataStoreFactory = dataStoreFactory ?? throw new ArgumentNullException(nameof(dataStoreFactory)); + _dataStoreName = defaultDataStoreOptions?.Value?.DefaultDataStoreName + ?? throw new ArgumentNullException(nameof(defaultDataStoreOptions)); + _maxRetries = outboxOptions?.Value?.MaxRetries ?? 5; + } + + private RCommonDbContext DbContext => _dataStoreFactory.Resolve(_dataStoreName); + + public async Task SaveAsync(IOutboxMessage message, CancellationToken cancellationToken = default) + { + var dbContext = DbContext; + if (message is OutboxMessage entity) + { + dbContext.Set().Add(entity); + } + else + { + dbContext.Set().Add(new OutboxMessage + { + Id = message.Id, + EventType = message.EventType, + EventPayload = message.EventPayload, + CreatedAtUtc = message.CreatedAtUtc, + ProcessedAtUtc = message.ProcessedAtUtc, + DeadLetteredAtUtc = message.DeadLetteredAtUtc, + ErrorMessage = message.ErrorMessage, + RetryCount = message.RetryCount, + CorrelationId = message.CorrelationId, + TenantId = message.TenantId + }); + } + await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + } + + public async Task> GetPendingAsync(int batchSize, CancellationToken cancellationToken = default) + { + return await DbContext.Set() + .Where(m => m.ProcessedAtUtc == null + && m.DeadLetteredAtUtc == null + && m.RetryCount < _maxRetries) + .OrderBy(m => m.CreatedAtUtc) + .Take(batchSize) + .ToListAsync(cancellationToken) + .ConfigureAwait(false); + } + + public async Task MarkProcessedAsync(Guid messageId, CancellationToken cancellationToken = default) + { + var dbContext = DbContext; + var message = await dbContext.Set() + .FindAsync(new object[] { messageId }, cancellationToken).ConfigureAwait(false); + if (message != null) + { + message.ProcessedAtUtc = DateTimeOffset.UtcNow; + await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + } + } + + public async Task MarkFailedAsync(Guid messageId, string error, CancellationToken cancellationToken = default) + { + var dbContext = DbContext; + var message = await dbContext.Set() + .FindAsync(new object[] { messageId }, cancellationToken).ConfigureAwait(false); + if (message != null) + { + message.ErrorMessage = error; + message.RetryCount++; + await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + } + } + + public async Task MarkDeadLetteredAsync(Guid messageId, CancellationToken cancellationToken = default) + { + var dbContext = DbContext; + var message = await dbContext.Set() + .FindAsync(new object[] { messageId }, cancellationToken).ConfigureAwait(false); + if (message != null) + { + message.DeadLetteredAtUtc = DateTimeOffset.UtcNow; + await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + } + } + + public async Task DeleteProcessedAsync(TimeSpan olderThan, CancellationToken cancellationToken = default) + { + var dbContext = DbContext; + var cutoff = DateTimeOffset.UtcNow - olderThan; + var old = await dbContext.Set() + .Where(m => m.ProcessedAtUtc != null && m.ProcessedAtUtc < cutoff) + .ToListAsync(cancellationToken).ConfigureAwait(false); + dbContext.Set().RemoveRange(old); + await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + } + + public async Task DeleteDeadLetteredAsync(TimeSpan olderThan, CancellationToken cancellationToken = default) + { + var dbContext = DbContext; + var cutoff = DateTimeOffset.UtcNow - olderThan; + var old = await dbContext.Set() + .Where(m => m.DeadLetteredAtUtc != null && m.DeadLetteredAtUtc < cutoff) + .ToListAsync(cancellationToken).ConfigureAwait(false); + dbContext.Set().RemoveRange(old); + await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + } +} +``` + +- [ ] **Step 4: Write EFCoreOutboxStore tests (SQLite in-memory)** + +```csharp +// Tests/RCommon.EfCore.Tests/EFCoreOutboxStoreTests.cs +using FluentAssertions; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; +using Moq; +using RCommon.Persistence.EFCore.Outbox; +using RCommon.Persistence.Outbox; +using Xunit; + +namespace RCommon.EfCore.Tests; + +// Minimal DbContext for testing +public class TestOutboxDbContext : RCommonDbContext +{ + public TestOutboxDbContext(DbContextOptions options) : base(options) { } + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.AddOutboxMessages(); + } +} + +public class EFCoreOutboxStoreTests : IDisposable +{ + private readonly TestOutboxDbContext _dbContext; + private readonly EFCoreOutboxStore _store; + + public EFCoreOutboxStoreTests() + { + var dbOptions = new DbContextOptionsBuilder() + .UseSqlite("DataSource=:memory:") + .Options; + _dbContext = new TestOutboxDbContext(dbOptions); + _dbContext.Database.OpenConnection(); + _dbContext.Database.EnsureCreated(); + + var factoryMock = new Mock(); + factoryMock.Setup(f => f.Resolve(It.IsAny())) + .Returns(_dbContext); + var defaultOpts = Options.Create(new DefaultDataStoreOptions { DefaultDataStoreName = "test" }); + var outboxOpts = Options.Create(new OutboxOptions { MaxRetries = 3 }); + + _store = new EFCoreOutboxStore(factoryMock.Object, defaultOpts, outboxOpts); + } + + [Fact] + public async Task SaveAsync_PersistsMessage() + { + var msg = new OutboxMessage + { + Id = Guid.NewGuid(), EventType = "Test.Event", EventPayload = "{}", + CreatedAtUtc = DateTimeOffset.UtcNow + }; + await _store.SaveAsync(msg); + var count = await _dbContext.Set().CountAsync(); + count.Should().Be(1); + } + + [Fact] + public async Task GetPendingAsync_ExcludesProcessedDeadLetteredAndMaxRetries() + { + var pending = new OutboxMessage + { + Id = Guid.NewGuid(), EventType = "T", EventPayload = "{}", + CreatedAtUtc = DateTimeOffset.UtcNow, RetryCount = 0 + }; + var processed = new OutboxMessage + { + Id = Guid.NewGuid(), EventType = "T", EventPayload = "{}", + CreatedAtUtc = DateTimeOffset.UtcNow, ProcessedAtUtc = DateTimeOffset.UtcNow + }; + var deadLettered = new OutboxMessage + { + Id = Guid.NewGuid(), EventType = "T", EventPayload = "{}", + CreatedAtUtc = DateTimeOffset.UtcNow, DeadLetteredAtUtc = DateTimeOffset.UtcNow + }; + var maxedOut = new OutboxMessage + { + Id = Guid.NewGuid(), EventType = "T", EventPayload = "{}", + CreatedAtUtc = DateTimeOffset.UtcNow, RetryCount = 3 // == MaxRetries + }; + _dbContext.Set().AddRange(pending, processed, deadLettered, maxedOut); + await _dbContext.SaveChangesAsync(); + + var result = await _store.GetPendingAsync(100); + result.Should().HaveCount(1); + result[0].Id.Should().Be(pending.Id); + } + + [Fact] + public async Task MarkProcessedAsync_SetsProcessedAtUtc() + { + var msg = new OutboxMessage + { + Id = Guid.NewGuid(), EventType = "T", EventPayload = "{}", + CreatedAtUtc = DateTimeOffset.UtcNow + }; + _dbContext.Set().Add(msg); + await _dbContext.SaveChangesAsync(); + + await _store.MarkProcessedAsync(msg.Id); + + var updated = await _dbContext.Set().FindAsync(msg.Id); + updated!.ProcessedAtUtc.Should().NotBeNull(); + } + + [Fact] + public async Task MarkFailedAsync_IncrementsRetryCountAndSetsError() + { + var msg = new OutboxMessage + { + Id = Guid.NewGuid(), EventType = "T", EventPayload = "{}", + CreatedAtUtc = DateTimeOffset.UtcNow, RetryCount = 1 + }; + _dbContext.Set().Add(msg); + await _dbContext.SaveChangesAsync(); + + await _store.MarkFailedAsync(msg.Id, "error"); + + var updated = await _dbContext.Set().FindAsync(msg.Id); + updated!.RetryCount.Should().Be(2); + updated.ErrorMessage.Should().Be("error"); + } + + [Fact] + public async Task MarkDeadLetteredAsync_SetsDeadLetteredAtUtc() + { + var msg = new OutboxMessage + { + Id = Guid.NewGuid(), EventType = "T", EventPayload = "{}", + CreatedAtUtc = DateTimeOffset.UtcNow + }; + _dbContext.Set().Add(msg); + await _dbContext.SaveChangesAsync(); + + await _store.MarkDeadLetteredAsync(msg.Id); + + var updated = await _dbContext.Set().FindAsync(msg.Id); + updated!.DeadLetteredAtUtc.Should().NotBeNull(); + } + + public void Dispose() => _dbContext.Dispose(); +} +``` + +- [ ] **Step 5: Run tests** + +Run: `dotnet test Tests/RCommon.EfCore.Tests/ --filter "FullyQualifiedName~EFCoreOutboxStoreTests"` +Expected: All tests pass. + +Note: The test project may need a `Microsoft.EntityFrameworkCore.Sqlite` PackageReference for the SQLite in-memory provider. Add it if missing. + +- [ ] **Step 6: Build EF Core project** + +Run: `dotnet build Src/RCommon.EfCore/RCommon.EfCore.csproj` +Expected: 0 errors. + +- [ ] **Step 7: Commit** + +```bash +git add Src/RCommon.EfCore/Outbox/ Tests/RCommon.EfCore.Tests/EFCoreOutboxStoreTests.cs +git commit -m "feat: add EFCoreOutboxStore with IDataStoreFactory, RetryCount filter, and SQLite tests" +``` + +--- + +## Task 9: Dapper Outbox Store + Tests + +**Files:** +- Create: `Src/RCommon.Dapper/Outbox/DapperOutboxStore.cs` +- Create: `Tests/RCommon.Dapper.Tests/DapperOutboxStoreTests.cs` + +**Pattern:** Dapper repositories resolve `RDbConnection` via `IDataStoreFactory.Resolve(dataStoreName)`, then call `dataStore.GetDbConnection()` to get a `DbConnection`. Connection state is checked and opened if closed. The outbox store follows the same pattern. + +**Atomicity note:** Each call to `GetDbConnection()` creates a new `DbConnection` (this is the existing Dapper repository pattern). When opened within an active `TransactionScope`, each connection enlists in the ambient transaction. On SQL Server, multiple connections to the same database within a `TransactionScope` may promote to a distributed transaction (MSDTC). This is the same behavior as the existing Dapper repositories and is not unique to the outbox store. On platforms where MSDTC is unavailable, users should ensure a single connection is reused, or use the EF Core outbox store instead. + +**SQL Server dialect:** The raw SQL uses SQL Server syntax (`SELECT TOP`, `[TableName]` bracket quoting). For PostgreSQL or MySQL users, a custom `IOutboxStore` implementation with dialect-specific SQL would be needed. This matches the existing Dapper repository pattern which also uses SQL Server syntax. + +- [ ] **Step 1: Implement DapperOutboxStore using IDataStoreFactory** + +```csharp +// Src/RCommon.Dapper/Outbox/DapperOutboxStore.cs +using Dapper; +using Microsoft.Extensions.Options; +using RCommon.Persistence.Outbox; +using RCommon.Persistence.Sql; +using System.Data; +using System.Data.Common; + +namespace RCommon.Persistence.Dapper.Outbox; + +public class DapperOutboxStore : IOutboxStore +{ + private readonly IDataStoreFactory _dataStoreFactory; + private readonly string _dataStoreName; + private readonly string _tableName; + private readonly int _maxRetries; + + public DapperOutboxStore( + IDataStoreFactory dataStoreFactory, + IOptions defaultDataStoreOptions, + IOptions outboxOptions) + { + _dataStoreFactory = dataStoreFactory ?? throw new ArgumentNullException(nameof(dataStoreFactory)); + _dataStoreName = defaultDataStoreOptions?.Value?.DefaultDataStoreName + ?? throw new ArgumentNullException(nameof(defaultDataStoreOptions)); + _tableName = outboxOptions?.Value?.TableName ?? "__OutboxMessages"; + _maxRetries = outboxOptions?.Value?.MaxRetries ?? 5; + } + + private async Task GetOpenConnectionAsync(CancellationToken cancellationToken) + { + var dataStore = _dataStoreFactory.Resolve(_dataStoreName); + var connection = dataStore.GetDbConnection(); + if (connection.State == ConnectionState.Closed) + { + await connection.OpenAsync(cancellationToken).ConfigureAwait(false); + } + return connection; + } + + public async Task SaveAsync(IOutboxMessage message, CancellationToken cancellationToken = default) + { + await using var db = await GetOpenConnectionAsync(cancellationToken).ConfigureAwait(false); + var sql = $@"INSERT INTO [{_tableName}] (Id, EventType, EventPayload, CreatedAtUtc, ProcessedAtUtc, DeadLetteredAtUtc, ErrorMessage, RetryCount, CorrelationId, TenantId) + VALUES (@Id, @EventType, @EventPayload, @CreatedAtUtc, @ProcessedAtUtc, @DeadLetteredAtUtc, @ErrorMessage, @RetryCount, @CorrelationId, @TenantId)"; + await db.ExecuteAsync(new CommandDefinition(sql, message, cancellationToken: cancellationToken)).ConfigureAwait(false); + } + + public async Task> GetPendingAsync(int batchSize, CancellationToken cancellationToken = default) + { + await using var db = await GetOpenConnectionAsync(cancellationToken).ConfigureAwait(false); + var sql = $@"SELECT TOP (@BatchSize) * FROM [{_tableName}] + WHERE ProcessedAtUtc IS NULL AND DeadLetteredAtUtc IS NULL AND RetryCount < @MaxRetries + ORDER BY CreatedAtUtc ASC"; + var result = await db.QueryAsync( + new CommandDefinition(sql, new { BatchSize = batchSize, MaxRetries = _maxRetries }, + cancellationToken: cancellationToken)).ConfigureAwait(false); + return result.ToList(); + } + + public async Task MarkProcessedAsync(Guid messageId, CancellationToken cancellationToken = default) + { + await using var db = await GetOpenConnectionAsync(cancellationToken).ConfigureAwait(false); + var sql = $"UPDATE [{_tableName}] SET ProcessedAtUtc = @Now WHERE Id = @Id"; + await db.ExecuteAsync(new CommandDefinition(sql, + new { Id = messageId, Now = DateTimeOffset.UtcNow }, + cancellationToken: cancellationToken)).ConfigureAwait(false); + } + + public async Task MarkFailedAsync(Guid messageId, string error, CancellationToken cancellationToken = default) + { + await using var db = await GetOpenConnectionAsync(cancellationToken).ConfigureAwait(false); + var sql = $"UPDATE [{_tableName}] SET ErrorMessage = @Error, RetryCount = RetryCount + 1 WHERE Id = @Id"; + await db.ExecuteAsync(new CommandDefinition(sql, + new { Id = messageId, Error = error }, + cancellationToken: cancellationToken)).ConfigureAwait(false); + } + + public async Task MarkDeadLetteredAsync(Guid messageId, CancellationToken cancellationToken = default) + { + await using var db = await GetOpenConnectionAsync(cancellationToken).ConfigureAwait(false); + var sql = $"UPDATE [{_tableName}] SET DeadLetteredAtUtc = @Now WHERE Id = @Id"; + await db.ExecuteAsync(new CommandDefinition(sql, + new { Id = messageId, Now = DateTimeOffset.UtcNow }, + cancellationToken: cancellationToken)).ConfigureAwait(false); + } + + public async Task DeleteProcessedAsync(TimeSpan olderThan, CancellationToken cancellationToken = default) + { + await using var db = await GetOpenConnectionAsync(cancellationToken).ConfigureAwait(false); + var cutoff = DateTimeOffset.UtcNow - olderThan; + var sql = $"DELETE FROM [{_tableName}] WHERE ProcessedAtUtc IS NOT NULL AND ProcessedAtUtc < @Cutoff"; + await db.ExecuteAsync(new CommandDefinition(sql, + new { Cutoff = cutoff }, + cancellationToken: cancellationToken)).ConfigureAwait(false); + } + + public async Task DeleteDeadLetteredAsync(TimeSpan olderThan, CancellationToken cancellationToken = default) + { + await using var db = await GetOpenConnectionAsync(cancellationToken).ConfigureAwait(false); + var cutoff = DateTimeOffset.UtcNow - olderThan; + var sql = $"DELETE FROM [{_tableName}] WHERE DeadLetteredAtUtc IS NOT NULL AND DeadLetteredAtUtc < @Cutoff"; + await db.ExecuteAsync(new CommandDefinition(sql, + new { Cutoff = cutoff }, + cancellationToken: cancellationToken)).ConfigureAwait(false); + } +} +``` + +- [ ] **Step 2: Write DapperOutboxStore tests** + +These tests verify the SQL generation and store operations using a mock `IDataStoreFactory` and mock `RDbConnection`. For a full integration test, a real SQLite or SQL Server connection would be needed, but mock-based tests verify the interaction pattern. + +```csharp +// Tests/RCommon.Dapper.Tests/DapperOutboxStoreTests.cs +using FluentAssertions; +using Microsoft.Extensions.Options; +using Moq; +using RCommon.Persistence.Dapper.Outbox; +using RCommon.Persistence.Outbox; +using RCommon.Persistence.Sql; +using System.Data; +using System.Data.Common; +using Xunit; + +namespace RCommon.Dapper.Tests; + +public class DapperOutboxStoreTests +{ + [Fact] + public void Constructor_ThrowsOnNullDataStoreFactory() + { + var act = () => new DapperOutboxStore( + null!, + Options.Create(new DefaultDataStoreOptions { DefaultDataStoreName = "test" }), + Options.Create(new OutboxOptions())); + + act.Should().Throw(); + } + + [Fact] + public void Constructor_ThrowsOnNullDefaultDataStoreOptions() + { + var factoryMock = new Mock(); + var act = () => new DapperOutboxStore( + factoryMock.Object, + Options.Create(new DefaultDataStoreOptions()), + Options.Create(new OutboxOptions())); + + act.Should().Throw(); + } + + [Fact] + public void Constructor_SucceedsWithValidParameters() + { + var factoryMock = new Mock(); + var store = new DapperOutboxStore( + factoryMock.Object, + Options.Create(new DefaultDataStoreOptions { DefaultDataStoreName = "test" }), + Options.Create(new OutboxOptions())); + + store.Should().NotBeNull(); + } +} +``` + +- [ ] **Step 3: Build** + +Run: `dotnet build Src/RCommon.Dapper/RCommon.Dapper.csproj` +Expected: 0 errors. + +- [ ] **Step 4: Run tests** + +Run: `dotnet test Tests/RCommon.Dapper.Tests/ --filter "FullyQualifiedName~DapperOutboxStoreTests"` +Expected: All tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add Src/RCommon.Dapper/Outbox/ Tests/RCommon.Dapper.Tests/DapperOutboxStoreTests.cs +git commit -m "feat: add DapperOutboxStore with IDataStoreFactory and RetryCount filter" +``` + +--- + +## Task 10: Linq2Db Outbox Store + Tests + +**Files:** +- Create: `Src/RCommon.Linq2Db/Outbox/Linq2DbOutboxStore.cs` +- Create: `Tests/RCommon.Linq2Db.Tests/Linq2DbOutboxStoreTests.cs` + +**Pattern:** Linq2Db repositories resolve `RCommonDataConnection` via `IDataStoreFactory.Resolve(dataStoreName)`. The outbox store follows the same pattern. + +- [ ] **Step 1: Implement Linq2DbOutboxStore using IDataStoreFactory** + +```csharp +// Src/RCommon.Linq2Db/Outbox/Linq2DbOutboxStore.cs +using LinqToDB; +using Microsoft.Extensions.Options; +using RCommon.Persistence.Outbox; + +namespace RCommon.Persistence.Linq2Db.Outbox; + +public class Linq2DbOutboxStore : IOutboxStore +{ + private readonly IDataStoreFactory _dataStoreFactory; + private readonly string _dataStoreName; + private readonly string _tableName; + private readonly int _maxRetries; + + public Linq2DbOutboxStore( + IDataStoreFactory dataStoreFactory, + IOptions defaultDataStoreOptions, + IOptions outboxOptions) + { + _dataStoreFactory = dataStoreFactory ?? throw new ArgumentNullException(nameof(dataStoreFactory)); + _dataStoreName = defaultDataStoreOptions?.Value?.DefaultDataStoreName + ?? throw new ArgumentNullException(nameof(defaultDataStoreOptions)); + _tableName = outboxOptions?.Value?.TableName ?? "__OutboxMessages"; + _maxRetries = outboxOptions?.Value?.MaxRetries ?? 5; + } + + private RCommonDataConnection DataConnection + => _dataStoreFactory.Resolve(_dataStoreName); + + private ITable Table + => DataConnection.GetTable().TableName(_tableName); + + public async Task SaveAsync(IOutboxMessage message, CancellationToken cancellationToken = default) + { + var entity = message as OutboxMessage ?? new OutboxMessage + { + Id = message.Id, + EventType = message.EventType, + EventPayload = message.EventPayload, + CreatedAtUtc = message.CreatedAtUtc, + ProcessedAtUtc = message.ProcessedAtUtc, + DeadLetteredAtUtc = message.DeadLetteredAtUtc, + ErrorMessage = message.ErrorMessage, + RetryCount = message.RetryCount, + CorrelationId = message.CorrelationId, + TenantId = message.TenantId + }; + await DataConnection.InsertAsync(entity, _tableName, token: cancellationToken).ConfigureAwait(false); + } + + public async Task> GetPendingAsync(int batchSize, CancellationToken cancellationToken = default) + { + return await Table + .Where(m => m.ProcessedAtUtc == null + && m.DeadLetteredAtUtc == null + && m.RetryCount < _maxRetries) + .OrderBy(m => m.CreatedAtUtc) + .Take(batchSize) + .ToListAsync(cancellationToken) + .ConfigureAwait(false); + } + + public async Task MarkProcessedAsync(Guid messageId, CancellationToken cancellationToken = default) + { + await Table + .Where(m => m.Id == messageId) + .Set(m => m.ProcessedAtUtc, DateTimeOffset.UtcNow) + .UpdateAsync(cancellationToken) + .ConfigureAwait(false); + } + + public async Task MarkFailedAsync(Guid messageId, string error, CancellationToken cancellationToken = default) + { + await Table + .Where(m => m.Id == messageId) + .Set(m => m.ErrorMessage, error) + .Set(m => m.RetryCount, m => m.RetryCount + 1) + .UpdateAsync(cancellationToken) + .ConfigureAwait(false); + } + + public async Task MarkDeadLetteredAsync(Guid messageId, CancellationToken cancellationToken = default) + { + await Table + .Where(m => m.Id == messageId) + .Set(m => m.DeadLetteredAtUtc, DateTimeOffset.UtcNow) + .UpdateAsync(cancellationToken) + .ConfigureAwait(false); + } + + public async Task DeleteProcessedAsync(TimeSpan olderThan, CancellationToken cancellationToken = default) + { + var cutoff = DateTimeOffset.UtcNow - olderThan; + await Table + .Where(m => m.ProcessedAtUtc != null && m.ProcessedAtUtc < cutoff) + .DeleteAsync(cancellationToken) + .ConfigureAwait(false); + } + + public async Task DeleteDeadLetteredAsync(TimeSpan olderThan, CancellationToken cancellationToken = default) + { + var cutoff = DateTimeOffset.UtcNow - olderThan; + await Table + .Where(m => m.DeadLetteredAtUtc != null && m.DeadLetteredAtUtc < cutoff) + .DeleteAsync(cancellationToken) + .ConfigureAwait(false); + } +} +``` + +- [ ] **Step 2: Write Linq2DbOutboxStore tests** + +```csharp +// Tests/RCommon.Linq2Db.Tests/Linq2DbOutboxStoreTests.cs +using FluentAssertions; +using Microsoft.Extensions.Options; +using Moq; +using RCommon.Persistence.Linq2Db.Outbox; +using RCommon.Persistence.Outbox; +using Xunit; + +namespace RCommon.Linq2Db.Tests; + +public class Linq2DbOutboxStoreTests +{ + [Fact] + public void Constructor_ThrowsOnNullDataStoreFactory() + { + var act = () => new Linq2DbOutboxStore( + null!, + Options.Create(new DefaultDataStoreOptions { DefaultDataStoreName = "test" }), + Options.Create(new OutboxOptions())); + + act.Should().Throw(); + } + + [Fact] + public void Constructor_ThrowsOnNullDefaultDataStoreOptions() + { + var factoryMock = new Mock(); + var act = () => new Linq2DbOutboxStore( + factoryMock.Object, + Options.Create(new DefaultDataStoreOptions()), + Options.Create(new OutboxOptions())); + + act.Should().Throw(); + } + + [Fact] + public void Constructor_SucceedsWithValidParameters() + { + var factoryMock = new Mock(); + var store = new Linq2DbOutboxStore( + factoryMock.Object, + Options.Create(new DefaultDataStoreOptions { DefaultDataStoreName = "test" }), + Options.Create(new OutboxOptions())); + + store.Should().NotBeNull(); + } +} +``` + +- [ ] **Step 3: Build** + +Run: `dotnet build Src/RCommon.Linq2Db/RCommon.Linq2Db.csproj` +Expected: 0 errors. + +- [ ] **Step 4: Run tests** + +Run: `dotnet test Tests/RCommon.Linq2Db.Tests/ --filter "FullyQualifiedName~Linq2DbOutboxStoreTests"` +Expected: All tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add Src/RCommon.Linq2Db/Outbox/ Tests/RCommon.Linq2Db.Tests/Linq2DbOutboxStoreTests.cs +git commit -m "feat: add Linq2DbOutboxStore with IDataStoreFactory and RetryCount filter" +``` + +--- + +## Task 11: MassTransit.Outbox Project + +**Files:** +- Create: `Src/RCommon.MassTransit.Outbox/RCommon.MassTransit.Outbox.csproj` +- Create: `Src/RCommon.MassTransit.Outbox/IMassTransitOutboxBuilder.cs` +- Create: `Src/RCommon.MassTransit.Outbox/MassTransitOutboxBuilder.cs` +- Create: `Src/RCommon.MassTransit.Outbox/MassTransitOutboxBuilderExtensions.cs` +- Create: `Tests/RCommon.MassTransit.Outbox.Tests/RCommon.MassTransit.Outbox.Tests.csproj` +- Create: `Tests/RCommon.MassTransit.Outbox.Tests/MassTransitOutboxBuilderTests.cs` + +- [ ] **Step 1: Create csproj** + +Create `Src/RCommon.MassTransit.Outbox/RCommon.MassTransit.Outbox.csproj` following the same pattern as `RCommon.MassTransit.StateMachines.csproj` (multi-target net8.0;net9.0;net10.0, standard package metadata). References: `RCommon.MassTransit`, `RCommon.Persistence`. NuGet: `MassTransit.EntityFrameworkCore`. + +- [ ] **Step 2: Create IMassTransitOutboxBuilder** + +```csharp +// Src/RCommon.MassTransit.Outbox/IMassTransitOutboxBuilder.cs +using MassTransit; + +namespace RCommon.MassTransit.Outbox; + +public interface IMassTransitOutboxBuilder +{ + IMassTransitOutboxBuilder UsePostgres(); + IMassTransitOutboxBuilder UseSqlServer(); + IMassTransitOutboxBuilder UseBusOutbox(Action? configure = null); +} +``` + +- [ ] **Step 3: Create MassTransitOutboxBuilder implementation** + +```csharp +// Src/RCommon.MassTransit.Outbox/MassTransitOutboxBuilder.cs +using MassTransit; + +namespace RCommon.MassTransit.Outbox; + +public class MassTransitOutboxBuilder : IMassTransitOutboxBuilder +{ + private readonly IEntityFrameworkOutboxConfigurator _configurator; + + public MassTransitOutboxBuilder(IEntityFrameworkOutboxConfigurator configurator) + { + _configurator = configurator ?? throw new ArgumentNullException(nameof(configurator)); + } + + public IMassTransitOutboxBuilder UsePostgres() + { + _configurator.UsePostgres(); + return this; + } + + public IMassTransitOutboxBuilder UseSqlServer() + { + _configurator.UseSqlServer(); + return this; + } + + public IMassTransitOutboxBuilder UseBusOutbox(Action? configure = null) + { + _configurator.UseBusOutbox(configure); + return this; + } +} +``` + +- [ ] **Step 4: Create MassTransitOutboxBuilderExtensions** + +```csharp +// Src/RCommon.MassTransit.Outbox/MassTransitOutboxBuilderExtensions.cs +using Microsoft.EntityFrameworkCore; +using RCommon.MassTransit; +using RCommon.MassTransit.Outbox; + +namespace RCommon; + +public static class MassTransitOutboxBuilderExtensions +{ + public static IMassTransitEventHandlingBuilder AddOutbox( + this IMassTransitEventHandlingBuilder builder, + Action? configure = null) + where TDbContext : DbContext + { + // Delegate to MassTransit's native EntityFramework outbox + builder.AddEntityFrameworkOutbox(o => + { + var outboxBuilder = new MassTransitOutboxBuilder(o); + configure?.Invoke(outboxBuilder); + }); + return builder; + } +} +``` + +- [ ] **Step 5: Create test project and DI test** + +Create `Tests/RCommon.MassTransit.Outbox.Tests/RCommon.MassTransit.Outbox.Tests.csproj` referencing `RCommon.MassTransit.Outbox`, `RCommon.Core`, and `Microsoft.Extensions.DependencyInjection`. + +```csharp +// Tests/RCommon.MassTransit.Outbox.Tests/MassTransitOutboxBuilderTests.cs +using FluentAssertions; +using MassTransit; +using Moq; +using RCommon.MassTransit.Outbox; +using Xunit; + +namespace RCommon.MassTransit.Outbox.Tests; + +public class MassTransitOutboxBuilderTests +{ + [Fact] + public void UseSqlServer_DelegatesToConfigurator() + { + var configuratorMock = new Mock(); + var builder = new MassTransitOutboxBuilder(configuratorMock.Object); + + var result = builder.UseSqlServer(); + + result.Should().BeSameAs(builder); + configuratorMock.Verify(c => c.UseSqlServer(), Times.Once); + } + + [Fact] + public void UsePostgres_DelegatesToConfigurator() + { + var configuratorMock = new Mock(); + var builder = new MassTransitOutboxBuilder(configuratorMock.Object); + + var result = builder.UsePostgres(); + + result.Should().BeSameAs(builder); + configuratorMock.Verify(c => c.UsePostgres(), Times.Once); + } + + [Fact] + public void UseBusOutbox_DelegatesToConfigurator() + { + var configuratorMock = new Mock(); + var builder = new MassTransitOutboxBuilder(configuratorMock.Object); + + var result = builder.UseBusOutbox(); + + result.Should().BeSameAs(builder); + configuratorMock.Verify(c => c.UseBusOutbox(It.IsAny>()), Times.Once); + } + + [Fact] + public void Constructor_ThrowsOnNull() + { + var act = () => new MassTransitOutboxBuilder(null!); + act.Should().Throw(); + } +} +``` + +- [ ] **Step 6: Build and test** + +Run: `dotnet build Src/RCommon.MassTransit.Outbox/ && dotnet test Tests/RCommon.MassTransit.Outbox.Tests/` +Expected: Build succeeds, tests pass. + +- [ ] **Step 7: Commit** + +```bash +git add Src/RCommon.MassTransit.Outbox/ Tests/RCommon.MassTransit.Outbox.Tests/ +git commit -m "feat: add RCommon.MassTransit.Outbox wrapping native EF Core outbox" +``` + +--- + +## Task 12: Wolverine.Outbox Project + +**Files:** +- Create: `Src/RCommon.Wolverine.Outbox/RCommon.Wolverine.Outbox.csproj` +- Create: `Src/RCommon.Wolverine.Outbox/IWolverineOutboxBuilder.cs` +- Create: `Src/RCommon.Wolverine.Outbox/WolverineOutboxBuilder.cs` +- Create: `Src/RCommon.Wolverine.Outbox/WolverineOutboxBuilderExtensions.cs` +- Create: `Tests/RCommon.Wolverine.Outbox.Tests/RCommon.Wolverine.Outbox.Tests.csproj` +- Create: `Tests/RCommon.Wolverine.Outbox.Tests/WolverineOutboxBuilderTests.cs` + +- [ ] **Step 1: Create csproj** + +Create `Src/RCommon.Wolverine.Outbox/RCommon.Wolverine.Outbox.csproj` (multi-target, standard metadata). References: `RCommon.Wolverine`, `RCommon.Persistence`. NuGet: `WolverineFx.EntityFrameworkCore`. + +- [ ] **Step 2: Create IWolverineOutboxBuilder** + +```csharp +// Src/RCommon.Wolverine.Outbox/IWolverineOutboxBuilder.cs +namespace RCommon.Wolverine.Outbox; + +public interface IWolverineOutboxBuilder +{ + IWolverineOutboxBuilder UseEntityFrameworkCoreTransactions(); +} +``` + +- [ ] **Step 3: Create WolverineOutboxBuilder** + +```csharp +// Src/RCommon.Wolverine.Outbox/WolverineOutboxBuilder.cs +using Wolverine; +using Wolverine.EntityFrameworkCore; + +namespace RCommon.Wolverine.Outbox; + +public class WolverineOutboxBuilder : IWolverineOutboxBuilder +{ + private readonly WolverineOptions _wolverineOptions; + + public WolverineOutboxBuilder(WolverineOptions wolverineOptions) + { + _wolverineOptions = wolverineOptions ?? throw new ArgumentNullException(nameof(wolverineOptions)); + } + + public IWolverineOutboxBuilder UseEntityFrameworkCoreTransactions() + { + _wolverineOptions.UseEntityFrameworkCoreTransactions(); + return this; + } +} +``` + +- [ ] **Step 4: Create WolverineOutboxBuilderExtensions** + +```csharp +// Src/RCommon.Wolverine.Outbox/WolverineOutboxBuilderExtensions.cs +using Microsoft.Extensions.DependencyInjection; +using Wolverine; +using Wolverine.EntityFrameworkCore; +using RCommon.Wolverine; +using RCommon.Wolverine.Outbox; + +namespace RCommon; + +public static class WolverineOutboxBuilderExtensions +{ + public static IWolverineEventHandlingBuilder AddOutbox( + this IWolverineEventHandlingBuilder builder, + Action? configure = null) + { + // Post-configure Wolverine options through IServiceCollection + builder.Services.ConfigureWolverine(opts => + { + var outboxBuilder = new WolverineOutboxBuilder(opts); + configure?.Invoke(outboxBuilder); + }); + return builder; + } +} +``` + +Note: `ConfigureWolverine` is a WolverineFx extension on `IServiceCollection`. If unavailable in the installed version, use `services.AddOptions().Configure(...)` instead. + +- [ ] **Step 5: Create test project and DI test** + +Create `Tests/RCommon.Wolverine.Outbox.Tests/RCommon.Wolverine.Outbox.Tests.csproj` referencing `RCommon.Wolverine.Outbox`, `RCommon.Core`, and `Microsoft.Extensions.DependencyInjection`. + +```csharp +// Tests/RCommon.Wolverine.Outbox.Tests/WolverineOutboxBuilderTests.cs +using FluentAssertions; +using RCommon.Wolverine.Outbox; +using Xunit; + +namespace RCommon.Wolverine.Outbox.Tests; + +public class WolverineOutboxBuilderTests +{ + [Fact] + public void Constructor_ThrowsOnNull() + { + var act = () => new WolverineOutboxBuilder(null!); + act.Should().Throw(); + } +} +``` + +- [ ] **Step 5: Build and test** + +Run: `dotnet build Src/RCommon.Wolverine.Outbox/ && dotnet test Tests/RCommon.Wolverine.Outbox.Tests/` +Expected: Build succeeds, tests pass. + +- [ ] **Step 6: Commit** + +```bash +git add Src/RCommon.Wolverine.Outbox/ Tests/RCommon.Wolverine.Outbox.Tests/ +git commit -m "feat: add RCommon.Wolverine.Outbox wrapping native durable messaging" +``` + +--- + +## Task 13: Solution File + Full Build + Full Test + +**Files:** +- Modify: `Src/RCommon.sln` + +- [ ] **Step 1: Add all new projects to solution** + +```bash +cd Src && dotnet sln RCommon.sln add RCommon.MassTransit.Outbox/RCommon.MassTransit.Outbox.csproj && dotnet sln RCommon.sln add RCommon.Wolverine.Outbox/RCommon.Wolverine.Outbox.csproj && dotnet sln RCommon.sln add ../Tests/RCommon.MassTransit.Outbox.Tests/RCommon.MassTransit.Outbox.Tests.csproj && dotnet sln RCommon.sln add ../Tests/RCommon.Wolverine.Outbox.Tests/RCommon.Wolverine.Outbox.Tests.csproj && cd .. +``` + +- [ ] **Step 2: Full solution build** + +Run: `dotnet build Src/RCommon.sln` +Expected: All projects build with 0 errors. + +- [ ] **Step 3: Run ALL tests** + +Run: `dotnet test Src/RCommon.sln` +Expected: All test projects pass. No regressions in existing tests. + +- [ ] **Step 4: Commit** + +```bash +git add Src/RCommon.sln +git commit -m "chore: add outbox projects to solution file" +``` + +--- + +## Verification Checklist + +After all tasks are complete, verify: + +- [ ] `dotnet build Src/RCommon.sln` — 0 errors +- [ ] `dotnet test Src/RCommon.sln` — all pass +- [ ] Existing tests (UnitOfWork, EventSubscriptionManager, Mediatr behaviors) still pass unchanged +- [ ] Non-outbox users have identical behavior (PersistEventsAsync is no-op) +- [ ] `IEntityEventTracker.EmitTransactionalEventsAsync(CancellationToken)` compiles with no arguments (default CT) diff --git a/docs/superpowers/plans/2026-03-23-outbox-v2.md b/docs/superpowers/plans/2026-03-23-outbox-v2.md new file mode 100644 index 00000000..28303ea9 --- /dev/null +++ b/docs/superpowers/plans/2026-03-23-outbox-v2.md @@ -0,0 +1,1425 @@ +# Outbox V2 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add exponential backoff, distributed locking, dead letter replay, and inbox/idempotency to the RCommon transactional outbox. + +**Architecture:** Break `IOutboxStore`/`IOutboxMessage` interfaces directly. Replace `GetPendingAsync` with atomic `ClaimAsync` using provider-specific SQL (SQL Server CTE + `UPDLOCK, ROWLOCK, READPAST`; PostgreSQL `FOR UPDATE SKIP LOCKED`). Add `IInboxStore` as opt-in separate table. `OutboxEventRouter` shifts to retained-event dispatch; `OutboxProcessingService` gains instance identity and backoff-aware failure handling. + +**Tech Stack:** .NET (net8.0/net9.0/net10.0), EF Core 9, Dapper, Linq2Db, xUnit 2.9.3, FluentAssertions 8.2.0, Moq 4.20.72 + +**Spec:** `docs/superpowers/specs/2026-03-23-outbox-v2-design.md` + +--- + +## File Structure + +### New files + +| File | Responsibility | +|------|---------------| +| `Src/RCommon.Persistence/Outbox/IBackoffStrategy.cs` | Single-method interface for retry delay computation | +| `Src/RCommon.Persistence/Outbox/ExponentialBackoffStrategy.cs` | Default implementation: `base * 2^retryCount`, capped | +| `Src/RCommon.Persistence/Outbox/ILockStatementProvider.cs` | Marker interface with `ProviderName` for Dapper/Linq2Db | +| `Src/RCommon.Persistence/Outbox/SqlServerLockStatementProvider.cs` | Returns `"SqlServer"` | +| `Src/RCommon.Persistence/Outbox/PostgreSqlLockStatementProvider.cs` | Returns `"PostgreSql"` | +| `Src/RCommon.Persistence/Inbox/IInboxMessage.cs` | Interface: `MessageId`, `EventType`, `ConsumerType`, `ReceivedAtUtc` | +| `Src/RCommon.Persistence/Inbox/InboxMessage.cs` | Concrete entity | +| `Src/RCommon.Persistence/Inbox/IInboxStore.cs` | Interface: `ExistsAsync`, `RecordAsync`, `CleanupAsync` | +| `Src/RCommon.Persistence/Inbox/InboxPersistenceBuilderExtensions.cs` | `AddInbox()` extension on `IPersistenceBuilder` | +| `Src/RCommon.EfCore/Inbox/EFCoreInboxStore.cs` | EF Core implementation of `IInboxStore` | +| `Src/RCommon.EfCore/Inbox/InboxMessageConfiguration.cs` | EF Core entity config with composite PK | +| `Src/RCommon.Dapper/Inbox/DapperInboxStore.cs` | Dapper implementation of `IInboxStore` | +| `Src/RCommon.Linq2Db/Inbox/Linq2DbInboxStore.cs` | Linq2Db implementation of `IInboxStore` | +| `Tests/RCommon.Persistence.Tests/ExponentialBackoffStrategyTests.cs` | Backoff computation tests | +| `Tests/RCommon.EfCore.Tests/EFCoreInboxStoreTests.cs` | EF Core inbox store tests | +| `Tests/RCommon.Dapper.Tests/DapperInboxStoreTests.cs` | Dapper inbox store tests | +| `Tests/RCommon.Linq2Db.Tests/Linq2DbInboxStoreTests.cs` | Linq2Db inbox store tests | + +### Modified files + +| File | Changes | +|------|---------| +| `Src/RCommon.Persistence/Outbox/IOutboxMessage.cs` | Add `NextRetryAtUtc`, `LockedByInstanceId`, `LockedUntilUtc` | +| `Src/RCommon.Persistence/Outbox/OutboxMessage.cs` | Add matching properties | +| `Src/RCommon.Persistence/Outbox/IOutboxStore.cs` | Remove `GetPendingAsync`, change `MarkFailedAsync`, add `ClaimAsync`/`GetDeadLettersAsync`/`ReplayDeadLetterAsync` | +| `Src/RCommon.Persistence/Outbox/OutboxOptions.cs` | Add `LockDuration`, `BackoffBaseDelay`, `BackoffMaxDelay`, `BackoffMultiplier`, `InboxTableName` | +| `Src/RCommon.Persistence/Outbox/OutboxEventRouter.cs` | Retained-event dispatch pattern, remove store reads | +| `Src/RCommon.Persistence/Outbox/OutboxProcessingService.cs` | Instance ID, `ClaimAsync`, backoff, inbox auto-check | +| `Src/RCommon.Persistence/Outbox/OutboxPersistenceBuilderExtensions.cs` | Register `IBackoffStrategy` | +| `Src/RCommon.EfCore/Outbox/EFCoreOutboxStore.cs` | Implement `ClaimAsync`, `GetDeadLettersAsync`, `ReplayDeadLetterAsync`, update `MarkFailedAsync` | +| `Src/RCommon.EfCore/Outbox/OutboxMessageConfiguration.cs` | New columns, updated index, dead letter index | +| `Src/RCommon.EfCore/Outbox/ModelBuilderExtensions.cs` | Add inbox configuration | +| `Src/RCommon.Dapper/Outbox/DapperOutboxStore.cs` | Same store updates with raw SQL | +| `Src/RCommon.Linq2Db/Outbox/Linq2DbOutboxStore.cs` | Same store updates with LINQ + raw SQL | +| `Tests/RCommon.Persistence.Tests/OutboxProcessingServiceTests.cs` | Update mocks for `ClaimAsync`/backoff | +| `Tests/RCommon.Persistence.Tests/OutboxEventRouterTests.cs` | Update for retained-event dispatch | +| `Tests/RCommon.Persistence.Tests/OutboxEntityEventTrackerTests.cs` | Update mocks | +| `Tests/RCommon.Persistence.Tests/OutboxConcurrencyTests.cs` | Update mocks | +| `Tests/RCommon.EfCore.Tests/EFCoreOutboxStoreTests.cs` | Rewrite for `ClaimAsync` + new methods | +| `Tests/RCommon.Dapper.Tests/DapperOutboxStoreTests.cs` | Update constructor tests for `ILockStatementProvider` | +| `Tests/RCommon.Linq2Db.Tests/Linq2DbOutboxStoreTests.cs` | Update constructor tests for `ILockStatementProvider` | + +--- + +### Task 1: Backoff Strategy + OutboxOptions + +**Files:** +- Create: `Src/RCommon.Persistence/Outbox/IBackoffStrategy.cs` +- Create: `Src/RCommon.Persistence/Outbox/ExponentialBackoffStrategy.cs` +- Modify: `Src/RCommon.Persistence/Outbox/OutboxOptions.cs` +- Create: `Tests/RCommon.Persistence.Tests/ExponentialBackoffStrategyTests.cs` + +**Context:** These are new abstractions with no dependencies on existing code. Start here because everything else depends on `OutboxOptions` and `IBackoffStrategy`. + +- [ ] **Step 1: Write failing tests for ExponentialBackoffStrategy** + +```csharp +// Tests/RCommon.Persistence.Tests/ExponentialBackoffStrategyTests.cs +using FluentAssertions; +using RCommon.Persistence.Outbox; +using Xunit; + +namespace RCommon.Persistence.Tests; + +public class ExponentialBackoffStrategyTests +{ + [Fact] + public void ComputeDelay_RetryCount0_ReturnsBaseDelay() + { + var strategy = new ExponentialBackoffStrategy( + TimeSpan.FromSeconds(5), TimeSpan.FromMinutes(30)); + strategy.ComputeDelay(0).Should().Be(TimeSpan.FromSeconds(5)); + } + + [Fact] + public void ComputeDelay_RetryCount1_ReturnsBaseTimesMultiplier() + { + var strategy = new ExponentialBackoffStrategy( + TimeSpan.FromSeconds(5), TimeSpan.FromMinutes(30)); + strategy.ComputeDelay(1).Should().Be(TimeSpan.FromSeconds(10)); + } + + [Fact] + public void ComputeDelay_RetryCount3_ReturnsExponentialDelay() + { + var strategy = new ExponentialBackoffStrategy( + TimeSpan.FromSeconds(5), TimeSpan.FromMinutes(30)); + // 5 * 2^3 = 40 seconds + strategy.ComputeDelay(3).Should().Be(TimeSpan.FromSeconds(40)); + } + + [Fact] + public void ComputeDelay_ExceedsMax_CapsAtMaxDelay() + { + var strategy = new ExponentialBackoffStrategy( + TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(60)); + // 5 * 2^10 = 5120 seconds, capped at 60 + strategy.ComputeDelay(10).Should().Be(TimeSpan.FromSeconds(60)); + } + + [Fact] + public void ComputeDelay_CustomMultiplier_UsesMultiplier() + { + var strategy = new ExponentialBackoffStrategy( + TimeSpan.FromSeconds(5), TimeSpan.FromMinutes(30), multiplier: 3.0); + // 5 * 3^2 = 45 seconds + strategy.ComputeDelay(2).Should().Be(TimeSpan.FromSeconds(45)); + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `dotnet test Tests/RCommon.Persistence.Tests/ --filter "FullyQualifiedName~ExponentialBackoffStrategyTests" -v minimal` +Expected: Compilation error — `ExponentialBackoffStrategy` does not exist + +- [ ] **Step 3: Create IBackoffStrategy interface** + +```csharp +// Src/RCommon.Persistence/Outbox/IBackoffStrategy.cs +namespace RCommon.Persistence.Outbox; + +public interface IBackoffStrategy +{ + TimeSpan ComputeDelay(int retryCount); +} +``` + +- [ ] **Step 4: Create ExponentialBackoffStrategy** + +```csharp +// Src/RCommon.Persistence/Outbox/ExponentialBackoffStrategy.cs +using System; + +namespace RCommon.Persistence.Outbox; + +public class ExponentialBackoffStrategy : IBackoffStrategy +{ + private readonly TimeSpan _baseDelay; + private readonly TimeSpan _maxDelay; + private readonly double _multiplier; + + public ExponentialBackoffStrategy(TimeSpan baseDelay, TimeSpan maxDelay, double multiplier = 2.0) + { + _baseDelay = baseDelay; + _maxDelay = maxDelay; + _multiplier = multiplier; + } + + public TimeSpan ComputeDelay(int retryCount) + => TimeSpan.FromSeconds( + Math.Min( + _baseDelay.TotalSeconds * Math.Pow(_multiplier, retryCount), + _maxDelay.TotalSeconds)); +} +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `dotnet test Tests/RCommon.Persistence.Tests/ --filter "FullyQualifiedName~ExponentialBackoffStrategyTests" -v minimal` +Expected: 5 passed, 0 failed + +- [ ] **Step 6: Add V2 properties to OutboxOptions** + +Modify `Src/RCommon.Persistence/Outbox/OutboxOptions.cs` — add after `TableName`: + +```csharp + public TimeSpan LockDuration { get; set; } = TimeSpan.FromMinutes(5); + public TimeSpan BackoffBaseDelay { get; set; } = TimeSpan.FromSeconds(5); + public TimeSpan BackoffMaxDelay { get; set; } = TimeSpan.FromMinutes(30); + public double BackoffMultiplier { get; set; } = 2.0; + public string InboxTableName { get; set; } = "__InboxMessages"; +``` + +- [ ] **Step 7: Commit** + +```bash +git add Src/RCommon.Persistence/Outbox/IBackoffStrategy.cs Src/RCommon.Persistence/Outbox/ExponentialBackoffStrategy.cs Src/RCommon.Persistence/Outbox/OutboxOptions.cs Tests/RCommon.Persistence.Tests/ExponentialBackoffStrategyTests.cs +git commit -m "feat: add IBackoffStrategy, ExponentialBackoffStrategy, and V2 OutboxOptions" +``` + +--- + +### Task 2: IOutboxMessage + IOutboxStore Interface Changes + Lock Providers + +**Files:** +- Modify: `Src/RCommon.Persistence/Outbox/IOutboxMessage.cs` +- Modify: `Src/RCommon.Persistence/Outbox/OutboxMessage.cs` +- Modify: `Src/RCommon.Persistence/Outbox/IOutboxStore.cs` +- Create: `Src/RCommon.Persistence/Outbox/ILockStatementProvider.cs` +- Create: `Src/RCommon.Persistence/Outbox/SqlServerLockStatementProvider.cs` +- Create: `Src/RCommon.Persistence/Outbox/PostgreSqlLockStatementProvider.cs` + +**Context:** This task intentionally breaks compilation. All `IOutboxStore` implementations and consumers will fail to compile until they're updated in later tasks. The implementer must NOT try to fix those errors yet — they'll be addressed task-by-task. + +- [ ] **Step 1: Add 3 new properties to IOutboxMessage** + +Modify `Src/RCommon.Persistence/Outbox/IOutboxMessage.cs` — add after `TenantId`: + +```csharp + DateTimeOffset? NextRetryAtUtc { get; set; } + string? LockedByInstanceId { get; set; } + DateTimeOffset? LockedUntilUtc { get; set; } +``` + +- [ ] **Step 2: Add matching properties to OutboxMessage** + +Modify `Src/RCommon.Persistence/Outbox/OutboxMessage.cs` — add after `TenantId`: + +```csharp + public DateTimeOffset? NextRetryAtUtc { get; set; } + public string? LockedByInstanceId { get; set; } + public DateTimeOffset? LockedUntilUtc { get; set; } +``` + +- [ ] **Step 3: Rewrite IOutboxStore interface** + +Replace the entire body of `Src/RCommon.Persistence/Outbox/IOutboxStore.cs` with: + +```csharp +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace RCommon.Persistence.Outbox; + +public interface IOutboxStore +{ + Task SaveAsync(IOutboxMessage message, CancellationToken cancellationToken = default); + Task MarkProcessedAsync(Guid messageId, CancellationToken cancellationToken = default); + Task MarkFailedAsync(Guid messageId, string error, DateTimeOffset nextRetryAtUtc, CancellationToken cancellationToken = default); + Task MarkDeadLetteredAsync(Guid messageId, CancellationToken cancellationToken = default); + Task DeleteProcessedAsync(TimeSpan olderThan, CancellationToken cancellationToken = default); + Task DeleteDeadLetteredAsync(TimeSpan olderThan, CancellationToken cancellationToken = default); + Task> ClaimAsync(string instanceId, int batchSize, TimeSpan lockDuration, CancellationToken cancellationToken = default); + Task> GetDeadLettersAsync(int batchSize, int offset = 0, CancellationToken cancellationToken = default); + Task ReplayDeadLetterAsync(Guid messageId, CancellationToken cancellationToken = default); +} +``` + +- [ ] **Step 4: Create ILockStatementProvider + implementations** + +```csharp +// Src/RCommon.Persistence/Outbox/ILockStatementProvider.cs +namespace RCommon.Persistence.Outbox; + +public interface ILockStatementProvider +{ + string ProviderName { get; } +} +``` + +```csharp +// Src/RCommon.Persistence/Outbox/SqlServerLockStatementProvider.cs +namespace RCommon.Persistence.Outbox; + +public class SqlServerLockStatementProvider : ILockStatementProvider +{ + public string ProviderName => "SqlServer"; +} +``` + +```csharp +// Src/RCommon.Persistence/Outbox/PostgreSqlLockStatementProvider.cs +namespace RCommon.Persistence.Outbox; + +public class PostgreSqlLockStatementProvider : ILockStatementProvider +{ + public string ProviderName => "PostgreSql"; +} +``` + +- [ ] **Step 5: Verify only the expected compilation errors exist** + +Run: `dotnet build Src/RCommon.Persistence/` +Expected: Build succeeds (Persistence project doesn't reference store implementations) + +Run: `dotnet build Src/RCommon.EfCore/ 2>&1 | head -20` +Expected: Compilation errors in `EFCoreOutboxStore.cs` (expected — will be fixed in Task 6) + +- [ ] **Step 6: Commit** + +```bash +git add Src/RCommon.Persistence/Outbox/IOutboxMessage.cs Src/RCommon.Persistence/Outbox/OutboxMessage.cs Src/RCommon.Persistence/Outbox/IOutboxStore.cs Src/RCommon.Persistence/Outbox/ILockStatementProvider.cs Src/RCommon.Persistence/Outbox/SqlServerLockStatementProvider.cs Src/RCommon.Persistence/Outbox/PostgreSqlLockStatementProvider.cs +git commit -m "feat: V2 interface changes — ClaimAsync, backoff, locking, dead letter replay" +``` + +--- + +### Task 3: Inbox Abstractions + +**Files:** +- Create: `Src/RCommon.Persistence/Inbox/IInboxMessage.cs` +- Create: `Src/RCommon.Persistence/Inbox/InboxMessage.cs` +- Create: `Src/RCommon.Persistence/Inbox/IInboxStore.cs` +- Create: `Src/RCommon.Persistence/Inbox/InboxPersistenceBuilderExtensions.cs` + +**Context:** New inbox types, independent of outbox stores. No compilation breakage. + +- [ ] **Step 1: Create IInboxMessage** + +```csharp +// Src/RCommon.Persistence/Inbox/IInboxMessage.cs +using System; + +namespace RCommon.Persistence.Inbox; + +public interface IInboxMessage +{ + Guid MessageId { get; } + string EventType { get; } + string? ConsumerType { get; } + DateTimeOffset ReceivedAtUtc { get; } +} +``` + +- [ ] **Step 2: Create InboxMessage** + +```csharp +// Src/RCommon.Persistence/Inbox/InboxMessage.cs +using System; + +namespace RCommon.Persistence.Inbox; + +public class InboxMessage : IInboxMessage +{ + public Guid MessageId { get; set; } + public string EventType { get; set; } = string.Empty; + public string? ConsumerType { get; set; } + public DateTimeOffset ReceivedAtUtc { get; set; } +} +``` + +- [ ] **Step 3: Create IInboxStore** + +```csharp +// Src/RCommon.Persistence/Inbox/IInboxStore.cs +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace RCommon.Persistence.Inbox; + +public interface IInboxStore +{ + Task ExistsAsync(Guid messageId, string? consumerType = null, CancellationToken cancellationToken = default); + Task RecordAsync(IInboxMessage message, CancellationToken cancellationToken = default); + Task CleanupAsync(TimeSpan olderThan, CancellationToken cancellationToken = default); +} +``` + +- [ ] **Step 4: Create InboxPersistenceBuilderExtensions** + +```csharp +// Src/RCommon.Persistence/Inbox/InboxPersistenceBuilderExtensions.cs +using Microsoft.Extensions.DependencyInjection; +using RCommon.Persistence.Inbox; + +namespace RCommon; + +public static class InboxPersistenceBuilderExtensions +{ + public static IPersistenceBuilder AddInbox(this IPersistenceBuilder builder) + where TInboxStore : class, IInboxStore + { + builder.Services.AddScoped(); + return builder; + } +} +``` + +- [ ] **Step 5: Verify RCommon.Persistence builds** + +Run: `dotnet build Src/RCommon.Persistence/` +Expected: Build succeeded + +- [ ] **Step 6: Commit** + +```bash +git add Src/RCommon.Persistence/Inbox/ +git commit -m "feat: add IInboxStore, IInboxMessage, and InboxMessage abstractions" +``` + +--- + +### Task 4: OutboxEventRouter V2 + Test Updates + +**Files:** +- Modify: `Src/RCommon.Persistence/Outbox/OutboxEventRouter.cs` +- Modify: `Tests/RCommon.Persistence.Tests/OutboxEventRouterTests.cs` +- Modify: `Tests/RCommon.Persistence.Tests/OutboxEntityEventTrackerTests.cs` +- Modify: `Tests/RCommon.Persistence.Tests/OutboxConcurrencyTests.cs` + +**Context:** `OutboxEventRouter.RouteEventsAsync()` currently calls `GetPendingAsync` (removed) and `MarkFailedAsync` (signature changed). V2 retains persisted events in a list and dispatches from memory. Read the spec Section 4.4 for the implementation sketch. + +**Important existing code to understand:** +- `Src/RCommon.Persistence/Outbox/OutboxEventRouter.cs` — current implementation at lines 35-176 +- `_buffer` is `ConcurrentQueue` — drained in `PersistBufferedEventsAsync` +- `RouteEventsAsync()` (no-params) currently reads from store — must change to dispatch from retained list +- `RouteEventsAsync(IEnumerable, CancellationToken)` — direct dispatch, unchanged + +**Changes to make:** + +1. Add private field: `private readonly List<(Guid MessageId, ISerializableEvent Event)> _persistedEvents = new();` +2. In `PersistBufferedEventsAsync`, after `await _outboxStore.SaveAsync(message, ...)`, add: `_persistedEvents.Add((message.Id, @event));` +3. Replace the entire `RouteEventsAsync()` (no-params overload, lines 118-150) with the retained-event dispatch pattern from spec Section 4.4 + +**Test updates:** +- `OutboxEventRouterTests.cs` — tests that mock `GetPendingAsync` must change to verify dispatch of retained events. Tests that mock `MarkFailedAsync` must be updated (no longer called by router). The router now only calls `MarkProcessedAsync` on success. +- `OutboxEntityEventTrackerTests.cs` — update any mocks of `IOutboxStore` to match new interface (no `GetPendingAsync`, changed `MarkFailedAsync` signature) +- `OutboxConcurrencyTests.cs` — same mock updates + +- [ ] **Step 1: Read the current test files to understand what needs changing** + +Read: `Tests/RCommon.Persistence.Tests/OutboxEventRouterTests.cs` +Read: `Tests/RCommon.Persistence.Tests/OutboxEntityEventTrackerTests.cs` +Read: `Tests/RCommon.Persistence.Tests/OutboxConcurrencyTests.cs` + +- [ ] **Step 2: Update OutboxEventRouter — add retained events field and populate in PersistBufferedEventsAsync** + +In `Src/RCommon.Persistence/Outbox/OutboxEventRouter.cs`: + +After the `_buffer` field declaration (line 43), add: +```csharp + private readonly List<(Guid MessageId, ISerializableEvent Event)> _persistedEvents = new(); +``` + +In `PersistBufferedEventsAsync`, after `await _outboxStore.SaveAsync(message, cancellationToken).ConfigureAwait(false);` (line 108), add: +```csharp + _persistedEvents.Add((message.Id, @event)); +``` + +- [ ] **Step 3: Replace RouteEventsAsync() no-params overload** + +Replace the `RouteEventsAsync()` method (lines 118-150) with: + +```csharp + public async Task RouteEventsAsync(CancellationToken cancellationToken = default) + { + if (_persistedEvents.Count == 0) return; + + _logger.LogInformation("OutboxEventRouter dispatching {Count} retained messages", _persistedEvents.Count); + + var producers = _serviceProvider.GetServices(); + + foreach (var (messageId, @event) in _persistedEvents) + { + try + { + var filteredProducers = _subscriptionManager.HasSubscriptions + ? _subscriptionManager.GetProducersForEvent(producers, @event.GetType()) + : producers; + + foreach (var producer in filteredProducers) + { + await producer.ProduceEventAsync(@event, cancellationToken).ConfigureAwait(false); + } + + await _outboxStore.MarkProcessedAsync(messageId, cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Best-effort dispatch failed for message {Id}; background processor will retry", messageId); + } + } + + _persistedEvents.Clear(); + } +``` + +- [ ] **Step 4: Update OutboxEventRouterTests.cs** + +Rewrite tests that reference `GetPendingAsync` or `MarkFailedAsync(Guid, string, CancellationToken)`. The router now: +- Retains events after `PersistBufferedEventsAsync` +- Dispatches from memory in `RouteEventsAsync()` (no store read) +- Calls `MarkProcessedAsync` on success +- Logs warning on failure (no `MarkFailedAsync` call) + +Update all `IOutboxStore` mock setups to match the new interface (no `GetPendingAsync`, `MarkFailedAsync` takes 3 args + CT). + +- [ ] **Step 5: Update OutboxEntityEventTrackerTests.cs** + +Update `IOutboxStore` mock setups to match new interface. These tests primarily test `PersistEventsAsync` and `EmitTransactionalEventsAsync` delegation — the store mock interface changes are the main fix. + +- [ ] **Step 6: Update OutboxConcurrencyTests.cs** + +Update `IOutboxStore` mock setups to match new interface. Remove any `GetPendingAsync` mock setups. + +- [ ] **Step 7: Verify RCommon.Persistence builds and tests pass** + +Run: `dotnet build Src/RCommon.Persistence/` +Expected: Build succeeded + +Run: `dotnet test Tests/RCommon.Persistence.Tests/ --filter "FullyQualifiedName~OutboxEventRouter" -v minimal` +Expected: All tests pass + +Run: `dotnet test Tests/RCommon.Persistence.Tests/ --filter "FullyQualifiedName~OutboxEntityEventTracker" -v minimal` +Expected: All tests pass + +Run: `dotnet test Tests/RCommon.Persistence.Tests/ --filter "FullyQualifiedName~OutboxConcurrency" -v minimal` +Expected: All tests pass + +- [ ] **Step 8: Commit** + +```bash +git add Src/RCommon.Persistence/Outbox/OutboxEventRouter.cs Tests/RCommon.Persistence.Tests/OutboxEventRouterTests.cs Tests/RCommon.Persistence.Tests/OutboxEntityEventTrackerTests.cs Tests/RCommon.Persistence.Tests/OutboxConcurrencyTests.cs +git commit -m "feat: OutboxEventRouter V2 — retained-event dispatch, no store reads" +``` + +--- + +### Task 5: OutboxProcessingService V2 + DI Registration + +**Files:** +- Modify: `Src/RCommon.Persistence/Outbox/OutboxProcessingService.cs` +- Modify: `Src/RCommon.Persistence/Outbox/OutboxPersistenceBuilderExtensions.cs` +- Modify: `Tests/RCommon.Persistence.Tests/OutboxProcessingServiceTests.cs` + +**Context:** The processing service gets instance identity, `ClaimAsync`-based polling, backoff-aware failure handling, and optional inbox auto-check. Read spec Sections 4.1-4.3 for details. + +**Important existing code:** +- `Src/RCommon.Persistence/Outbox/OutboxProcessingService.cs` — constructor at lines 21-29, `ProcessBatchAsync` at lines 50-109 +- Constructor currently takes: `IServiceProvider`, `IOptions`, `ILogger` +- Must add `IBackoffStrategy` to constructor + +- [ ] **Step 1: Read the current OutboxProcessingServiceTests.cs** + +Read: `Tests/RCommon.Persistence.Tests/OutboxProcessingServiceTests.cs` + +- [ ] **Step 2: Update OutboxProcessingServiceTests.cs** + +Rewrite tests for V2 behavior: +- Mock `IOutboxStore.ClaimAsync` instead of `GetPendingAsync` +- Mock `IBackoffStrategy.ComputeDelay` for failure tests +- `MarkFailedAsync` now takes `(Guid, string, DateTimeOffset, CancellationToken)` — verify `nextRetryAtUtc` is passed +- Add test for inbox auto-check when `IInboxStore` is registered +- Add test for inbox auto-check skipped when `IInboxStore` is NOT registered + +Key test scenarios: +1. `ProcessBatchAsync_ClaimsBatch_DispatchesAndMarksProcessed` — uses `ClaimAsync` with instance ID +2. `ProcessBatchAsync_DispatchFails_ComputesBackoffAndMarksFailedWithNextRetry` — verifies `IBackoffStrategy.ComputeDelay` called, `MarkFailedAsync` gets computed `nextRetryAtUtc` +3. `ProcessBatchAsync_ExceedsMaxRetries_DeadLetters` — same as V1 but with `ClaimAsync` +4. `ProcessBatchAsync_InboxRegistered_SkipsDuplicateMessage` — mock `IInboxStore.ExistsAsync` returns true, verify `MarkProcessedAsync` called without dispatch +5. `ProcessBatchAsync_InboxNotRegistered_DispatchesNormally` — `GetService()` returns null + +- [ ] **Step 3: Update OutboxProcessingService** + +Changes to `Src/RCommon.Persistence/Outbox/OutboxProcessingService.cs`: + +1. Add field: `private readonly string _instanceId = Guid.NewGuid().ToString("N");` +2. Add field: `private readonly IBackoffStrategy _backoffStrategy;` +3. Add `IBackoffStrategy backoffStrategy` to constructor, assign to `_backoffStrategy` +4. Replace `ProcessBatchAsync` with V2 implementation from spec Section 4.2 (add `.ConfigureAwait(false)` to all awaits) +5. Key changes in `ProcessBatchAsync`: + - Replace `store.GetPendingAsync(...)` with `store.ClaimAsync(_instanceId, _options.BatchSize, _options.LockDuration, cancellationToken)` + - Remove the `if (message.RetryCount >= _options.MaxRetries)` pre-check (ClaimAsync already filters) + - Add inbox auto-check: resolve `IInboxStore` via `GetService`, check `ExistsAsync` before dispatch, call `RecordAsync` after dispatch + - On failure: compute `var delay = _backoffStrategy.ComputeDelay(message.RetryCount + 1);` and pass `DateTimeOffset.UtcNow + delay` to `MarkFailedAsync` + - Add inbox cleanup in the periodic cleanup section + +- [ ] **Step 4: Update OutboxPersistenceBuilderExtensions** + +In `Src/RCommon.Persistence/Outbox/OutboxPersistenceBuilderExtensions.cs`, add after the `OutboxOptions` configuration block: + +```csharp + // Backoff strategy (singleton, replaceable) + builder.Services.TryAddSingleton(sp => + { + var opts = sp.GetRequiredService>().Value; + return new ExponentialBackoffStrategy(opts.BackoffBaseDelay, opts.BackoffMaxDelay, opts.BackoffMultiplier); + }); +``` + +Add required using: `using Microsoft.Extensions.Options;` + +- [ ] **Step 5: Verify tests pass** + +Run: `dotnet test Tests/RCommon.Persistence.Tests/ --filter "FullyQualifiedName~OutboxProcessingService" -v minimal` +Expected: All tests pass + +- [ ] **Step 6: Commit** + +```bash +git add Src/RCommon.Persistence/Outbox/OutboxProcessingService.cs Src/RCommon.Persistence/Outbox/OutboxPersistenceBuilderExtensions.cs Tests/RCommon.Persistence.Tests/OutboxProcessingServiceTests.cs +git commit -m "feat: OutboxProcessingService V2 — ClaimAsync, backoff, inbox auto-check" +``` + +--- + +### Task 6: EFCoreOutboxStore V2 + Entity Config + +**Files:** +- Modify: `Src/RCommon.EfCore/Outbox/EFCoreOutboxStore.cs` +- Modify: `Src/RCommon.EfCore/Outbox/OutboxMessageConfiguration.cs` +- Modify: `Src/RCommon.EfCore/Outbox/ModelBuilderExtensions.cs` +- Modify: `Tests/RCommon.EfCore.Tests/EFCoreOutboxStoreTests.cs` + +**Context:** The EF Core store needs `ClaimAsync` (raw SQL with provider detection), `GetDeadLettersAsync`, `ReplayDeadLetterAsync`, and an updated `MarkFailedAsync`. Read spec Sections 3.2, 6, and 7.1. + +**Important existing code:** +- `Src/RCommon.EfCore/Outbox/EFCoreOutboxStore.cs` — constructor at lines 29-38, `DbContext` property at line 43 +- `DbContext.Database.ProviderName` for auto-detection: `"Microsoft.EntityFrameworkCore.SqlServer"` or `"Npgsql.EntityFrameworkCore.PostgreSQL"` +- `OutboxMessageConfiguration.cs` — entity config at lines 1-29, index at line 22-23 +- `ModelBuilderExtensions.cs` — `AddOutboxMessages` at lines 1-12 +- Test DB: SQLite in-memory (`UseSqlite("DataSource=:memory:")`) + +**SQLite limitation:** `ClaimAsync` requires raw SQL with provider-specific syntax. SQLite doesn't support `UPDATE...OUTPUT` or `FOR UPDATE SKIP LOCKED`. For tests, use a SQLite-compatible approach: the tests should verify the non-SQL-specific logic. Consider adding a `SqliteClaimAsync` fallback that uses a two-step SELECT+UPDATE (acceptable for testing only, not production). + +**Alternative for testing:** Since EF Core tests use SQLite in-memory, and `ClaimAsync` requires provider-specific SQL that SQLite doesn't support, the tests should mock the `ClaimAsync` behavior or test at a higher level. The spec review noted the SQLite DateTimeOffset limitation already. For this task, test `GetDeadLettersAsync`, `ReplayDeadLetterAsync`, `MarkFailedAsync`, and `SaveAsync` with the new properties using SQLite. Test `ClaimAsync` with mocked provider detection that falls back gracefully, or add a simple two-query fallback for unsupported providers (SELECT + UPDATE in a transaction). + +- [ ] **Step 1: Read the current EFCoreOutboxStoreTests.cs and TestOutboxDbContext** + +Read: `Tests/RCommon.EfCore.Tests/EFCoreOutboxStoreTests.cs` +Read the test `DbContext` class used by the tests (likely `TestOutboxDbContext` or similar) + +- [ ] **Step 2: Update OutboxMessageConfiguration — new columns and indexes** + +Modify `Src/RCommon.EfCore/Outbox/OutboxMessageConfiguration.cs`: + +Add property configurations: +```csharp + builder.Property(x => x.NextRetryAtUtc); + builder.Property(x => x.LockedByInstanceId).HasMaxLength(64); + builder.Property(x => x.LockedUntilUtc); +``` + +Replace the existing pending index with: +```csharp + builder.HasIndex(x => new { x.ProcessedAtUtc, x.DeadLetteredAtUtc, x.NextRetryAtUtc, x.LockedUntilUtc, x.CreatedAtUtc }) + .HasDatabaseName("IX_OutboxMessages_Pending"); + + builder.HasIndex(x => x.DeadLetteredAtUtc) + .HasDatabaseName("IX_OutboxMessages_DeadLettered") + .HasFilter("[DeadLetteredAtUtc] IS NOT NULL"); +``` + +Note: The filtered index `HasFilter` uses SQL Server syntax. For PostgreSQL it would be `"\"DeadLetteredAtUtc\" IS NOT NULL"`. Since this is EF Core configuration and migrations handle provider differences, use the SQL Server syntax as default. + +- [ ] **Step 3: Add `_tableName` field to EFCoreOutboxStore** + +The existing `EFCoreOutboxStore` stores `_maxRetries` but NOT `_tableName`. The `ClaimAsync` raw SQL needs both. In the constructor, add: + +```csharp + private readonly string _tableName; +``` + +And in the constructor body, add: +```csharp + _tableName = outboxOptions?.Value?.TableName ?? "__OutboxMessages"; +``` + +- [ ] **Step 4: Remove GetPendingAsync, update MarkFailedAsync** + +In `Src/RCommon.EfCore/Outbox/EFCoreOutboxStore.cs`: + +Remove the `GetPendingAsync` method entirely (lines 73-86). + +Update `MarkFailedAsync` signature and implementation: +```csharp + public async Task MarkFailedAsync(Guid messageId, string error, DateTimeOffset nextRetryAtUtc, CancellationToken cancellationToken = default) + { + var message = await DbContext.Set() + .FirstOrDefaultAsync(m => m.Id == messageId, cancellationToken).ConfigureAwait(false); + + if (message != null) + { + message.ErrorMessage = error; + message.RetryCount++; + message.NextRetryAtUtc = nextRetryAtUtc; + message.LockedByInstanceId = null; + message.LockedUntilUtc = null; + await DbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + } + } +``` + +- [ ] **Step 5: Add ClaimAsync to EFCoreOutboxStore** + +Add the `ClaimAsync` implementation. EF Core uses `Database.ProviderName` for auto-detection. **Important:** Use `DbContext.Set().FromSqlRaw(...)` (NOT `Database.SqlQueryRaw()`) because `OutboxMessage` is a mapped entity type. `SqlQueryRaw` only works for unmapped/scalar types. + +```csharp + public async Task> ClaimAsync(string instanceId, int batchSize, TimeSpan lockDuration, CancellationToken cancellationToken = default) + { + var now = DateTimeOffset.UtcNow; + var lockUntil = now + lockDuration; + var providerName = DbContext.Database.ProviderName; + + if (providerName?.Contains("SqlServer") == true) + { + var sql = @" + WITH batch AS ( + SELECT TOP({0}) Id + FROM [{1}] WITH (UPDLOCK, ROWLOCK, READPAST) + WHERE ProcessedAtUtc IS NULL + AND DeadLetteredAtUtc IS NULL + AND RetryCount < {2} + AND (NextRetryAtUtc IS NULL OR NextRetryAtUtc <= {3}) + AND (LockedUntilUtc IS NULL OR LockedUntilUtc <= {3}) + ORDER BY CreatedAtUtc + ) + UPDATE o + SET o.LockedByInstanceId = {4}, o.LockedUntilUtc = {5} + OUTPUT INSERTED.* + FROM [{1}] o + INNER JOIN batch ON o.Id = batch.Id"; + + return await DbContext.Set() + .FromSqlRaw(sql, batchSize, _tableName, _maxRetries, now, instanceId, lockUntil) + .ToListAsync(cancellationToken).ConfigureAwait(false); + } + else if (providerName?.Contains("Npgsql") == true) + { + var sql = $@" + UPDATE ""{_tableName}"" o + SET ""LockedByInstanceId"" = @p3, ""LockedUntilUtc"" = @p4 + FROM ( + SELECT ""Id"" FROM ""{_tableName}"" + WHERE ""ProcessedAtUtc"" IS NULL + AND ""DeadLetteredAtUtc"" IS NULL + AND ""RetryCount"" < @p1 + AND (""NextRetryAtUtc"" IS NULL OR ""NextRetryAtUtc"" <= @p2) + AND (""LockedUntilUtc"" IS NULL OR ""LockedUntilUtc"" <= @p2) + ORDER BY ""CreatedAtUtc"" + LIMIT @p0 + FOR UPDATE SKIP LOCKED + ) AS batch + WHERE o.""Id"" = batch.""Id"" + RETURNING o.*"; + + return await DbContext.Set() + .FromSqlRaw(sql, + new Npgsql.NpgsqlParameter("p0", batchSize), + new Npgsql.NpgsqlParameter("p1", _maxRetries), + new Npgsql.NpgsqlParameter("p2", now), + new Npgsql.NpgsqlParameter("p3", instanceId), + new Npgsql.NpgsqlParameter("p4", lockUntil)) + .ToListAsync(cancellationToken).ConfigureAwait(false); + } + else + { + // Fallback for unsupported providers (e.g., SQLite in tests): + // Two-step SELECT + UPDATE — NOT safe for concurrent production use. + var pending = await DbContext.Set() + .Where(m => m.ProcessedAtUtc == null + && m.DeadLetteredAtUtc == null + && m.RetryCount < _maxRetries + && (m.NextRetryAtUtc == null || m.NextRetryAtUtc <= now) + && (m.LockedUntilUtc == null || m.LockedUntilUtc <= now)) + .OrderBy(m => m.CreatedAtUtc) + .Take(batchSize) + .ToListAsync(cancellationToken).ConfigureAwait(false); + + foreach (var m in pending) + { + m.LockedByInstanceId = instanceId; + m.LockedUntilUtc = lockUntil; + } + await DbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + + return pending; + } + } +``` + +**Note on parameterization:** The `FromSqlRaw` call uses positional parameters `{0}` through `{5}` for SQL Server. For PostgreSQL, named `NpgsqlParameter` objects are used. Table name and max retries are interpolated into the PostgreSQL SQL string since they are not user input (they come from `OutboxOptions`). The implementer should verify `FromSqlRaw` parameterization works correctly with the specific SQL syntax — if `FromSqlRaw` doesn't support table name/TOP as parameters for SQL Server, use string interpolation for those values only and parameterize `now`, `instanceId`, `lockUntil`. + +- [ ] **Step 6: Add GetDeadLettersAsync and ReplayDeadLetterAsync** + +```csharp + public async Task> GetDeadLettersAsync(int batchSize, int offset = 0, CancellationToken cancellationToken = default) + { + // Client-side ordering for SQLite compatibility (same pattern as V1 GetPendingAsync) + var results = await DbContext.Set() + .Where(m => m.DeadLetteredAtUtc != null) + .ToListAsync(cancellationToken).ConfigureAwait(false); + + return results + .OrderByDescending(m => m.DeadLetteredAtUtc) + .Skip(offset) + .Take(batchSize) + .ToList(); + } + + public async Task ReplayDeadLetterAsync(Guid messageId, CancellationToken cancellationToken = default) + { + var message = await DbContext.Set() + .FirstOrDefaultAsync(m => m.Id == messageId, cancellationToken).ConfigureAwait(false); + + if (message == null || message.DeadLetteredAtUtc == null) + { + throw new InvalidOperationException($"Message {messageId} does not exist or is not dead-lettered."); + } + + message.DeadLetteredAtUtc = null; + message.ProcessedAtUtc = null; + message.ErrorMessage = null; + message.RetryCount = 0; + message.NextRetryAtUtc = null; + message.LockedByInstanceId = null; + message.LockedUntilUtc = null; + + await DbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + } +``` + +- [ ] **Step 7: Update SaveAsync to copy new properties** + +In the existing `SaveAsync` method, ensure the new properties (`NextRetryAtUtc`, `LockedByInstanceId`, `LockedUntilUtc`) are copied from the `IOutboxMessage` parameter to the `OutboxMessage` entity. Check how `SaveAsync` currently works — if it creates a new `OutboxMessage` from the interface, add the three new property assignments. + +- [ ] **Step 8: Update EFCoreOutboxStoreTests.cs** + +Rewrite tests for V2: +- Replace `GetPendingAsync` tests with `ClaimAsync` tests (using SQLite fallback) +- Add tests for `ClaimAsync` — verify it filters by pending, not dead-lettered, under max retries, respects `NextRetryAtUtc` and `LockedUntilUtc` +- Add tests for `GetDeadLettersAsync` — verify ordering, paging +- Add tests for `ReplayDeadLetterAsync` — verify reset of all fields, verify throws for non-existent/non-dead-lettered +- Update `MarkFailedAsync` test for new signature — verify `NextRetryAtUtc` is set and lock is cleared + +- [ ] **Step 9: Update ModelBuilderExtensions to support inbox** + +Modify `Src/RCommon.EfCore/Outbox/ModelBuilderExtensions.cs` — add inbox support: + +```csharp + public static ModelBuilder AddInboxMessages(this ModelBuilder modelBuilder, string tableName = "__InboxMessages") + { + modelBuilder.ApplyConfiguration(new InboxMessageConfiguration(tableName)); + return modelBuilder; + } +``` + +Add required using for the inbox configuration class. + +- [ ] **Step 10: Verify EF Core builds and tests pass** + +Run: `dotnet build Src/RCommon.EfCore/` +Expected: Build succeeded + +Run: `dotnet test Tests/RCommon.EfCore.Tests/ --filter "FullyQualifiedName~EFCoreOutboxStore" -v minimal` +Expected: All tests pass + +- [ ] **Step 11: Commit** + +```bash +git add Src/RCommon.EfCore/Outbox/ Tests/RCommon.EfCore.Tests/EFCoreOutboxStoreTests.cs +git commit -m "feat: EFCoreOutboxStore V2 — ClaimAsync, dead letter replay, backoff" +``` + +--- + +### Task 7: EFCoreInboxStore + Tests + +**Files:** +- Create: `Src/RCommon.EfCore/Inbox/EFCoreInboxStore.cs` +- Create: `Src/RCommon.EfCore/Inbox/InboxMessageConfiguration.cs` +- Create: `Tests/RCommon.EfCore.Tests/EFCoreInboxStoreTests.cs` + +**Context:** Standard EF Core CRUD implementation of `IInboxStore`. Composite PK on `(MessageId, ConsumerType)`. `ConsumerType` stored as `""` when null. + +**Follows same patterns as EFCoreOutboxStore:** +- Constructor takes `IDataStoreFactory`, `IOptions`, `IOptions` (for table name) +- `DbContext` property resolves from factory +- Tests use SQLite in-memory + +- [ ] **Step 1: Write failing tests** + +```csharp +// Tests/RCommon.EfCore.Tests/EFCoreInboxStoreTests.cs +using FluentAssertions; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; +using Moq; +using RCommon.Persistence; +using RCommon.Persistence.EFCore; +using RCommon.Persistence.Inbox; +using RCommon.Persistence.Outbox; +using Xunit; + +namespace RCommon.EfCore.Tests; + +public class EFCoreInboxStoreTests : IDisposable +{ + private readonly TestOutboxDbContext _dbContext; + private readonly EFCoreInboxStore _store; + + public EFCoreInboxStoreTests() + { + var dbOptions = new DbContextOptionsBuilder() + .UseSqlite("DataSource=:memory:") + .Options; + _dbContext = new TestOutboxDbContext(dbOptions); + _dbContext.Database.OpenConnection(); + _dbContext.Database.EnsureCreated(); + + var factoryMock = new Mock(); + factoryMock.Setup(f => f.Resolve(It.IsAny())) + .Returns(_dbContext); + + var defaultOptions = Options.Create(new DefaultDataStoreOptions { DefaultDataStoreName = "test" }); + var outboxOptions = Options.Create(new OutboxOptions()); + + _store = new EFCoreInboxStore(factoryMock.Object, defaultOptions, outboxOptions); + } + + [Fact] + public async Task ExistsAsync_NoRecord_ReturnsFalse() + { + var result = await _store.ExistsAsync(Guid.NewGuid(), "TestConsumer"); + result.Should().BeFalse(); + } + + [Fact] + public async Task RecordAsync_ThenExistsAsync_ReturnsTrue() + { + var messageId = Guid.NewGuid(); + await _store.RecordAsync(new InboxMessage + { + MessageId = messageId, + EventType = "TestEvent", + ConsumerType = "TestConsumer", + ReceivedAtUtc = DateTimeOffset.UtcNow + }); + + var result = await _store.ExistsAsync(messageId, "TestConsumer"); + result.Should().BeTrue(); + } + + [Fact] + public async Task ExistsAsync_DifferentConsumer_ReturnsFalse() + { + var messageId = Guid.NewGuid(); + await _store.RecordAsync(new InboxMessage + { + MessageId = messageId, + EventType = "TestEvent", + ConsumerType = "ConsumerA", + ReceivedAtUtc = DateTimeOffset.UtcNow + }); + + var result = await _store.ExistsAsync(messageId, "ConsumerB"); + result.Should().BeFalse(); + } + + [Fact] + public async Task CleanupAsync_RemovesOldEntries() + { + var old = new InboxMessage + { + MessageId = Guid.NewGuid(), + EventType = "TestEvent", + ConsumerType = "TestConsumer", + ReceivedAtUtc = DateTimeOffset.UtcNow.AddDays(-10) + }; + await _store.RecordAsync(old); + + var recent = new InboxMessage + { + MessageId = Guid.NewGuid(), + EventType = "TestEvent", + ConsumerType = "TestConsumer", + ReceivedAtUtc = DateTimeOffset.UtcNow + }; + await _store.RecordAsync(recent); + + await _store.CleanupAsync(TimeSpan.FromDays(7)); + + (await _store.ExistsAsync(old.MessageId, "TestConsumer")).Should().BeFalse(); + (await _store.ExistsAsync(recent.MessageId, "TestConsumer")).Should().BeTrue(); + } + + public void Dispose() => _dbContext?.Dispose(); +} +``` + +Note: The test `DbContext` (`TestOutboxDbContext`) must be updated to include inbox entities. The implementer should either: +- Update the existing `TestOutboxDbContext` to call `modelBuilder.AddInboxMessages()` in `OnModelCreating`, OR +- Create a separate test context that includes both outbox and inbox configurations + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `dotnet test Tests/RCommon.EfCore.Tests/ --filter "FullyQualifiedName~EFCoreInboxStore" -v minimal` +Expected: Compilation error — `EFCoreInboxStore` does not exist + +- [ ] **Step 3: Create InboxMessageConfiguration** + +```csharp +// Src/RCommon.EfCore/Inbox/InboxMessageConfiguration.cs +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using RCommon.Persistence.Inbox; + +namespace RCommon.Persistence.EFCore.Inbox; + +public class InboxMessageConfiguration : IEntityTypeConfiguration +{ + private readonly string _tableName; + + public InboxMessageConfiguration(string tableName = "__InboxMessages") + { + _tableName = tableName; + } + + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable(_tableName); + + // ConsumerType stored as "" when null for composite PK + builder.HasKey(x => new { x.MessageId, x.ConsumerType }); + builder.Property(x => x.ConsumerType) + .HasMaxLength(512) + .HasDefaultValue("") + .IsRequired(); + builder.Property(x => x.EventType).HasMaxLength(1024).IsRequired(); + builder.Property(x => x.ReceivedAtUtc).IsRequired(); + + builder.HasIndex(x => x.ReceivedAtUtc) + .HasDatabaseName("IX_InboxMessages_Cleanup"); + } +} +``` + +- [ ] **Step 4: Create EFCoreInboxStore** + +```csharp +// Src/RCommon.EfCore/Inbox/EFCoreInboxStore.cs +using System; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; +using RCommon.Persistence.EFCore; +using RCommon.Persistence.Inbox; +using RCommon.Persistence.Outbox; + +namespace RCommon.Persistence.EFCore.Inbox; + +public class EFCoreInboxStore : IInboxStore +{ + private readonly IDataStoreFactory _dataStoreFactory; + private readonly string _dataStoreName; + + public EFCoreInboxStore( + IDataStoreFactory dataStoreFactory, + IOptions defaultDataStoreOptions, + IOptions outboxOptions) + { + _dataStoreFactory = dataStoreFactory ?? throw new ArgumentNullException(nameof(dataStoreFactory)); + _dataStoreName = defaultDataStoreOptions?.Value?.DefaultDataStoreName + ?? throw new ArgumentNullException(nameof(defaultDataStoreOptions)); + } + + private RCommonDbContext DbContext => _dataStoreFactory.Resolve(_dataStoreName); + + public async Task ExistsAsync(Guid messageId, string? consumerType = null, CancellationToken cancellationToken = default) + { + var ct = consumerType ?? ""; + return await DbContext.Set() + .AnyAsync(m => m.MessageId == messageId && m.ConsumerType == ct, cancellationToken) + .ConfigureAwait(false); + } + + public async Task RecordAsync(IInboxMessage message, CancellationToken cancellationToken = default) + { + var entity = new InboxMessage + { + MessageId = message.MessageId, + EventType = message.EventType, + ConsumerType = message.ConsumerType ?? "", + ReceivedAtUtc = message.ReceivedAtUtc + }; + + DbContext.Set().Add(entity); + await DbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + } + + public async Task CleanupAsync(TimeSpan olderThan, CancellationToken cancellationToken = default) + { + var cutoff = DateTimeOffset.UtcNow - olderThan; + var old = await DbContext.Set() + .Where(m => m.ReceivedAtUtc < cutoff) + .ToListAsync(cancellationToken).ConfigureAwait(false); + + DbContext.Set().RemoveRange(old); + await DbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + } +} +``` + +- [ ] **Step 5: Update test DbContext to include inbox entities** + +Add `modelBuilder.AddInboxMessages();` to the test `DbContext`'s `OnModelCreating` method. + +- [ ] **Step 6: Run tests to verify they pass** + +Run: `dotnet test Tests/RCommon.EfCore.Tests/ --filter "FullyQualifiedName~EFCoreInboxStore" -v minimal` +Expected: 4 passed, 0 failed + +- [ ] **Step 7: Commit** + +```bash +git add Src/RCommon.EfCore/Inbox/ Src/RCommon.EfCore/Outbox/ModelBuilderExtensions.cs Tests/RCommon.EfCore.Tests/ +git commit -m "feat: EFCoreInboxStore — inbox/idempotency for EF Core" +``` + +--- + +### Task 8: DapperOutboxStore V2 + +**Files:** +- Modify: `Src/RCommon.Dapper/Outbox/DapperOutboxStore.cs` +- Modify: `Src/RCommon.Dapper/DapperPersistenceBuilder.cs` +- Modify: `Tests/RCommon.Dapper.Tests/DapperOutboxStoreTests.cs` + +**Context:** Dapper uses raw SQL. `ClaimAsync` SQL is selected by `ILockStatementProvider.ProviderName`. Constructor needs `ILockStatementProvider` added. The persistence builder needs a method to register the lock provider. Read spec Section 3.2 for SQL. + +**Important existing code:** +- `Src/RCommon.Dapper/Outbox/DapperOutboxStore.cs` — constructor at lines 22-32, all SQL uses bracket-quoted identifiers (`[{_tableName}]`) +- `Src/RCommon.Dapper/DapperPersistenceBuilder.cs` — builder class with `AddDbConnection` and `SetDefaultDataStore` methods +- Connection management: `GetOpenConnectionAsync` resolves `RDbConnection` from `IDataStoreFactory` +- Tests: `Tests/RCommon.Dapper.Tests/DapperOutboxStoreTests.cs` — 3 constructor validation tests +- `GetDeadLettersAsync` and `ReplayDeadLetterAsync` SQL must also be dialect-aware (SQL Server uses `OFFSET...FETCH`, PostgreSQL uses `LIMIT`/`OFFSET` with double-quoted identifiers). Select dialect based on `_lockProvider.ProviderName`. + +- [ ] **Step 1: Read current DapperOutboxStore.cs, DapperPersistenceBuilder.cs, and DapperOutboxStoreTests.cs** + +Read: `Src/RCommon.Dapper/Outbox/DapperOutboxStore.cs` +Read: `Tests/RCommon.Dapper.Tests/DapperOutboxStoreTests.cs` + +- [ ] **Step 2: Update DapperOutboxStore** + +Changes: +1. Add `ILockStatementProvider _lockProvider` field and constructor parameter +2. Remove `GetPendingAsync` +3. Update `MarkFailedAsync` — add `DateTimeOffset nextRetryAtUtc` parameter, update SQL to set `NextRetryAtUtc`, clear `LockedByInstanceId`/`LockedUntilUtc` +4. Update `SaveAsync` SQL — add `NextRetryAtUtc`, `LockedByInstanceId`, `LockedUntilUtc` columns +5. Add `ClaimAsync` — select SQL dialect based on `_lockProvider.ProviderName` +6. Add `GetDeadLettersAsync` — `SELECT * FROM ... WHERE DeadLetteredAtUtc IS NOT NULL ORDER BY DeadLetteredAtUtc DESC OFFSET @Offset ROWS FETCH NEXT @BatchSize ROWS ONLY` +7. Add `ReplayDeadLetterAsync` — `UPDATE ... SET DeadLetteredAtUtc=NULL, ProcessedAtUtc=NULL, ErrorMessage=NULL, RetryCount=0, NextRetryAtUtc=NULL, LockedByInstanceId=NULL, LockedUntilUtc=NULL WHERE Id=@Id AND DeadLetteredAtUtc IS NOT NULL`; throw `InvalidOperationException` if no rows affected + +SQL Server `ClaimAsync`: +```sql +WITH batch AS ( + SELECT TOP(@BatchSize) Id + FROM [{_tableName}] WITH (UPDLOCK, ROWLOCK, READPAST) + WHERE ProcessedAtUtc IS NULL + AND DeadLetteredAtUtc IS NULL + AND RetryCount < @MaxRetries + AND (NextRetryAtUtc IS NULL OR NextRetryAtUtc <= @Now) + AND (LockedUntilUtc IS NULL OR LockedUntilUtc <= @Now) + ORDER BY CreatedAtUtc +) +UPDATE o +SET o.LockedByInstanceId = @InstanceId, o.LockedUntilUtc = @LockUntil +OUTPUT INSERTED.* +FROM [{_tableName}] o +INNER JOIN batch ON o.Id = batch.Id +``` + +PostgreSQL `ClaimAsync`: +```sql +UPDATE "{_tableName}" o +SET "LockedByInstanceId" = @InstanceId, "LockedUntilUtc" = @LockUntil +FROM ( + SELECT "Id" FROM "{_tableName}" + WHERE "ProcessedAtUtc" IS NULL + AND "DeadLetteredAtUtc" IS NULL + AND "RetryCount" < @MaxRetries + AND ("NextRetryAtUtc" IS NULL OR "NextRetryAtUtc" <= @Now) + AND ("LockedUntilUtc" IS NULL OR "LockedUntilUtc" <= @Now) + ORDER BY "CreatedAtUtc" + LIMIT @BatchSize + FOR UPDATE SKIP LOCKED +) AS batch +WHERE o."Id" = batch."Id" +RETURNING o.* +``` + +- [ ] **Step 3: Update DapperOutboxStoreTests.cs** + +Add constructor validation test for `ILockStatementProvider`: +```csharp +[Fact] +public void Constructor_NullLockStatementProvider_ThrowsArgumentNullException() +{ + // ... existing setup ... + var act = () => new DapperOutboxStore(factoryMock.Object, defaultOptions, outboxOptions, null!); + act.Should().Throw().WithParameterName("lockStatementProvider"); +} +``` + +Update existing constructor tests to include `ILockStatementProvider` parameter. + +- [ ] **Step 4: Add ILockStatementProvider registration to DapperPersistenceBuilder** + +Modify `Src/RCommon.Dapper/DapperPersistenceBuilder.cs` — add a method for registering the lock statement provider: + +```csharp + public IDapperPersistenceBuilder UseLockStatementProvider() + where TProvider : class, ILockStatementProvider + { + this.Services.AddSingleton(); + return this; + } +``` + +Add required using: `using RCommon.Persistence.Outbox;` + +This allows users to configure: `.AddDapperPersistence(dapper => dapper.UseLockStatementProvider())` + +- [ ] **Step 5: Verify Dapper builds and tests pass** + +Run: `dotnet build Src/RCommon.Dapper/` +Expected: Build succeeded + +Run: `dotnet test Tests/RCommon.Dapper.Tests/ --filter "FullyQualifiedName~DapperOutboxStore" -v minimal` +Expected: All tests pass + +- [ ] **Step 6: Commit** + +```bash +git add Src/RCommon.Dapper/Outbox/DapperOutboxStore.cs Src/RCommon.Dapper/DapperPersistenceBuilder.cs Tests/RCommon.Dapper.Tests/DapperOutboxStoreTests.cs +git commit -m "feat: DapperOutboxStore V2 — ClaimAsync, dead letter replay, backoff" +``` + +--- + +### Task 9: DapperInboxStore + Tests + +**Files:** +- Create: `Src/RCommon.Dapper/Inbox/DapperInboxStore.cs` +- Create: `Tests/RCommon.Dapper.Tests/DapperInboxStoreTests.cs` + +**Context:** Standard Dapper `INSERT`/`SELECT EXISTS`/`DELETE` for inbox. Constructor takes `IDataStoreFactory`, `IOptions`, `IOptions` (for inbox table name). Follows same connection management pattern as `DapperOutboxStore`. + +- [ ] **Step 1: Write failing tests** + +Constructor validation tests (same pattern as `DapperOutboxStoreTests`): +- `Constructor_NullDataStoreFactory_ThrowsArgumentNullException` +- `Constructor_NullDefaultDataStoreOptions_ThrowsArgumentNullException` +- `Constructor_NullOutboxOptions_ThrowsArgumentNullException` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `dotnet test Tests/RCommon.Dapper.Tests/ --filter "FullyQualifiedName~DapperInboxStore" -v minimal` +Expected: Compilation error + +- [ ] **Step 3: Create DapperInboxStore** + +SQL operations: +- `ExistsAsync`: `SELECT CASE WHEN EXISTS (SELECT 1 FROM [{_tableName}] WHERE MessageId = @MessageId AND ConsumerType = @ConsumerType) THEN 1 ELSE 0 END` +- `RecordAsync`: `INSERT INTO [{_tableName}] (MessageId, EventType, ConsumerType, ReceivedAtUtc) VALUES (@MessageId, @EventType, @ConsumerType, @ReceivedAtUtc)` — `ConsumerType` coalesced to `""` before insert +- `CleanupAsync`: `DELETE FROM [{_tableName}] WHERE ReceivedAtUtc < @Cutoff` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `dotnet test Tests/RCommon.Dapper.Tests/ --filter "FullyQualifiedName~DapperInboxStore" -v minimal` +Expected: All tests pass + +- [ ] **Step 5: Commit** + +```bash +git add Src/RCommon.Dapper/Inbox/ Tests/RCommon.Dapper.Tests/DapperInboxStoreTests.cs +git commit -m "feat: DapperInboxStore — inbox/idempotency for Dapper" +``` + +--- + +### Task 10: Linq2DbOutboxStore V2 + +**Files:** +- Modify: `Src/RCommon.Linq2Db/Outbox/Linq2DbOutboxStore.cs` +- Modify: `Src/RCommon.Linq2Db/Linq2DbPersistenceBuilder.cs` +- Modify: `Tests/RCommon.Linq2Db.Tests/Linq2DbOutboxStoreTests.cs` + +**Context:** Linq2Db uses LINQ API for most operations and raw SQL for `ClaimAsync`. Constructor needs `ILockStatementProvider`. The persistence builder needs a method to register the lock provider. Read existing code patterns in `Src/RCommon.Linq2Db/Outbox/Linq2DbOutboxStore.cs`. + +**Important existing code:** +- `Table` property: `DataConnection.GetTable().TableName(_tableName)` +- LINQ-based updates use `.Set(m => m.Property, value).UpdateAsync()` +- `Src/RCommon.Linq2Db/Linq2DbPersistenceBuilder.cs` — builder class with `AddDataConnection` and `SetDefaultDataStore` methods +- Constructor: lines 30-40 + +- [ ] **Step 1: Read current Linq2DbOutboxStore.cs and tests** + +Read: `Src/RCommon.Linq2Db/Outbox/Linq2DbOutboxStore.cs` +Read: `Tests/RCommon.Linq2Db.Tests/Linq2DbOutboxStoreTests.cs` + +- [ ] **Step 2: Update Linq2DbOutboxStore** + +Changes: +1. Add `ILockStatementProvider _lockProvider` field and constructor parameter +2. Remove `GetPendingAsync` +3. Update `MarkFailedAsync` — add `nextRetryAtUtc` parameter, add `.Set(m => m.NextRetryAtUtc, nextRetryAtUtc)`, `.Set(m => m.LockedByInstanceId, (string?)null)`, `.Set(m => m.LockedUntilUtc, (DateTimeOffset?)null)` +4. Update `SaveAsync` — the `InsertAsync` call may need new column mappings for the three new properties +5. Add `ClaimAsync` — raw SQL via `DataConnection.QueryAsync(sql, params)`, dialect selected by `_lockProvider.ProviderName` (same SQL as Dapper Task 8 but using Linq2Db parameter syntax) +6. Add `GetDeadLettersAsync` — LINQ: `Table.Where(m => m.DeadLetteredAtUtc != null).OrderByDescending(m => m.DeadLetteredAtUtc).Skip(offset).Take(batchSize).ToListAsync()` +7. Add `ReplayDeadLetterAsync` — LINQ update setting all fields to null/0, check rows affected, throw `InvalidOperationException` if 0 + +- [ ] **Step 3: Update Linq2DbOutboxStoreTests.cs** + +Add constructor validation test for `ILockStatementProvider` (same pattern as Dapper). +Update existing tests to include `ILockStatementProvider` in constructor. + +- [ ] **Step 4: Add ILockStatementProvider registration to Linq2DbPersistenceBuilder** + +Modify `Src/RCommon.Linq2Db/Linq2DbPersistenceBuilder.cs` — add a method (same pattern as Dapper Task 8 Step 4): + +```csharp + public ILinq2DbPersistenceBuilder UseLockStatementProvider() + where TProvider : class, ILockStatementProvider + { + this.Services.AddSingleton(); + return this; + } +``` + +Add required using: `using RCommon.Persistence.Outbox;` + +- [ ] **Step 5: Verify Linq2Db builds and tests pass** + +Run: `dotnet build Src/RCommon.Linq2Db/` +Expected: Build succeeded + +Run: `dotnet test Tests/RCommon.Linq2Db.Tests/ --filter "FullyQualifiedName~Linq2DbOutboxStore" -v minimal` +Expected: All tests pass + +- [ ] **Step 6: Commit** + +```bash +git add Src/RCommon.Linq2Db/Outbox/Linq2DbOutboxStore.cs Src/RCommon.Linq2Db/Linq2DbPersistenceBuilder.cs Tests/RCommon.Linq2Db.Tests/Linq2DbOutboxStoreTests.cs +git commit -m "feat: Linq2DbOutboxStore V2 — ClaimAsync, dead letter replay, backoff" +``` + +--- + +### Task 11: Linq2DbInboxStore + Tests + +**Files:** +- Create: `Src/RCommon.Linq2Db/Inbox/Linq2DbInboxStore.cs` +- Create: `Tests/RCommon.Linq2Db.Tests/Linq2DbInboxStoreTests.cs` + +**Context:** Linq2Db LINQ API for inbox CRUD. Same pattern as `Linq2DbOutboxStore`. + +- [ ] **Step 1: Write failing tests** + +Constructor validation tests (same pattern as `Linq2DbOutboxStoreTests`): +- `Constructor_NullDataStoreFactory_ThrowsArgumentNullException` +- `Constructor_NullDefaultDataStoreOptions_ThrowsArgumentNullException` +- `Constructor_NullOutboxOptions_ThrowsArgumentNullException` + +- [ ] **Step 2: Run tests to verify they fail** + +Expected: Compilation error + +- [ ] **Step 3: Create Linq2DbInboxStore** + +Uses LINQ API: +- `ExistsAsync`: `Table.AnyAsync(m => m.MessageId == messageId && m.ConsumerType == (consumerType ?? ""))` +- `RecordAsync`: `DataConnection.InsertAsync(entity)` with `ConsumerType` coalesced +- `CleanupAsync`: `Table.Where(m => m.ReceivedAtUtc < cutoff).DeleteAsync()` + +- [ ] **Step 4: Run tests to verify they pass** + +Expected: All tests pass + +- [ ] **Step 5: Commit** + +```bash +git add Src/RCommon.Linq2Db/Inbox/ Tests/RCommon.Linq2Db.Tests/Linq2DbInboxStoreTests.cs +git commit -m "feat: Linq2DbInboxStore — inbox/idempotency for Linq2Db" +``` + +--- + +### Task 12: Full Build + Full Test Pass + +**Files:** +- Verify: All projects build, all tests pass + +**Context:** This is the final verification. Every prior task may have introduced subtle issues. Run the full build and test suite. Fix anything that breaks. + +- [ ] **Step 1: Full solution build** + +Run: `dotnet build Src/RCommon.sln` +Expected: 0 errors, 0 warnings (or only pre-existing warnings) + +If there are errors, fix them. Common issues: +- Missing `using` statements for `RCommon.Persistence.Inbox` namespace +- `UnitOfWorkOutboxTests.cs` may need `IOutboxStore` mock updates +- Any other test files that mock `IOutboxStore` with the old `GetPendingAsync` or `MarkFailedAsync` signatures + +- [ ] **Step 2: Full test suite** + +Run: `dotnet test Src/RCommon.sln -v minimal` +Expected: All tests pass (3,000+ tests, 0 failures) + +If tests fail, investigate and fix. Do NOT skip failing tests. + +- [ ] **Step 3: Commit any remaining fixes** + +```bash +git add -A +git commit -m "fix: resolve remaining build and test issues for Outbox V2" +``` + +Only commit if there were actual fixes needed. If everything passed clean, skip this step. diff --git a/docs/superpowers/specs/2026-03-16-ddd-entity-abstractions-design.md b/docs/superpowers/specs/2026-03-16-ddd-entity-abstractions-design.md new file mode 100644 index 00000000..cf0c2b1b --- /dev/null +++ b/docs/superpowers/specs/2026-03-16-ddd-entity-abstractions-design.md @@ -0,0 +1,382 @@ +# DDD Entity Abstractions for RCommon.Entities + +**Date:** 2026-03-16 +**Branch:** feature/ddd +**Status:** Design + +## Summary + +Add Domain-Driven Design tactical building blocks to the existing `RCommon.Entities` project: `AggregateRoot`, `DomainEntity`, `ValueObject`, and `IDomainEvent`. These types extend the existing `BusinessEntity` hierarchy and reuse the `IEntityEventTracker` pipeline for domain event dispatch. + +## Goals + +- Provide first-class DDD abstractions for aggregate roots, domain entities, and value objects +- Reuse existing infrastructure (`BusinessEntity`, `IEntityEventTracker`, `IEventRouter`) with zero breaking changes +- Maintain the generic key pattern (`TKey : IEquatable`) consistent with the rest of RCommon +- Keep scope focused: entity types + domain events only (no domain services, sagas, or event sourcing in this iteration) + +## Non-Goals + +- Domain service abstractions +- Guard/invariant helper classes +- Event sourcing infrastructure +- Aggregate-specific repository interfaces (persistence layer changes) +- Saga/process manager abstractions + +## Design Decisions + +### 1. Location: In-place in RCommon.Entities + +DDD types are added directly to `RCommon.Entities` in the `RCommon.Entities` namespace. Rationale: `AggregateRoot` inherits from `BusinessEntity`, and `IDomainEvent` extends `ISerializableEvent` — these are natural extensions of the existing hierarchy, not a separate concern. The project is small (12 files) and adding 6 more keeps it focused. + +### 2. AggregateRoot extends BusinessEntity + +`AggregateRoot` inherits from `BusinessEntity`. This reuses existing event tracking, key support, and entity equality. The `AddDomainEvent` method delegates to `AddLocalEvent`, making the entire event pipeline (`IEntityEventTracker` → `InMemoryEntityEventTracker` → `IEventRouter` → `IEventProducer`) work without modification. + +### 3. Value Objects use C# records + +`ValueObject` is an abstract record, leveraging C# record semantics for automatic structural equality, immutability, and `with`-expression support. This is the modern, idiomatic C# approach. + +### 4. IDomainEvent extends ISerializableEvent + +`IDomainEvent` extends the existing `ISerializableEvent` marker interface, adding `EventId` and `OccurredOn` metadata. This means domain events flow through the existing event routing pipeline unchanged. + +### 5. Versioning on AggregateRoot + +`AggregateRoot` includes a `Version` (int) property for optimistic concurrency control, decorated with `[ConcurrencyCheck]` to signal ORM-level concurrency checking. This is essential for eventual event sourcing support and is standard DDD practice for aggregate consistency. + +### 6. DomainEntity is lightweight + +`DomainEntity` is a standalone class (does not extend `BusinessEntity`) with identity-based equality but no event tracking. Entities within an aggregate raise events through their aggregate root, not directly. Because `DomainEntity` does not implement `IBusinessEntity`, the `ObjectGraphWalker` in `InMemoryEntityEventTracker` will not traverse it — this is intentional. All domain events must be raised on the aggregate root. + +### 7. Namespace style: block-scoped + +All new files use block-scoped namespace declarations (`namespace RCommon.Entities { ... }`) to match the existing convention in the project. + +### 8. IAggregateRoot constraint asymmetry + +`IAggregateRoot` adds `where TKey : IEquatable` while its parent `IBusinessEntity` has no such constraint. This is intentional — aggregate roots require identity equality for consistency guarantees. The concrete class `BusinessEntity` already has this constraint, so the class hierarchy compiles correctly. A non-generic `IAggregateRoot` marker interface is also provided for infrastructure scenarios (repository filtering, middleware, generic constraints). + +## Type Hierarchy + +``` +Existing (unchanged): + ITrackedEntity + IBusinessEntity + BusinessEntity (abstract, composite keys, event tracking) + BusinessEntity (abstract, single key, event tracking) + +New DDD types: + IAggregateRoot : IBusinessEntity (non-generic marker) + IAggregateRoot : IAggregateRoot, IBusinessEntity + AggregateRoot : BusinessEntity, IAggregateRoot + + DomainEntity (standalone, identity only, no event tracking) + + ValueObject (abstract record, structural equality) + + IDomainEvent : ISerializableEvent + DomainEvent (abstract record, base implementation) +``` + +## New Files + +All files are added to `Src/RCommon.Entities/` in the `RCommon.Entities` namespace. Total: 6 new files. + +### IDomainEvent.cs + +```csharp +using RCommon.Models.Events; + +namespace RCommon.Entities +{ + /// + /// Represents a domain event raised by an aggregate root. + /// Extends ISerializableEvent for compatibility with the existing event routing pipeline. + /// + public interface IDomainEvent : ISerializableEvent + { + /// + /// Unique identifier for this event instance. + /// + Guid EventId { get; } + + /// + /// The date and time when this event occurred. + /// + DateTimeOffset OccurredOn { get; } + } +} +``` + +### DomainEvent.cs + +```csharp +namespace RCommon.Entities +{ + /// + /// Abstract base record for domain events. Provides default values for EventId and OccurredOn. + /// Use as a base for all concrete domain events. + /// + public abstract record DomainEvent : IDomainEvent + { + public Guid EventId { get; init; } = Guid.NewGuid(); + public DateTimeOffset OccurredOn { get; init; } = DateTimeOffset.UtcNow; + } +} +``` + +### IAggregateRoot.cs + +```csharp +namespace RCommon.Entities +{ + /// + /// Non-generic marker interface for aggregate roots. + /// Useful for infrastructure scenarios such as repository filtering, middleware, and generic constraints. + /// + public interface IAggregateRoot : IBusinessEntity + { + /// + /// The version number used for optimistic concurrency control. + /// + int Version { get; } + + /// + /// The collection of domain events raised by this aggregate that have not yet been dispatched. + /// + IReadOnlyCollection DomainEvents { get; } + } + + /// + /// Generic interface for aggregate roots in the domain model. + /// Extends IBusinessEntity to maintain compatibility with existing repository and event tracking infrastructure. + /// Note: The IEquatable constraint is stricter than IBusinessEntity<TKey> — this is intentional + /// because aggregate roots require identity equality for consistency guarantees. + /// + public interface IAggregateRoot : IAggregateRoot, IBusinessEntity + where TKey : IEquatable + { + } +} +``` + +### AggregateRoot.cs + +```csharp +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace RCommon.Entities +{ + /// + /// Abstract base class for aggregate roots. Extends BusinessEntity to reuse event tracking, + /// key support, and entity equality. Adds versioning for optimistic concurrency and typed + /// domain event methods. + /// + /// The type of the aggregate's identity. + [Serializable] + public abstract class AggregateRoot : BusinessEntity, IAggregateRoot + where TKey : IEquatable + { + private readonly List _domainEvents = new(); + + /// + /// Version number for optimistic concurrency control. Incremented via . + /// Decorated with [ConcurrencyCheck] to signal ORM-level concurrency checking. + /// + [ConcurrencyCheck] + public virtual int Version { get; protected set; } + + /// + /// Returns the domain events that have been raised by this aggregate but not yet dispatched. + /// + [NotMapped] + public IReadOnlyCollection DomainEvents + => _domainEvents.AsReadOnly(); + + /// + /// Raises a domain event on this aggregate. The event is added to both the DomainEvents + /// collection and the base LocalEvents collection for dispatch via the event tracking pipeline. + /// + protected void AddDomainEvent(IDomainEvent domainEvent) + { + _domainEvents.Add(domainEvent); + AddLocalEvent(domainEvent); + } + + /// + /// Removes a previously raised domain event before it has been dispatched. + /// + protected void RemoveDomainEvent(IDomainEvent domainEvent) + { + _domainEvents.Remove(domainEvent); + RemoveLocalEvent(domainEvent); + } + + /// + /// Clears all pending domain events from this aggregate. + /// + public void ClearDomainEvents() + { + _domainEvents.Clear(); + ClearLocalEvents(); + } + + /// + /// Increments the version number for optimistic concurrency control. + /// Call this when the aggregate's state changes. + /// Note: This is not thread-safe. Aggregates are designed for single-threaded access. + /// + protected void IncrementVersion() + => Version++; + } +} +``` + +### DomainEntity.cs + +```csharp +namespace RCommon.Entities +{ + /// + /// Abstract base class for domain entities within an aggregate. Provides identity-based equality + /// but no event tracking — entities within an aggregate raise events through their aggregate root. + /// Because DomainEntity does not implement IBusinessEntity, the ObjectGraphWalker in + /// InMemoryEntityEventTracker will not traverse it. All domain events must be raised on the + /// aggregate root. + /// + /// The type of the entity's identity. + [Serializable] + public abstract class DomainEntity : IEquatable> + where TKey : IEquatable + { + /// + /// The unique identity of this entity. + /// + public virtual TKey Id { get; protected set; } = default!; + + public bool Equals(DomainEntity? other) + { + if (other is null) + return false; + + if (ReferenceEquals(this, other)) + return true; + + if (GetType() != other.GetType()) + return false; + + if (IsTransient() || other.IsTransient()) + return false; + + return Id.Equals(other.Id); + } + + public override bool Equals(object? obj) + => Equals(obj as DomainEntity); + + public override int GetHashCode() + { + var id = Id; + if (id is null || id.Equals(default(TKey))) + return base.GetHashCode(); + return id.GetHashCode(); + } + + /// + /// Returns true if this entity has not yet been assigned a persistent identity. + /// + public bool IsTransient() + => Id is null || Id.Equals(default); + + public static bool operator ==(DomainEntity? left, DomainEntity? right) + { + if (left is null && right is null) + return true; + if (left is null || right is null) + return false; + return left.Equals(right); + } + + public static bool operator !=(DomainEntity? left, DomainEntity? right) + => !(left == right); + } +} +``` + +### ValueObject.cs + +```csharp +namespace RCommon.Entities +{ + /// + /// Abstract base record for value objects. Leverages C# record semantics for automatic + /// structural equality, immutability, and with-expression support. + /// + /// Derive concrete value objects from this type: + /// + /// public record Money(decimal Amount, string Currency) : ValueObject; + /// public record Address(string Street, string City, string ZipCode) : ValueObject; + /// + /// + public abstract record ValueObject; +} +``` + +## Domain Event Flow + +The domain event dispatch flow reuses the existing infrastructure with zero modifications: + +``` +1. AggregateRoot.AddDomainEvent(IDomainEvent) + → adds to _domainEvents (typed collection) AND calls BusinessEntity.AddLocalEvent() + → IDomainEvent IS-A ISerializableEvent, so AddLocalEvent just works + → event stored in both AggregateRoot._domainEvents and BusinessEntity._localEvents + → C# event TransactionalEventAdded fires + +2. Repository.AddAsync/UpdateAsync/DeleteAsync(aggregate) + → EventTracker.AddEntity(aggregate) + → (existing behavior, unchanged) + +3. EmitTransactionalEventsAsync() + → InMemoryEntityEventTracker traverses object graph via ObjectGraphWalker + → Only discovers IBusinessEntity instances (DomainEntity is NOT traversed — intentional) + → Collects LocalEvents from aggregate root (and any nested IBusinessEntity children) + → IEventRouter.AddTransactionalEvents() + RouteEventsAsync() + → IEventProducer dispatches via MediatR, EventBus, MassTransit, etc. +``` + +**Important:** The `ObjectGraphWalker` in `InMemoryEntityEventTracker` traverses for `IBusinessEntity`. Since `DomainEntity` does not implement `IBusinessEntity`, child entities using `DomainEntity` will not be traversed. All domain events must be raised on the `AggregateRoot`, not on child `DomainEntity` instances. + +**Known limitation:** `BusinessEntity` exposes `AddLocalEvent`, `RemoveLocalEvent`, and `ClearLocalEvents` as public methods inherited by `AggregateRoot`. External callers could bypass `AddDomainEvent`/`RemoveDomainEvent`/`ClearDomainEvents` (which maintain the dual-list sync between `_domainEvents` and `_localEvents`). Using the inherited methods directly would break the dual-list invariant. Consumers should always use the `DomainEvent`-prefixed methods on aggregate roots. A future iteration could use `new` keyword hiding to intercept these calls. + +**No changes required to:** +- `IEntityEventTracker` interface +- `InMemoryEntityEventTracker` implementation +- `IEventRouter` / `InMemoryTransactionalEventRouter` +- Repository base classes (`LinqRepositoryBase`, `GraphRepositoryBase`, `EFCoreRepository`) +- Event producer implementations + +## Existing Files: No Modifications + +This design requires zero changes to existing files. All new types are additive. + +## Testing Strategy + +Unit tests should cover: +- `AggregateRoot`: domain event add/remove/clear, version increment, DomainEvents projection, dual-list sync (events appear in both DomainEvents and LocalEvents) +- `DomainEntity`: identity-based equality, transient detection, type-mismatch inequality, null Id handling in GetHashCode +- `ValueObject`: structural equality via record semantics, inequality for different values +- `DomainEvent`: default `EventId` and `OccurredOn` generation, `init` property overrides +- Integration: verify domain events raised on `AggregateRoot` flow through `InMemoryEntityEventTracker` and `IEventRouter` correctly + +Note: `AggregateRoot` is designed for single-threaded access per DDD convention (one aggregate per transaction). Thread-safety testing is not required. + +## Future Considerations + +These are explicitly out of scope but inform the design: +- **Event sourcing**: `Version` on `AggregateRoot` is already positioned for event store append operations +- **Aggregate repository**: A future `IAggregateRepository` could enforce loading/saving complete aggregates +- **Domain services**: `IDomainService` marker interface could be added later +- **Saga/process managers**: Could consume `IDomainEvent` types for orchestration diff --git a/docs/superpowers/specs/2026-03-17-aggregate-repository-design.md b/docs/superpowers/specs/2026-03-17-aggregate-repository-design.md new file mode 100644 index 00000000..f8ab70e1 --- /dev/null +++ b/docs/superpowers/specs/2026-03-17-aggregate-repository-design.md @@ -0,0 +1,976 @@ +# DDD Infrastructure — Design Specification + +**Date:** 2026-03-17 +**Branch:** feature/ddd +**Status:** Approved + +## Problem + +The existing repository interfaces (`ILinqRepository`, `IGraphRepository`, `ISqlMapperRepository`) accept any `IBusinessEntity` type parameter. There is no compile-time enforcement that prevents persisting child entities (`DomainEntity`) directly, bypassing the aggregate root boundary. Additionally, domain events raised by aggregates are not automatically dispatched after persistence, there is no dedicated read-model query path, and there is no saga/process manager infrastructure for coordinating multi-step workflows. + +## Goal + +Extend RCommon's DDD support with four interconnected capabilities: + +1. **Aggregate Repository** — `IAggregateRepository` with compile-time enforcement, DDD-constrained API, open-generic registration, and non-breaking coexistence with existing repositories. +2. **Automatic Domain Event Dispatch** — UnitOfWork post-commit hook that dispatches accumulated domain events through the existing `IEntityEventTracker` → `IEventRouter` → `IEventProducer` pipeline. +3. **Read-Model Repositories** — `IReadModelRepository` for CQRS query-side access with paging, counting, and compile-time separation from write-model types. +4. **Saga & Process Manager Patterns** — `ISaga` orchestration with `IStateMachine` abstraction over state machine libraries (Stateless, MassTransit), `ISagaStore` for persistence, plus choreography via existing event infrastructure. + +## Non-Goals + +- Event sourcing integration (prepared for via `AggregateRoot.Version`, but not implemented here) +- Transactional outbox pattern (future enhancement for reliable event delivery) +- Concrete state machine adapters beyond interface definitions (Stateless/MassTransit adapters are separate packages) + +--- + +## Part 1: Aggregate Repository + +### Interface Hierarchy + +The new interface sits alongside (not above) the existing repository interfaces: + +``` +Existing (unchanged): + IReadOnlyRepository where TEntity : IBusinessEntity + IWriteOnlyRepository where TEntity : IBusinessEntity + ILinqRepository : IReadOnlyRepository, IWriteOnlyRepository, IEagerLoadableQueryable + IGraphRepository : ILinqRepository + ISqlMapperRepository : IReadOnlyRepository, IWriteOnlyRepository + +New: + IAggregateRepository + where TAggregate : class, IAggregateRoot + where TKey : IEquatable +``` + +`IAggregateRepository` does NOT inherit from `ILinqRepository`, `IGraphRepository`, or any existing repository interface. It does inherit `INamedDataSource` to support multi-database scenarios (consistent with all existing repository interfaces). This prevents consumers from casting up to the full query surface while preserving data store targeting. + +### Interface Definition + +**Location:** `Src/RCommon.Persistence/Crud/IAggregateRepository.cs` + +```csharp +public interface IAggregateRepository : INamedDataSource + where TAggregate : class, IAggregateRoot + where TKey : IEquatable +{ + // Read + Task GetByIdAsync(TKey id, CancellationToken cancellationToken = default); + Task FindAsync(ISpecification specification, CancellationToken cancellationToken = default); + Task ExistsAsync(TKey id, CancellationToken cancellationToken = default); + + // Write + Task AddAsync(TAggregate aggregate, CancellationToken cancellationToken = default); + Task UpdateAsync(TAggregate aggregate, CancellationToken cancellationToken = default); + Task DeleteAsync(TAggregate aggregate, CancellationToken cancellationToken = default); + + // Eager loading (fluent builder for aggregate graph) + IAggregateRepository Include( + Expression> path); + IAggregateRepository ThenInclude( + Expression> path); +} +``` + +**API decisions:** + +- **No `FindAllAsync`** — Aggregates should be loaded individually. Collection queries belong in read models or query handlers. +- **`ExistsAsync(TKey id)`** — Lightweight existence check without loading the full aggregate. Useful for validation before operations. +- **No `GetCountAsync`/`AnyAsync`** — Query/reporting concerns, not aggregate operations. +- **Include/ThenInclude** — Fluent chaining for eager loading child entities within the aggregate boundary. Returns `IAggregateRepository` for chaining. Note: this uses a generic `TProperty` parameter (not `object` like the existing `ILinqRepository.Include`), which provides stronger typing. Concrete implementations use **explicit interface implementation** to satisfy both the `IAggregateRepository.Include` (returning `IAggregateRepository`) and the inherited base class `Include` (returning `IEagerLoadableQueryable`) separately. +- **`INamedDataSource` inheritance** — Exposes `DataStoreName` property for multi-database targeting, consistent with all existing repository interfaces. +- **All methods have `CancellationToken`** — Consistent with the async hardening work done in prior commits. +- **Immediate save semantics** — `AddAsync`/`UpdateAsync`/`DeleteAsync` call `SaveChangesAsync` immediately, matching the existing repository behavior. Future UnitOfWork integration may defer persistence, but that is out of scope for this spec. + +### Known Trade-offs + +- **Base class API surface leak:** The concrete implementations inherit from ORM base classes (e.g., `GraphRepositoryBase`), which means the concrete type also implements `IGraphRepository` and its full hierarchy (~25+ methods from `LinqRepositoryBase`). These base class abstract methods are inherited/delegated automatically — the aggregate repository only exposes the narrow `IAggregateRepository` surface via DI. Runtime casting from `IAggregateRepository` to `IGraphRepository` would succeed but is the consumer's responsibility to avoid. This is an acceptable trade-off for infrastructure reuse (event tracking, data store resolution, soft-delete/tenant filtering, logging). + +### Concrete Implementations + +Each ORM gets one concrete implementation that inherits from its existing repository base class for infrastructure reuse (event tracking, data store resolution, soft-delete/tenant filtering, logging). + +#### EFCore + +**Location:** `Src/RCommon.EfCore/Crud/EFCoreAggregateRepository.cs` + +`EFCoreAggregateRepository : GraphRepositoryBase, IAggregateRepository` + +- `GetByIdAsync` → `FilteredRepositoryQuery.FirstOrDefaultAsync(e => e.Id.Equals(id))` (uses queryable path, not `DbSet.FindAsync`, because `FindAsync` ignores `Include` chains) +- `FindAsync` → `FilteredRepositoryQuery.Where(spec.Predicate).FirstOrDefaultAsync()` +- `ExistsAsync` → `FilteredRepositoryQuery.AnyAsync(e => e.Id.Equals(id))` +- `AddAsync` → `DbSet.AddAsync(aggregate)` + `EventTracker.AddEntity(aggregate)` + `SaveChangesAsync()` (matches existing `EFCoreRepository` immediate-save behavior) +- `UpdateAsync` → `DbSet.Update(aggregate)` + `EventTracker.AddEntity(aggregate)` + `SaveChangesAsync()` +- `DeleteAsync` → soft-delete via `ISoftDelete` or `DbSet.Remove(aggregate)` + `EventTracker.AddEntity(aggregate)` + `SaveChangesAsync()`. Supports the same dual-mode delete behavior as existing `EFCoreRepository` (physical delete by default, soft-delete when aggregate implements `ISoftDelete`). +- `Include/ThenInclude` → builds `IQueryable` using EF Core's `EntityFrameworkQueryableExtensions.Include/ThenInclude`. The `Include` method on `IAggregateRepository` is an explicit interface implementation returning `IAggregateRepository`; the inherited base class `Include` (returning `IEagerLoadableQueryable`) is also implemented for internal use. Both methods can coexist because explicit interface implementation disambiguates them. + +#### Dapper + +**Location:** `Src/RCommon.Dapper/Crud/DapperAggregateRepository.cs` + +`DapperAggregateRepository : SqlRepositoryBase, IAggregateRepository` + +- `GetByIdAsync` → `connection.GetAsync(id)` via Dommel +- `FindAsync` → `connection.SelectAsync(spec.Predicate).FirstOrDefault()` +- `ExistsAsync` → `connection.GetAsync(id) != null` +- `AddAsync/UpdateAsync/DeleteAsync` → Dommel CRUD operations + `EventTracker.AddEntity(aggregate)` +- `Include/ThenInclude` → no-op (returns `this`). Dapper does not support eager loading natively; aggregate child loading must be handled manually or via multi-queries in domain-specific repository subclasses. + +#### Linq2Db + +**Location:** `Src/RCommon.Linq2Db/Crud/Linq2DbAggregateRepository.cs` + +`Linq2DbAggregateRepository : LinqRepositoryBase, IAggregateRepository` + +- `GetByIdAsync` → `Table.FirstOrDefaultAsync(e => e.Id.Equals(id))` +- `FindAsync` → `Table.Where(spec.Predicate).FirstOrDefaultAsync()` +- `ExistsAsync` → `Table.AnyAsync(e => e.Id.Equals(id))` +- `AddAsync/UpdateAsync/DeleteAsync` → Linq2Db CRUD operations + `EventTracker.AddEntity(aggregate)` +- `Include/ThenInclude` → uses Linq2Db's `LoadWith` where applicable + +### DI Registration + +Each ORM builder adds the open-generic registration in its constructor, alongside the existing repository registrations. + +**EFCorePerisistenceBuilder** (note: existing filename has `Perisistence` typo): +```csharp +// Existing +services.AddTransient(typeof(IGraphRepository<>), typeof(EFCoreRepository<>)); +// New +services.AddTransient(typeof(IAggregateRepository<,>), typeof(EFCoreAggregateRepository<,>)); +``` + +**DapperPersistenceBuilder:** +```csharp +// Existing +services.AddTransient(typeof(ISqlMapperRepository<>), typeof(DapperRepository<>)); +// New +services.AddTransient(typeof(IAggregateRepository<,>), typeof(DapperAggregateRepository<,>)); +``` + +**Linq2DbPersistenceBuilder:** +```csharp +// Existing +services.AddTransient(typeof(ILinqRepository<>), typeof(Linq2DbRepository<>)); +// New +services.AddTransient(typeof(IAggregateRepository<,>), typeof(Linq2DbAggregateRepository<,>)); +``` + +### Consumer Usage + +```csharp +public class PlaceOrderHandler +{ + private readonly IAggregateRepository _orders; + + public PlaceOrderHandler(IAggregateRepository orders) + { + _orders = orders; + } + + public async Task HandleAsync(PlaceOrderCommand cmd, CancellationToken ct) + { + var order = new Order(cmd.CustomerId); + order.AddLineItem(cmd.ProductId, cmd.Quantity, cmd.Price); + + await _orders.AddAsync(order, ct); + } +} +``` + +--- + +## Part 2: Automatic Domain Event Dispatch + +### Mechanism + +The `UnitOfWork` gains an optional dependency on `IEntityEventTracker`. After the transaction is fully committed (i.e., after `TransactionScope.Dispose()` following a `Complete()` call), it dispatches accumulated domain events through the existing pipeline: `IEntityEventTracker` → `IEventRouter` → `IEventProducer` → `IEventBus` → `ISubscriber`. + +**Critical timing detail:** `TransactionScope.Complete()` only *marks* the scope as ready to commit. The actual database commit occurs when `TransactionScope.Dispose()` is called. Therefore, event dispatch must happen *after* scope disposal, not between `Complete()` and `Dispose()`. + +### Updated IUnitOfWork Interface + +**Location:** `Src/RCommon.Persistence/Transactions/IUnitOfWork.cs` + +```csharp +public interface IUnitOfWork : IDisposable +{ + Guid TransactionId { get; } + TransactionMode TransactionMode { get; set; } + IsolationLevel IsolationLevel { get; set; } + UnitOfWorkState State { get; } + bool AutoComplete { get; } + + [Obsolete("Use CommitAsync instead for automatic domain event dispatch.")] + void Commit(); + Task CommitAsync(CancellationToken cancellationToken = default); +} +``` + +**Note:** `IUnitOfWork` remains `IDisposable` only (not `IAsyncDisposable`). Adding `IAsyncDisposable` would be a breaking change for any external `IUnitOfWork` implementations. The concrete `UnitOfWork` class already inherits `IAsyncDisposable` from `DisposableResource` for callers that need `await using`. + +### Modified UnitOfWork Implementation + +**Location:** `Src/RCommon.Persistence/Transactions/UnitOfWork.cs` + +The existing constructor signatures are preserved. `IEntityEventTracker?` is added as an optional parameter to both overloads for backward compatibility: + +```csharp +public class UnitOfWork : DisposableResource, IUnitOfWork +{ + private readonly ILogger _logger; + private readonly IGuidGenerator _guidGenerator; + private readonly IEntityEventTracker? _eventTracker; + private UnitOfWorkState _state; + private TransactionScope _transactionScope; + private bool _transactionScopeDisposed; + + // Overload 1: settings-based (used by UnitOfWorkFactory) + public UnitOfWork( + ILogger logger, + IGuidGenerator guidGenerator, + IOptions unitOfWorkSettings, + IEntityEventTracker? eventTracker = null) + { + _logger = logger; + _guidGenerator = guidGenerator; + _eventTracker = eventTracker; + TransactionId = _guidGenerator.Create(); + TransactionMode = TransactionMode.Default; + IsolationLevel = unitOfWorkSettings.Value.DefaultIsolation; + AutoComplete = unitOfWorkSettings.Value.AutoCompleteScope; + _state = UnitOfWorkState.Created; + _transactionScope = TransactionScopeHelper.CreateScope(_logger, this); + } + + // Overload 2: explicit settings + public UnitOfWork( + ILogger logger, + IGuidGenerator guidGenerator, + TransactionMode transactionMode, + IsolationLevel isolationLevel, + IEntityEventTracker? eventTracker = null) + { + _logger = logger; + _guidGenerator = guidGenerator; + _eventTracker = eventTracker; + TransactionId = _guidGenerator.Create(); + TransactionMode = transactionMode; + IsolationLevel = isolationLevel; + AutoComplete = false; + _state = UnitOfWorkState.Created; + _transactionScope = TransactionScopeHelper.CreateScope(_logger, this); + } + + public async Task CommitAsync(CancellationToken cancellationToken = default) + { + Guard.Against(_state == UnitOfWorkState.Disposed, + "Cannot commit a disposed UnitOfWorkScope instance."); + Guard.Against(_state == UnitOfWorkState.Completed, + "This unit of work scope has been marked completed."); + + _state = UnitOfWorkState.CommitAttempted; + + // 1. Mark scope for commit + _transactionScope.Complete(); + + // 2. Dispose scope — this is where the actual DB commit occurs + _transactionScope.Dispose(); + _transactionScopeDisposed = true; + _state = UnitOfWorkState.Completed; + + // 3. Post-commit: dispatch domain events (transaction is fully committed) + if (_eventTracker != null) + { + var dispatched = await _eventTracker + .EmitTransactionalEventsAsync() + .ConfigureAwait(false); + + if (!dispatched) + { + _logger.LogWarning( + "UnitOfWork {TransactionId}: domain event dispatch returned false.", + TransactionId); + } + } + } + + [Obsolete("Use CommitAsync instead for automatic domain event dispatch.")] + public void Commit() + { + // Preserved for backward compatibility — no event dispatch + Guard.Against(_state == UnitOfWorkState.Disposed, ...); + Guard.Against(_state == UnitOfWorkState.Completed, ...); + _state = UnitOfWorkState.CommitAttempted; + _transactionScope.Complete(); + _state = UnitOfWorkState.Completed; + } + + protected override void Dispose(bool disposing) + { + // ... existing logic, with guard for already-disposed scope: + // In the finally block: + if (!_transactionScopeDisposed) + { + _transactionScope.Dispose(); + } + _state = UnitOfWorkState.Disposed; + base.Dispose(disposing); + } +} +``` + +### Design Decisions + +- **`CommitAsync` as primary API** — New async method handles transaction commit + event dispatch. The synchronous `Commit()` is marked `[Obsolete]` but preserved for backward compatibility and does NOT dispatch events (avoids sync-over-async deadlocks). +- **Optional `IEntityEventTracker`** — Constructor parameter defaults to `null`. When no tracker is injected (non-DDD usage), the commit path is unchanged. No breaking change. +- **Post-commit dispatch timing** — `CommitAsync` calls `TransactionScope.Complete()` then `TransactionScope.Dispose()` before dispatching events. This ensures the database transaction is fully committed before handlers execute. The `_transactionScopeDisposed` flag prevents double-disposal in `Dispose(bool)`. +- **`EmitTransactionalEventsAsync` return value** — The existing method returns `Task`. A `false` result is logged as a warning but does not throw, because the committed data should not be rolled back due to event dispatch issues. +- **No outbox** — If event dispatch fails after commit, events are lost. A future transactional outbox pattern can address this by storing events in the same transaction and dispatching via a background worker. This is explicitly out of scope. + +### UnitOfWorkBehavior Migration + +The existing `UnitOfWorkRequestBehavior` (MediatR pipeline behavior) calls the synchronous `Commit()`. Since it runs in an async context (`Handle` returns `Task`), it should be updated to call `await CommitAsync(cancellationToken)` to enable automatic domain event dispatch and avoid sync-over-async deadlock risks. + +### Event Dispatch Clarification: DomainEvents vs LocalEvents + +`AggregateRoot.AddDomainEvent()` adds to both the `_domainEvents` collection and the `_localEvents` collection (via `AddLocalEvent()`). The `DomainEvents` property is a read-only view for the aggregate itself (inspection, testing). The `LocalEvents` collection is what drives the event dispatch pipeline through `IEntityEventTracker`. + +### Event Flow (End-to-End) + +``` +1. AggregateRoot.AddDomainEvent(new OrderCreatedEvent(...)) + → adds to DomainEvents (read-only view) + LocalEvents (dispatch pipeline) +2. Repository.AddAsync(aggregate) + → EventTracker.AddEntity(aggregate) registers for tracking + → SaveChangesAsync() persists to database +3. UnitOfWork.CommitAsync() + → TransactionScope.Complete() marks scope for commit + → TransactionScope.Dispose() — actual DB commit happens here + → EventTracker.EmitTransactionalEventsAsync(): + - Traverses object graph for nested IBusinessEntity instances + - Collects all LocalEvents from root + children + - Routes via IEventRouter → IEventProducer → IEventBus + → ISubscriber.HandleAsync() executes +``` + +--- + +## Part 3: Read-Model Repositories + +### IReadModel Marker Interface + +**Location:** `Src/RCommon.Persistence/IReadModel.cs` + +```csharp +/// +/// Marker interface for read-model/projection types used in CQRS query-side repositories. +/// Read models are optimized for querying and do not participate in domain event tracking. +/// +public interface IReadModel { } +``` + +### IReadModelRepository Interface + +**Location:** `Src/RCommon.Persistence/Crud/IReadModelRepository.cs` + +```csharp +public interface IReadModelRepository : INamedDataSource + where TReadModel : class, IReadModel +{ + // Single result + Task FindAsync( + ISpecification specification, + CancellationToken cancellationToken = default); + + // Collection results + Task> FindAllAsync( + ISpecification specification, + CancellationToken cancellationToken = default); + + // Paged results + Task> GetPagedAsync( + IPagedSpecification specification, + CancellationToken cancellationToken = default); + + // Counting + Task GetCountAsync( + ISpecification specification, + CancellationToken cancellationToken = default); + + // Existence + Task AnyAsync( + ISpecification specification, + CancellationToken cancellationToken = default); + + // Eager loading + IReadModelRepository Include( + Expression> path); +} +``` + +### IPagedResult Interface + +**Location:** `Src/RCommon.Models/IPagedResult.cs` + +```csharp +public interface IPagedResult +{ + IReadOnlyList Items { get; } + long TotalCount { get; } + int PageNumber { get; } + int PageSize { get; } + int TotalPages { get; } + bool HasNextPage { get; } + bool HasPreviousPage { get; } +} +``` + +**Relationship to `IPaginatedList`:** The existing `IPaginatedList` (in `RCommon.Core/Collections/`) extends `IList` and is a mutable, self-contained collection. `IPagedResult` is a read-only result envelope that wraps items with pagination metadata. They serve different purposes: `IPaginatedList` for in-memory collections, `IPagedResult` for query results returned from repositories. + +### PagedResult Implementation + +**Location:** `Src/RCommon.Models/PagedResult.cs` + +```csharp +public class PagedResult : IPagedResult +{ + public IReadOnlyList Items { get; } + public long TotalCount { get; } + public int PageNumber { get; } + public int PageSize { get; } + public int TotalPages => PageSize > 0 ? (int)Math.Ceiling((double)TotalCount / PageSize) : 0; + public bool HasNextPage => PageNumber < TotalPages; + public bool HasPreviousPage => PageNumber > 1; + + public PagedResult(IReadOnlyList items, long totalCount, int pageNumber, int pageSize) + { + Guard.Against(pageSize <= 0, "PageSize must be greater than zero."); + Items = items; + TotalCount = totalCount; + PageNumber = pageNumber; + PageSize = pageSize; + } +} +``` + +### Design Decisions + +- **No write operations** — Read models are populated by event handlers or projections, not through this repository. +- **`IReadModel` constraint** — Prevents accidentally querying aggregate types through the read path. +- **`IPagedResult`** — Structured paging result with total count for UI pagination. Distinct from `IPaginatedList` (see above). +- **`PageSize` guard** — Constructor throws `ArgumentOutOfRangeException` if `pageSize <= 0` to prevent division-by-zero in `TotalPages`. +- **No `ThenInclude`** — Read models are typically flat/denormalized; single-level Include is sufficient. +- **No event tracking** — Read model repositories do NOT inject `IEntityEventTracker`; read operations don't produce domain events. +- **`INamedDataSource`** — Supports targeting a read-optimized database (common CQRS pattern). + +### Concrete Implementations (Composition Pattern) + +Read-model concrete implementations use **composition** rather than inheriting from `LinqRepositoryBase` / `SqlRepositoryBase`. This is necessary because those base classes constrain `TEntity` to `IBusinessEntity`, but read models implement `IReadModel` (not `IBusinessEntity`). Composition allows clean read models that are simple POCOs with only the `IReadModel` marker. + +Each implementation wraps the underlying ORM data access directly: + +| ORM | Class | Approach | Location | +|-----|-------|----------|----------| +| EF Core | `EFCoreReadModelRepository` | Wraps `DbContext` + `DbSet` directly | `Src/RCommon.EfCore/Crud/` | +| Dapper | `DapperReadModelRepository` | Wraps `IDbConnection` via Dommel | `Src/RCommon.Dapper/Crud/` | +| Linq2Db | `Linq2DbReadModelRepository` | Wraps `IDataContext.GetTable()` | `Src/RCommon.Linq2Db/Crud/` | + +Each implementation resolves its data store via `IDataStoreFactory` (injected) and `DataStoreName` (from `INamedDataSource`) to support multi-database targeting, consistent with existing repositories. + +### DI Registration + +Added to each ORM builder alongside existing registrations: + +```csharp +// EFCore +services.AddTransient(typeof(IReadModelRepository<>), typeof(EFCoreReadModelRepository<>)); + +// Dapper +services.AddTransient(typeof(IReadModelRepository<>), typeof(DapperReadModelRepository<>)); + +// Linq2Db +services.AddTransient(typeof(IReadModelRepository<>), typeof(Linq2DbReadModelRepository<>)); +``` + +### Consumer Usage + +```csharp +// Read model (clean POCO with IReadModel marker) +public class OrderSummary : IReadModel +{ + public Guid OrderId { get; set; } + public string CustomerName { get; set; } = default!; + public decimal Total { get; set; } + public string Status { get; set; } = default!; + public DateTimeOffset PlacedAt { get; set; } +} + +// Query handler +public class GetOrderSummariesHandler +{ + private readonly IReadModelRepository _orders; + + public GetOrderSummariesHandler(IReadModelRepository orders) + { + _orders = orders; + } + + public async Task> HandleAsync( + GetOrderSummaries query, CancellationToken ct) + { + var spec = new PagedSpecification( + o => o.Status == query.StatusFilter, + query.Page, query.PageSize, + o => o.PlacedAt, SortDirection.Descending); + + return await _orders.GetPagedAsync(spec, ct); + } +} +``` + +--- + +## Part 4: Saga & Process Manager Patterns + +### 4A. State Machine Abstraction + +**Location:** `Src/RCommon.Core/StateMachines/` +**Namespace:** `RCommon.StateMachines` + +The state machine abstraction decouples saga logic from any specific library (Stateless, MassTransit Automatonymous, etc.). Concrete adapters are separate NuGet packages. These interfaces live in `RCommon.Core` because a state machine is a general-purpose abstraction that can exist without persistence (e.g., coordinating steps within a single request). The saga types that depend on persistence live separately in `RCommon.Persistence/Sagas/`. + +```csharp +// Core abstraction +public interface IStateMachine + where TState : struct, Enum + where TTrigger : struct, Enum +{ + TState CurrentState { get; } + Task FireAsync(TTrigger trigger, CancellationToken cancellationToken = default); + Task FireAsync(TTrigger trigger, TData data, CancellationToken cancellationToken = default); + bool CanFire(TTrigger trigger); + IEnumerable PermittedTriggers { get; } +} + +// Configuration builder (fluent API for defining transitions) +public interface IStateMachineConfigurator + where TState : struct, Enum + where TTrigger : struct, Enum +{ + IStateConfigurator ForState(TState state); + IStateMachine Build(TState initialState); +} + +public interface IStateConfigurator + where TState : struct, Enum + where TTrigger : struct, Enum +{ + IStateConfigurator Permit(TTrigger trigger, TState destinationState); + IStateConfigurator OnEntry(Func action); + IStateConfigurator OnExit(Func action); + IStateConfigurator PermitIf( + TTrigger trigger, TState destinationState, Func guard); +} +``` + +**Concrete adapters (separate packages, out of scope for this spec):** +- `RCommon.Stateless` → `StatelessStateMachine` wrapping `Stateless.StateMachine` +- `RCommon.MassTransit` → adapter wrapping MassTransit's Automatonymous state machine + +### 4B. Saga State + +**Location:** `Src/RCommon.Persistence/Sagas/SagaState.cs` +**Namespace:** `RCommon.Persistence.Sagas` + +```csharp +/// +/// Base class for saga state that is persisted across steps. +/// Tracks lifecycle, correlation, and fault information. +/// +public abstract class SagaState + where TKey : IEquatable +{ + public TKey Id { get; set; } = default!; + public string CorrelationId { get; set; } = default!; + public DateTimeOffset StartedAt { get; set; } + public DateTimeOffset? CompletedAt { get; set; } + public string CurrentStep { get; set; } = default!; + public bool IsCompleted { get; set; } + public bool IsFaulted { get; set; } + public string? FaultReason { get; set; } + public int Version { get; set; } // optimistic concurrency +} +``` + +**Note:** `CurrentStep` is stored as `string` for database serialization compatibility. The `SagaOrchestrator` handles the `string` ↔ `enum` conversion internally via `Enum.Parse` / `ToString()`. + +### 4C. Saga Orchestrator + +**Location:** `Src/RCommon.Persistence/Sagas/ISaga.cs` + +```csharp +/// +/// Defines a saga orchestrator that coordinates multi-step workflows. +/// Subscribes to domain events and advances state through a state machine. +/// +public interface ISaga + where TState : SagaState + where TKey : IEquatable +{ + Task HandleAsync(TEvent @event, TState state, CancellationToken ct = default) + where TEvent : ISerializableEvent; + Task CompensateAsync(TState state, CancellationToken ct = default); +} +``` + +**Location:** `Src/RCommon.Persistence/Sagas/SagaOrchestrator.cs` + +```csharp +/// +/// Abstract base class for saga orchestrators that use a state machine +/// to coordinate transitions between steps. +/// +public abstract class SagaOrchestrator + : ISaga + where TState : SagaState + where TKey : IEquatable + where TSagaState : struct, Enum + where TSagaTrigger : struct, Enum +{ + private readonly IStateMachineConfigurator _configurator; + private IStateMachine? _stateMachineTemplate; + + protected ISagaStore Store { get; } + + protected SagaOrchestrator( + ISagaStore store, + IStateMachineConfigurator configurator) + { + Store = store; + _configurator = configurator; + } + + /// + /// Define state machine transitions (called once during lazy initialization). + /// + protected abstract void ConfigureStateMachine( + IStateMachineConfigurator configurator); + + /// + /// Map an incoming domain event to a state machine trigger. + /// + protected abstract TSagaTrigger MapEventToTrigger(TEvent @event) + where TEvent : ISerializableEvent; + + /// + /// The initial state for new saga instances. + /// + protected abstract TSagaState InitialState { get; } + + /// + /// Ensures the state machine configuration is applied exactly once. + /// + private void EnsureConfigured() + { + if (_stateMachineTemplate == null) + { + ConfigureStateMachine(_configurator); + _stateMachineTemplate = _configurator.Build(InitialState); + } + } + + public async Task HandleAsync(TEvent @event, TState state, CancellationToken ct) + where TEvent : ISerializableEvent + { + EnsureConfigured(); + + // Determine the current state — use InitialState if CurrentStep is not yet set + var currentState = string.IsNullOrEmpty(state.CurrentStep) + ? InitialState + : Enum.Parse(state.CurrentStep); + + var machine = _configurator.Build(currentState); + var trigger = MapEventToTrigger(@event); + + if (!machine.CanFire(trigger)) + return; // Invalid transition — ignore + + await machine.FireAsync(trigger, ct).ConfigureAwait(false); + state.CurrentStep = machine.CurrentState.ToString()!; + await Store.SaveAsync(state, ct).ConfigureAwait(false); + } + + /// + /// Execute compensation logic to reverse completed steps. + /// + public abstract Task CompensateAsync(TState state, CancellationToken ct); +} +``` + +**Key design decisions:** +- **`Store` is a `protected` property** — subclasses need access to look up saga state by correlation ID in their `ISubscriber.HandleAsync` methods. +- **Lazy initialization** — `ConfigureStateMachine` is called once (via `EnsureConfigured()`) and the configurator is reused. Each `HandleAsync` call builds a fresh state machine instance with the correct initial state for that saga instance. +- **Null `CurrentStep` handling** — New saga instances that haven't transitioned yet use `InitialState` instead of attempting `Enum.Parse` on a null string. +- **`struct, Enum` constraints** — `TSagaState` and `TSagaTrigger` are constrained to `struct, Enum` (C# 7.3+), making `Enum.Parse` safe at compile time. + +### 4D. Saga Persistence + +**Location:** `Src/RCommon.Persistence/Sagas/ISagaStore.cs` + +```csharp +/// +/// Persistence interface for saga state. Supports lookup by correlation ID +/// (for event-driven saga resolution) and by primary key. +/// +public interface ISagaStore + where TState : SagaState + where TKey : IEquatable +{ + Task FindByCorrelationIdAsync(string correlationId, CancellationToken ct = default); + Task GetByIdAsync(TKey id, CancellationToken ct = default); + Task SaveAsync(TState state, CancellationToken ct = default); + Task DeleteAsync(TState state, CancellationToken ct = default); +} +``` + +**Concrete implementations:** + +| ORM | Class | Location | Lifetime | +|-----|-------|----------|----------| +| EF Core | `EFCoreSagaStore` | `Src/RCommon.EfCore/Sagas/` | Scoped | +| Dapper | `DapperSagaStore` | `Src/RCommon.Dapper/Sagas/` | Scoped | +| Linq2Db | `Linq2DbSagaStore` | `Src/RCommon.Linq2Db/Sagas/` | Scoped | +| In-Memory | `InMemorySagaStore` | `Src/RCommon.Persistence/Sagas/` | Scoped | + +**Lifetime rationale:** `ISagaStore` is registered as **Scoped** (not Transient like `IAggregateRepository`) because saga state stores may hold DbContext or connection references that should be scoped per request. `IAggregateRepository` is Transient because it follows the existing repository pattern and creates its own DbContext via `IDataStoreFactory`. + +### 4E. Choreography Pattern + +Choreography requires **no new infrastructure**. It uses the existing event system: + +1. **Event handlers** (`ISubscriber`) react to domain events +2. **Each handler** performs its step and raises new domain events via `IEventProducer` +3. **No central coordinator** — the workflow emerges from the chain of event → handler → event + +The pattern is supported via: +- Existing `ISubscriber` for step handlers +- Existing `IEventProducer` for publishing follow-up events +- Existing `ISyncEvent`/`IAsyncEvent` markers for dispatch strategy + +### 4F. DI Registration + +```csharp +// Core (default in-memory store for development/testing) +services.AddScoped(typeof(ISagaStore<,>), typeof(InMemorySagaStore<,>)); + +// EFCore builder (overrides in-memory) +services.AddScoped(typeof(ISagaStore<,>), typeof(EFCoreSagaStore<,>)); + +// State machine adapter (separate package, e.g. RCommon.Stateless) +services.AddTransient(typeof(IStateMachineConfigurator<,>), typeof(StatelessConfigurator<,>)); +``` + +### Consumer Usage (Orchestration) + +```csharp +public enum OrderSagaStep { Pending, PaymentProcessed, Shipped, Completed, Compensating } +public enum OrderSagaTrigger { PaymentReceived, ShipmentConfirmed, DeliveryConfirmed, Failure } + +public class OrderSagaData : SagaState +{ + public Guid OrderId { get; set; } + public Guid PaymentId { get; set; } +} + +public class OrderSaga + : SagaOrchestrator, + ISubscriber, + ISubscriber +{ + protected override OrderSagaStep InitialState => OrderSagaStep.Pending; + + public OrderSaga( + ISagaStore store, + IStateMachineConfigurator configurator) + : base(store, configurator) { } + + protected override void ConfigureStateMachine( + IStateMachineConfigurator config) + { + config.ForState(OrderSagaStep.Pending) + .Permit(OrderSagaTrigger.PaymentReceived, OrderSagaStep.PaymentProcessed); + config.ForState(OrderSagaStep.PaymentProcessed) + .Permit(OrderSagaTrigger.ShipmentConfirmed, OrderSagaStep.Shipped) + .Permit(OrderSagaTrigger.Failure, OrderSagaStep.Compensating); + config.ForState(OrderSagaStep.Shipped) + .Permit(OrderSagaTrigger.DeliveryConfirmed, OrderSagaStep.Completed); + } + + protected override OrderSagaTrigger MapEventToTrigger(TEvent @event) => @event switch + { + PaymentProcessedEvent => OrderSagaTrigger.PaymentReceived, + ShipmentConfirmedEvent => OrderSagaTrigger.ShipmentConfirmed, + _ => throw new InvalidOperationException($"Unmapped event: {typeof(TEvent).Name}") + }; + + public override async Task CompensateAsync(OrderSagaData state, CancellationToken ct) + { + // Reverse payment, cancel shipment, etc. + } + + // ISubscriber implementations delegate to HandleAsync + public async Task HandleAsync(PaymentProcessedEvent @event, CancellationToken ct) + { + var state = await Store.FindByCorrelationIdAsync(@event.OrderId.ToString(), ct); + if (state != null) + await HandleAsync(@event, state, ct); + } + + public async Task HandleAsync(ShipmentConfirmedEvent @event, CancellationToken ct) + { + var state = await Store.FindByCorrelationIdAsync(@event.OrderId.ToString(), ct); + if (state != null) + await HandleAsync(@event, state, ct); + } +} +``` + +### Known Trade-offs + +- **State machine abstraction overhead:** Adds indirection between saga logic and state machine library. Justified because RCommon targets multiple hosting scenarios (in-process monolith, microservices with MassTransit, etc.). +- **ISagaStore vs IAggregateRepository:** Sagas use a dedicated store rather than the aggregate repository because saga state has different lifecycle semantics (correlation ID lookup, no domain events, compensation tracking). +- **Choreography is "just events":** Intentionally minimal — the existing event infrastructure is sufficient. Patterns are documented rather than building framework code. +- **Concrete state machine adapters are separate packages:** The core library defines only interfaces. Adapters for Stateless, MassTransit, etc. are separate NuGet packages to avoid forcing a dependency. +- **`CurrentStep` as string:** Stored as `string` in `SagaState` for database serialization. The `SagaOrchestrator` handles `Enum.Parse`/`ToString` conversion. A future enhancement could provide a generic helper for type-safe access. + +--- + +## Testing Strategy + +### Part 1: Aggregate Repository Tests + +**Location:** One test class per ORM test project, plus interface constraint tests in `RCommon.Persistence.Tests`. + +1. **Interface constraint tests** (RCommon.Persistence.Tests) + - Verify `IAggregateRepository` constrains `TAggregate` to `IAggregateRoot` via reflection + - Verify `DomainEntity` cannot satisfy the constraint + +2. **EFCore implementation tests** (RCommon.EfCore.Tests) + - `GetByIdAsync` returns entity from DbSet + - `FindAsync` applies specification predicate + - `ExistsAsync` returns true/false correctly + - `AddAsync/UpdateAsync/DeleteAsync` modify DbSet and call EventTracker + - `Include/ThenInclude` chain builds correct IQueryable + +3. **Dapper implementation tests** (RCommon.Dapper.Tests) + - Same CRUD operation tests via mocked IDbConnection + - Include/ThenInclude are no-ops (return same instance) + +4. **Linq2Db implementation tests** (RCommon.Linq2Db.Tests) + - Same CRUD operation tests via mocked DataConnection + +5. **Builder registration tests** (per ORM test project) + - Verify `IAggregateRepository<,>` is registered as transient in service collection + +### Part 2: Domain Event Dispatch Tests + +1. **UnitOfWork integration tests** + - `CommitAsync` dispatches events via `IEntityEventTracker.EmitTransactionalEventsAsync()` + - `CommitAsync` does not dispatch when no tracker is injected + - Events are not dispatched if `TransactionScope.Complete()` throws + - Events dispatch AFTER `TransactionScope.Dispose()` (verified via mock ordering) + - `EmitTransactionalEventsAsync` returning `false` logs warning but does not throw + - Backward-compatible `Commit()` still works (no event dispatch) + +2. **UnitOfWorkBehavior tests** (RCommon.Mediatr.Tests) + - `UnitOfWorkRequestBehavior` calls `CommitAsync` instead of `Commit` + - Events are dispatched when using MediatR pipeline + +3. **End-to-end event flow tests** + - Aggregate raises domain event → repository saves → UoW commits → subscriber receives event + +### Part 3: Read-Model Repository Tests + +1. **Interface constraint tests** + - Verify `IReadModelRepository` constrains `T` to `IReadModel` + +2. **Implementation tests per ORM** + - `FindAsync` applies specification + - `FindAllAsync` returns collection + - `GetPagedAsync` returns correct `IPagedResult` with pagination metadata + - `GetCountAsync` returns correct count + - `AnyAsync` returns true/false correctly + +3. **PagedResult unit tests** + - Verify `TotalPages`, `HasNextPage`, `HasPreviousPage` calculations + - Verify `PageSize <= 0` throws `ArgumentOutOfRangeException` + +### Part 4: Saga Tests + +1. **SagaState tests** + - Lifecycle state transitions (Pending → Active → Completed) + - Fault tracking (IsFaulted, FaultReason) + - Concurrency version increment + +2. **SagaOrchestrator tests** + - State machine configuration is applied exactly once (lazy init) + - Event triggers correct state transition + - Invalid triggers are ignored (no exception) + - State is persisted after each transition + - Null/empty `CurrentStep` uses `InitialState` + - Compensation is callable + +3. **ISagaStore tests per ORM** + - `FindByCorrelationIdAsync` returns correct saga + - `SaveAsync` persists state changes + - Concurrent save with stale version fails (optimistic concurrency) + +4. **State machine abstraction tests** + - `IStateMachine.FireAsync` transitions state + - `CanFire` returns correct permissions + - `PermittedTriggers` reflects current state + - Guard conditions prevent invalid transitions + +--- + +## File Summary + +| File | Action | Location | +|------|--------|----------| +| **Part 1: Aggregate Repository** | | | +| `IAggregateRepository.cs` | Create | `Src/RCommon.Persistence/Crud/` | +| `EFCoreAggregateRepository.cs` | Create | `Src/RCommon.EfCore/Crud/` | +| `DapperAggregateRepository.cs` | Create | `Src/RCommon.Dapper/Crud/` | +| `Linq2DbAggregateRepository.cs` | Create | `Src/RCommon.Linq2Db/Crud/` | +| `EFCorePerisistenceBuilder.cs` | Modify | `Src/RCommon.EfCore/` | +| `DapperPersistenceBuilder.cs` | Modify | `Src/RCommon.Dapper/` | +| `Linq2DbPersistenceBuilder.cs` | Modify | `Src/RCommon.Linq2Db/` | +| **Part 2: Domain Event Dispatch** | | | +| `IUnitOfWork.cs` | Modify | `Src/RCommon.Persistence/Transactions/` | +| `UnitOfWork.cs` | Modify | `Src/RCommon.Persistence/Transactions/` | +| `UnitOfWorkBehavior.cs` | Modify | `Src/RCommon.Mediatr/Behaviors/` | +| **Part 3: Read-Model Repositories** | | | +| `IReadModel.cs` | Create | `Src/RCommon.Persistence/` | +| `IReadModelRepository.cs` | Create | `Src/RCommon.Persistence/Crud/` | +| `IPagedResult.cs` | Create | `Src/RCommon.Models/` | +| `PagedResult.cs` | Create | `Src/RCommon.Models/` | +| `EFCoreReadModelRepository.cs` | Create | `Src/RCommon.EfCore/Crud/` | +| `DapperReadModelRepository.cs` | Create | `Src/RCommon.Dapper/Crud/` | +| `Linq2DbReadModelRepository.cs` | Create | `Src/RCommon.Linq2Db/Crud/` | +| **Part 4: State Machines (RCommon.Core)** | | | +| `IStateMachine.cs` | Create | `Src/RCommon.Core/StateMachines/` | +| `IStateMachineConfigurator.cs` | Create | `Src/RCommon.Core/StateMachines/` | +| `IStateConfigurator.cs` | Create | `Src/RCommon.Core/StateMachines/` | +| **Part 4: Sagas (RCommon.Persistence)** | | | +| `SagaState.cs` | Create | `Src/RCommon.Persistence/Sagas/` | +| `ISaga.cs` | Create | `Src/RCommon.Persistence/Sagas/` | +| `SagaOrchestrator.cs` | Create | `Src/RCommon.Persistence/Sagas/` | +| `ISagaStore.cs` | Create | `Src/RCommon.Persistence/Sagas/` | +| `InMemorySagaStore.cs` | Create | `Src/RCommon.Persistence/Sagas/` | +| `EFCoreSagaStore.cs` | Create | `Src/RCommon.EfCore/Sagas/` | +| `DapperSagaStore.cs` | Create | `Src/RCommon.Dapper/Sagas/` | +| `Linq2DbSagaStore.cs` | Create | `Src/RCommon.Linq2Db/Sagas/` | +| Test files | Create | Per project | diff --git a/docs/superpowers/specs/2026-03-21-transactional-outbox-design.md b/docs/superpowers/specs/2026-03-21-transactional-outbox-design.md new file mode 100644 index 00000000..4910f140 --- /dev/null +++ b/docs/superpowers/specs/2026-03-21-transactional-outbox-design.md @@ -0,0 +1,569 @@ +# Transactional Outbox Pattern Design + +## Context + +The current event dispatch flow in RCommon has a reliability gap: domain events are dispatched **after** the database transaction commits. If the process crashes between commit and dispatch, or if a producer fails, events are lost silently. + +The outbox pattern solves this by persisting events to a database table within the same transaction as the domain writes, guaranteeing at-least-once delivery. + +## Architecture Overview + +### Interception Point + +The outbox replaces `InMemoryTransactionalEventRouter` (the `IEventRouter` implementation) with an `OutboxEventRouter` that writes events to an `IOutboxStore` within the active transaction. A background `IHostedService` polls for pending messages and dispatches them. + +### Three Integration Tiers + +1. **Generic outbox** — RCommon's own outbox with ORM-specific stores (EF Core, Dapper, Linq2Db) +2. **MassTransit native outbox** — Wraps `MassTransit.EntityFrameworkCore`'s built-in transactional outbox +3. **Wolverine native outbox** — Wraps `WolverineFx.EntityFrameworkCore`'s durable messaging + +--- + +## Core Abstractions (`RCommon.Persistence`) + +### IOutboxMessage + +```csharp +public interface IOutboxMessage +{ + Guid Id { get; } + string EventType { get; } + string EventPayload { get; } + DateTimeOffset CreatedAtUtc { get; } + DateTimeOffset? ProcessedAtUtc { get; set; } + DateTimeOffset? DeadLetteredAtUtc { get; set; } + string? ErrorMessage { get; set; } + int RetryCount { get; set; } + string? CorrelationId { get; set; } + string? TenantId { get; set; } +} +``` + +### OutboxMessage (concrete entity) + +```csharp +public class OutboxMessage : IOutboxMessage +{ + public Guid Id { get; set; } + public string EventType { get; set; } = string.Empty; + public string EventPayload { get; set; } = string.Empty; + public DateTimeOffset CreatedAtUtc { get; set; } + public DateTimeOffset? ProcessedAtUtc { get; set; } + public DateTimeOffset? DeadLetteredAtUtc { get; set; } + public string? ErrorMessage { get; set; } + public int RetryCount { get; set; } + public string? CorrelationId { get; set; } + public string? TenantId { get; set; } +} +``` + +### IOutboxSerializer + +Pluggable serialization for converting `ISerializableEvent` to/from JSON. A default `JsonOutboxSerializer` uses `System.Text.Json` and stores the assembly-qualified type name in `EventType`. + +```csharp +public interface IOutboxSerializer +{ + string Serialize(ISerializableEvent @event); + string GetEventTypeName(ISerializableEvent @event); + ISerializableEvent Deserialize(string eventType, string payload); +} +``` + +**Default implementation (`JsonOutboxSerializer`):** +- `Serialize` — `JsonSerializer.Serialize(@event, @event.GetType())` +- `GetEventTypeName` — stores `Type.AssemblyQualifiedName` (short form: `TypeName, AssemblyName`) +- `Deserialize` — resolves type via `Type.GetType(eventType)`, then `JsonSerializer.Deserialize(payload, resolvedType)` + +**Security note:** Type-name-based deserialization is restricted to types implementing `ISerializableEvent`. The `JsonOutboxSerializer` validates that the resolved type implements `ISerializableEvent` before deserializing. + +Users can replace the default serializer via DI registration to use `Newtonsoft.Json`, a type-name mapping strategy, or custom serialization. + +### IOutboxStore + +```csharp +public interface IOutboxStore +{ + Task SaveAsync(IOutboxMessage message, CancellationToken cancellationToken = default); + Task> GetPendingAsync(int batchSize, CancellationToken cancellationToken = default); + Task MarkProcessedAsync(Guid messageId, CancellationToken cancellationToken = default); + Task MarkFailedAsync(Guid messageId, string error, CancellationToken cancellationToken = default); + Task MarkDeadLetteredAsync(Guid messageId, CancellationToken cancellationToken = default); + Task DeleteProcessedAsync(TimeSpan olderThan, CancellationToken cancellationToken = default); + Task DeleteDeadLetteredAsync(TimeSpan olderThan, CancellationToken cancellationToken = default); +} +``` + +`GetPendingAsync` returns messages where `ProcessedAtUtc IS NULL AND DeadLetteredAtUtc IS NULL AND RetryCount < MaxRetries`, ordered by `CreatedAtUtc ASC`. This ensures dead-lettered messages are excluded from polling. + +### OutboxOptions + +```csharp +public class OutboxOptions +{ + public TimeSpan PollingInterval { get; set; } = TimeSpan.FromSeconds(5); + public int BatchSize { get; set; } = 100; + public int MaxRetries { get; set; } = 5; + public TimeSpan CleanupAge { get; set; } = TimeSpan.FromDays(7); + public string TableName { get; set; } = "__OutboxMessages"; +} +``` + +### OutboxEventRouter + +Implements `IEventRouter`. Replaces `InMemoryTransactionalEventRouter` when outbox is enabled. + +- `AddTransactionalEvent(ISerializableEvent)` — **buffers the event in an internal list** (this method is `void` per the `IEventRouter` contract, so no async I/O here). Serialization and persistence happen later in `RouteEventsAsync`. +- `AddTransactionalEvents(IEnumerable)` — batch version, also buffers in memory +- `PersistBufferedEventsAsync(CancellationToken)` — **new method (not on IEventRouter):** Serializes buffered events via `IOutboxSerializer`, creates `OutboxMessage` instances (with `Id` from `IGuidGenerator`, `CorrelationId` and `TenantId` from ambient context), and calls `IOutboxStore.SaveAsync()` for each. Clears the buffer after persistence. Called by `OutboxEntityEventTracker.PersistEventsAsync()`. +- `RouteEventsAsync(CancellationToken)` — Reads pending messages from `IOutboxStore.GetPendingAsync()`, deserializes via `IOutboxSerializer`, dispatches via `IEventProducer` instances. On success, calls `MarkProcessedAsync()`. Failures are logged but not thrown (background poller will retry). Called by `OutboxEntityEventTracker.EmitTransactionalEventsAsync()` (post-commit immediate dispatch). +- `RouteEventsAsync(IEnumerable, CancellationToken)` — dispatches specific events directly (not from outbox store) + +**Note on sync/async:** The `IEventRouter.AddTransactionalEvent()` method is `void` (synchronous), so `OutboxEventRouter` buffers events in memory. The actual async persistence to `IOutboxStore` happens in `RouteEventsAsync()`, which is `Task`-returning. This avoids sync-over-async issues and is consistent with the existing `InMemoryTransactionalEventRouter` which also buffers in a `ConcurrentQueue`. + +**Concurrency with background poller:** Both `RouteEventsAsync` (immediate) and `OutboxProcessingService` (poller) may attempt to process the same message. This is acceptable — at-least-once semantics means duplicate dispatch is expected. Consumers must be idempotent. The immediate dispatch calls `MarkProcessedAsync` on success; the poller skips already-processed messages via the `GetPendingAsync` filter. + +### OutboxProcessingService + +`IHostedService` that runs a background loop. Injects `IServiceScopeFactory` (not `IOutboxStore` directly) and creates a new `IServiceScope` per polling iteration to resolve scoped dependencies. + +1. Creates a new `IServiceScope` +2. Resolves `IOutboxStore`, `IOutboxSerializer`, `IEventProducer` instances from the scope +3. Polls `IOutboxStore.GetPendingAsync(batchSize)` on the configured interval +4. Deserializes each `OutboxMessage` back to its `ISerializableEvent` type via `IOutboxSerializer` +5. Dispatches via the registered `IEventProducer` instances (using `EventSubscriptionManager` for filtering) +6. On success: calls `IOutboxStore.MarkProcessedAsync()` +7. On failure: calls `IOutboxStore.MarkFailedAsync()`, increments `RetryCount` +8. Messages exceeding `MaxRetries`: calls `IOutboxStore.MarkDeadLetteredAsync()` and logs a warning +9. Periodically calls `IOutboxStore.DeleteProcessedAsync(CleanupAge)` and `IOutboxStore.DeleteDeadLetteredAsync(CleanupAge)` to prune old entries +10. Disposes the scope + +--- + +## Two-Phase UnitOfWork Flow + +The existing `UnitOfWork.CommitAsync()` dispatches events **after** commit (Phase 3 only). The outbox requires events to be persisted **before** commit (within the same transaction). The changes below will be made during outbox implementation — the current codebase does not yet have `PersistEventsAsync` on `IEntityEventTracker`. + +### Changes to IEntityEventTracker + +Add `PersistEventsAsync` and add `CancellationToken` to `EmitTransactionalEventsAsync` (consistency fix): + +```csharp +public interface IEntityEventTracker +{ + void AddEntity(IBusinessEntity entity); + ICollection TrackedEntities { get; } + Task EmitTransactionalEventsAsync(CancellationToken cancellationToken = default); // MODIFIED: added CT + Task PersistEventsAsync(CancellationToken cancellationToken = default); // NEW +} +``` + +**Breaking change:** `EmitTransactionalEventsAsync()` now accepts an optional `CancellationToken`. Existing callers without the parameter continue to compile (default value). + +### InMemoryEntityEventTracker + +```csharp +// PersistEventsAsync is a no-op for in-memory +public Task PersistEventsAsync(CancellationToken cancellationToken = default) + => Task.CompletedTask; + +// EmitTransactionalEventsAsync updated to accept CT (passed through to IEventRouter) +public async Task EmitTransactionalEventsAsync(CancellationToken cancellationToken = default) +{ + // existing logic, passes cancellationToken to _eventRouter.RouteEventsAsync(ct) +} +``` + +### OutboxEntityEventTracker (new, true decorator) + +Holds an inner `InMemoryEntityEventTracker` reference via constructor injection (the DI container registers `InMemoryEntityEventTracker` as a concrete type, and `OutboxEntityEventTracker` as `IEntityEventTracker`). The decorator reuses the inner tracker's entity graph walking logic — no duplication. The data flow is: + +``` +PersistEventsAsync() (phase 1, within transaction): + → delegates to inner InMemoryEntityEventTracker to walk entity graph + → collects LocalEvents from each entity + → calls IEventRouter.AddTransactionalEvent() for each event (buffers in OutboxEventRouter) + → calls OutboxEventRouter.PersistBufferedEventsAsync() to flush buffer → IOutboxStore.SaveAsync() + +EmitTransactionalEventsAsync() (phase 3, after commit): + → calls IEventRouter.RouteEventsAsync() + → OutboxEventRouter reads pending from IOutboxStore, dispatches via IEventProducer +``` + +The `OutboxEntityEventTracker` delegates to the `IEventRouter` — it does NOT directly call `IOutboxStore`. The `OutboxEventRouter` is the single component that writes to the store. + +### Revised UnitOfWork.CommitAsync() + +```csharp +public async Task CommitAsync(CancellationToken cancellationToken = default) +{ + // guards... + _state = UnitOfWorkState.CommitAttempted; + + // Phase 1: persist events to outbox (within active transaction) + if (_eventTracker != null) + { + await _eventTracker.PersistEventsAsync(cancellationToken).ConfigureAwait(false); + } + + // Phase 2: commit transaction (domain writes + outbox writes atomically) + _transactionScope.Complete(); + _transactionScope.Dispose(); + _transactionScopeDisposed = true; + _state = UnitOfWorkState.Completed; + + // Phase 3: immediate dispatch attempt (best-effort, failures handled by poller) + if (_eventTracker != null) + { + await _eventTracker + .EmitTransactionalEventsAsync(cancellationToken) + .ConfigureAwait(false); + } +} +``` + +When outbox is NOT configured, `PersistEventsAsync()` is a no-op and `EmitTransactionalEventsAsync()` dispatches from memory as before. + +--- + +## ORM-Specific Outbox Stores + +All implementations share the same table schema (table name configurable via `OutboxOptions.TableName`, default `__OutboxMessages`): + +``` +__OutboxMessages +├── Id (uniqueidentifier, PK) +├── EventType (nvarchar(1024)) +├── EventPayload (nvarchar(max)) +├── CreatedAtUtc (datetimeoffset) +├── ProcessedAtUtc (datetimeoffset, nullable) +├── DeadLetteredAtUtc (datetimeoffset, nullable) +├── ErrorMessage (nvarchar(max), nullable) +├── RetryCount (int, default 0) +├── CorrelationId (nvarchar(256), nullable) +└── TenantId (nvarchar(256), nullable) +``` + +`OutboxMessage.Id` is generated via `IGuidGenerator` (which produces v7 UUIDs when configured, providing time-ordered keys for index performance). + +### EF Core (`Src/RCommon.EfCore/Outbox/`) + +- `EFCoreOutboxStore` — uses `RCommonDbContext`. `SaveAsync()` adds the `OutboxMessage` entity to the change tracker and calls `DbContext.SaveChangesAsync()`. Because the `DbContext` connection is enlisted in the ambient `TransactionScope` from `UnitOfWork`, both domain entity changes (saved earlier by repository `SaveAsync` calls) and outbox messages commit atomically when the `TransactionScope` completes. +- `OutboxMessageConfiguration` — `IEntityTypeConfiguration` mapping to `__OutboxMessages` (reads table name from `OutboxOptions`) +- Convenience extension: `modelBuilder.AddOutboxMessages()` to apply configuration + +**Important:** For EF Core to enlist in `TransactionScope`, the database connection must support distributed transactions or use `Enlist=true` in the connection string (SQL Server). For PostgreSQL, `Npgsql` enlists automatically. This is an existing requirement of `UnitOfWork`'s `TransactionScope` usage, not new to the outbox. + +### Dapper (`Src/RCommon.Dapper/Outbox/`) + +- `DapperOutboxStore` — raw SQL via `IDbConnection`. Enlists in the ambient `TransactionScope` from `UnitOfWork`. SQL statements use the table name from `OutboxOptions.TableName`. + +### Linq2Db (`Src/RCommon.Linq2Db/Outbox/`) + +- `Linq2DbOutboxStore` — uses `DataConnection.InsertAsync()`. Enlists in the ambient `TransactionScope` from `UnitOfWork`. + +### Migration Strategy + +- **EF Core users:** Add `modelBuilder.AddOutboxMessages()` to their `DbContext.OnModelCreating()` and run `dotnet ef migrations add AddOutboxMessages`. Standard EF Core migration workflow. +- **Dapper / Linq2Db users:** A SQL script is provided in the package documentation for each supported database (SQL Server, PostgreSQL). Users execute the script manually or integrate it into their migration tooling. + +--- + +## MassTransit Native Outbox (`Src/RCommon.MassTransit.Outbox/` — NEW PROJECT) + +Separate project to keep `RCommon.MassTransit` lean (no EF Core dependency). + +### Project References & Dependencies + +- Project: `RCommon.MassTransit`, `RCommon.Persistence` +- NuGet: `MassTransit.EntityFrameworkCore` + +### Fluent API + +```csharp +public interface IMassTransitOutboxBuilder +{ + IMassTransitOutboxBuilder UsePostgres(); + IMassTransitOutboxBuilder UseSqlServer(); + IMassTransitOutboxBuilder UseBusOutbox(Action? configure = null); +} + +// Extension on IMassTransitEventHandlingBuilder +public static IMassTransitEventHandlingBuilder AddOutbox( + this IMassTransitEventHandlingBuilder builder, + Action? configure = null) + where TDbContext : DbContext +``` + +Delegates to MassTransit's `AddEntityFrameworkOutbox()` and optionally `UseBusOutbox()`. Does NOT register `OutboxEventRouter` or `OutboxProcessingService` — MassTransit handles everything natively. + +--- + +## Wolverine Native Outbox (`Src/RCommon.Wolverine.Outbox/` — NEW PROJECT) + +Separate project to keep `RCommon.Wolverine` lean (no EF Core dependency). + +### Project References & Dependencies + +- Project: `RCommon.Wolverine`, `RCommon.Persistence` +- NuGet: `WolverineFx.EntityFrameworkCore` + +### Fluent API + +```csharp +public interface IWolverineOutboxBuilder +{ + IWolverineOutboxBuilder UseEntityFrameworkCoreTransactions(); +} + +// Extension on IWolverineEventHandlingBuilder +public static IWolverineEventHandlingBuilder AddOutbox( + this IWolverineEventHandlingBuilder builder, + Action? configure = null) +``` + +Delegates to Wolverine's `UseEntityFrameworkCoreTransactions()` and configures durable messaging. Does NOT register `OutboxEventRouter` or `OutboxProcessingService` — Wolverine handles everything natively. + +--- + +## Builder / Fluent API & DI Registration + +### Generic Outbox (on persistence builders) + +```csharp +// Extension method on IPersistenceBuilder +public static IPersistenceBuilder AddOutbox( + this IPersistenceBuilder builder, + Action? configure = null) + where TOutboxStore : class, IOutboxStore +``` + +**Registers:** +1. `IOutboxStore` → ORM-specific implementation (scoped) +2. `IOutboxSerializer` → `JsonOutboxSerializer` (singleton, replaceable) +3. `IEventRouter` → `OutboxEventRouter` (scoped, replaces `InMemoryTransactionalEventRouter`) +4. `IEntityEventTracker` → `OutboxEntityEventTracker` (scoped, replaces `InMemoryEntityEventTracker`) +5. `OutboxProcessingService` as `IHostedService` (singleton, uses `IServiceScopeFactory` internally) +6. `OutboxOptions` via `IOptions` + +### Usage Examples + +**EF Core:** +```csharp +builder.WithPersistence(ef => +{ + ef.AddDbContext("default", options => ...); + ef.AddOutbox(outbox => + { + outbox.PollingInterval = TimeSpan.FromSeconds(5); + outbox.MaxRetries = 5; + outbox.BatchSize = 100; + }); +}); +``` + +**Dapper:** +```csharp +builder.WithPersistence(dapper => +{ + dapper.AddDbConnection("default", options => ...); + dapper.AddOutbox(outbox => { ... }); +}); +``` + +**MassTransit native outbox:** +```csharp +builder.WithEventHandling(mt => +{ + mt.AddOutbox(outbox => + { + outbox.UseSqlServer(); + outbox.UseBusOutbox(); + }); +}); +``` + +**Wolverine native outbox:** +```csharp +builder.WithEventHandling(w => +{ + w.AddOutbox(outbox => + { + outbox.UseEntityFrameworkCoreTransactions(); + }); +}); +``` + +--- + +## Projects & Dependencies Summary + +### Existing Projects (modified) + +| Project | Changes | New Dependencies | +|---------|---------|-----------------| +| `RCommon.Persistence` | Add `IOutboxMessage`, `IOutboxStore`, `IOutboxSerializer`, `OutboxMessage`, `OutboxOptions`, `JsonOutboxSerializer`, `OutboxEventRouter`, `OutboxProcessingService`, `OutboxEntityEventTracker`, builder extensions | `Microsoft.Extensions.Hosting.Abstractions` (explicit PackageReference, not relying on transitive from RCommon.Core) | +| `RCommon.EfCore` | Add `Outbox/EFCoreOutboxStore`, `Outbox/OutboxMessageConfiguration`, `ModelBuilderExtensions` | None | +| `RCommon.Dapper` | Add `Outbox/DapperOutboxStore` | None | +| `RCommon.Linq2Db` | Add `Outbox/Linq2DbOutboxStore` | None | +| `RCommon.Entities` | Modify `IEntityEventTracker` (add `PersistEventsAsync`, add CT to `EmitTransactionalEventsAsync`), `InMemoryEntityEventTracker` (no-op impl + CT) | None | +| `RCommon.Persistence` | Modify `UnitOfWork.CommitAsync()` (two-phase) | None | + +### New Projects + +| Project | References | NuGet Dependencies | +|---------|-----------|-------------------| +| `Src/RCommon.MassTransit.Outbox` | `RCommon.MassTransit`, `RCommon.Persistence` | `MassTransit.EntityFrameworkCore` | +| `Src/RCommon.Wolverine.Outbox` | `RCommon.Wolverine`, `RCommon.Persistence` | `WolverineFx.EntityFrameworkCore` | + +### New Test Projects + +| Project | References | +|---------|-----------| +| `Tests/RCommon.MassTransit.Outbox.Tests` | `RCommon.MassTransit.Outbox` | +| `Tests/RCommon.Wolverine.Outbox.Tests` | `RCommon.Wolverine.Outbox` | + +--- + +## Changes to Existing Code + +### IEntityEventTracker (breaking interface change) + +```csharp +public interface IEntityEventTracker +{ + void AddEntity(IBusinessEntity entity); + ICollection TrackedEntities { get; } + Task EmitTransactionalEventsAsync(CancellationToken cancellationToken = default); // MODIFIED: added CT + Task PersistEventsAsync(CancellationToken cancellationToken = default); // NEW +} +``` + +### InMemoryEntityEventTracker + +```csharp +public Task PersistEventsAsync(CancellationToken cancellationToken = default) + => Task.CompletedTask; // no-op for in-memory +``` + +### UnitOfWork.CommitAsync() (revised order) + +```csharp +public async Task CommitAsync(CancellationToken cancellationToken = default) +{ + // guards... + _state = UnitOfWorkState.CommitAttempted; + + // Phase 1: persist events to outbox (within active transaction) + if (_eventTracker != null) + { + await _eventTracker.PersistEventsAsync(cancellationToken).ConfigureAwait(false); + } + + // Phase 2: commit transaction (domain writes + outbox writes atomically) + _transactionScope.Complete(); + _transactionScope.Dispose(); + _transactionScopeDisposed = true; + _state = UnitOfWorkState.Completed; + + // Phase 3: immediate dispatch attempt (best-effort, failures handled by poller) + if (_eventTracker != null) + { + await _eventTracker + .EmitTransactionalEventsAsync(cancellationToken) + .ConfigureAwait(false); + } +} +``` + +--- + +## Testing Strategy + +### Unit Tests (additions to existing test projects) + +**`Tests/RCommon.Persistence.Tests/`:** +- `OutboxEventRouterTests` — serialization via IOutboxSerializer, store calls, immediate dispatch, failure handling +- `OutboxProcessingServiceTests` — polling, scope creation per iteration, batch dispatch, retry logic, max retries → dead letter, cleanup +- `OutboxMessageTests` — serialization/deserialization round-trip via JsonOutboxSerializer +- `OutboxEntityEventTrackerTests` — two-phase flow, entity graph walking, delegates to IEventRouter (not IOutboxStore directly) +- `UnitOfWorkOutboxIntegrationTests` — full two-phase commit flow with mocked store/producers + +**`Tests/RCommon.EfCore.Tests/`:** +- `EFCoreOutboxStoreTests` — CRUD operations against in-memory SQLite DbContext + +**`Tests/RCommon.Dapper.Tests/`:** +- `DapperOutboxStoreTests` — CRUD operations with raw SQL + +**`Tests/RCommon.Linq2Db.Tests/`:** +- `Linq2DbOutboxStoreTests` — CRUD operations via DataConnection + +**`Tests/RCommon.MassTransit.Outbox.Tests/` (new):** +- `MassTransitOutboxBuilderTests` — verifies native outbox service registration + +**`Tests/RCommon.Wolverine.Outbox.Tests/` (new):** +- `WolverineOutboxBuilderTests` — verifies native outbox service registration + +### Concurrency & Edge Case Tests + +- **Concurrent dispatch test** — verifies that immediate dispatch + poller both processing the same message results in at-least-once delivery (not corruption) +- **Transaction rollback test** — verifies outbox messages are NOT persisted when the TransactionScope rolls back +- **Dead letter test** — verifies messages exceeding MaxRetries are marked dead-lettered and excluded from future GetPendingAsync calls + +### Test Frameworks + +- xUnit 2.9.3, FluentAssertions 8.2.0, Moq 4.20.72 (from `Directory.Build.props`) + +--- + +## Non-Breaking Guarantee + +When outbox is NOT configured: +- `IEntityEventTracker` → `InMemoryEntityEventTracker` (unchanged, `PersistEventsAsync` is no-op) +- `IEventRouter` → `InMemoryTransactionalEventRouter` (unchanged) +- `UnitOfWork.CommitAsync()` — `PersistEventsAsync` is no-op, `EmitTransactionalEventsAsync` dispatches from memory as before +- No `OutboxProcessingService` registered +- **Behavior is identical to today** + +--- + +## Concurrency Model + +The outbox guarantees **at-least-once delivery**. Duplicate dispatch is expected and consumers must be idempotent. + +- **Immediate dispatch** (in `OutboxEventRouter.RouteEventsAsync`): runs synchronously after commit, calls `MarkProcessedAsync` on success +- **Background poller** (`OutboxProcessingService`): runs on a timer, picks up messages where `ProcessedAtUtc IS NULL AND DeadLetteredAtUtc IS NULL` +- **Race window:** If immediate dispatch succeeds but `MarkProcessedAsync` fails (crash), the poller will re-dispatch. This is the at-least-once guarantee. +- **No distributed locking:** Single-process deployment assumed for V1. Horizontal scaling with multiple poller instances would require a `LockedUntilUtc` claim mechanism (documented as future enhancement). + +--- + +## Key Design Decisions + +| Decision | Rationale | +|----------|-----------| +| Replace `IEventRouter` (not producers or UoW) | Minimal code changes; router contract already fits the outbox write/dispatch pattern | +| Two-phase commit in UoW | Events must be persisted within the transaction for atomicity; dispatch happens after commit | +| Separate MassTransit.Outbox / Wolverine.Outbox projects | Keeps base messaging packages lean; EF Core outbox dependency is opt-in | +| Generic outbox for all three ORMs | Dapper/Linq2Db users need outbox without a messaging framework | +| Background poller + immediate attempt | Best latency (immediate) with guaranteed delivery (poller catches failures) | +| `PersistEventsAsync` no-op for in-memory | Zero behavior change for non-outbox users | +| Shared `__OutboxMessages` table schema | All ORMs read/write the same table; enables mixed ORM scenarios | +| `OutboxEntityEventTracker` as decorator | Adds outbox persistence without rewriting entity graph walking logic | +| `IOutboxSerializer` abstraction | Pluggable serialization; default `System.Text.Json` with type-safe deserialization | +| `IServiceScopeFactory` in hosted service | Singleton `OutboxProcessingService` cannot resolve scoped `IOutboxStore` directly | +| `DeadLetteredAtUtc` column | Dead-lettered messages are excluded from polling and can be cleaned up separately | +| `CorrelationId` / `TenantId` on outbox message | Multi-tenant and observability support; poller restores context when dispatching | +| Configurable table name | Avoids conflicts with user conventions or multi-schema deployments | +| `IGuidGenerator` for message IDs | Produces v7 UUIDs when configured, providing time-ordered keys for index performance | +| At-least-once / no distributed lock (V1) | Simple single-process model; distributed locking is a future enhancement | + +--- + +## Future Enhancements (V2) + +- **Exponential backoff:** Add `NextRetryAtUtc` column and configurable backoff strategy for failed messages +- **Distributed locking:** Add `LockedUntilUtc` / `ClaimAsync` for horizontal scaling with multiple poller instances +- **Dead letter replay:** Add `IOutboxStore.GetDeadLettersAsync()` and replay API for operational recovery +- **Inbox (idempotency):** Add `__InboxMessages` table to deduplicate incoming events at the consumer level diff --git a/docs/superpowers/specs/2026-03-23-outbox-v2-design.md b/docs/superpowers/specs/2026-03-23-outbox-v2-design.md new file mode 100644 index 00000000..50ce614c --- /dev/null +++ b/docs/superpowers/specs/2026-03-23-outbox-v2-design.md @@ -0,0 +1,640 @@ +# Outbox V2 Design Spec + +> **Scope:** Exponential backoff, distributed locking, dead letter replay, inbox/idempotency for the RCommon transactional outbox. + +**Date:** 2026-03-23 +**Branch:** feature/ddd +**Backward Compatibility:** Breaking — `IOutboxStore`, `IOutboxMessage`, and `OutboxMessage` are modified directly. + +--- + +## 1. Overview + +V1 of the transactional outbox provides reliable event persistence and dispatch via `OutboxProcessingService`. V2 adds four capabilities: + +1. **Exponential backoff** — failed messages wait progressively longer before retry +2. **Distributed locking** — multiple processor instances can run safely without double-dispatch +3. **Dead letter replay** — dead-lettered messages can be inspected and replayed +4. **Inbox/idempotency** — consumer-side deduplication via a separate inbox table + +All four features build on the existing architecture. The breaking changes are confined to `IOutboxStore`, `IOutboxMessage`, and `OutboxMessage`. + +--- + +## 2. Interface Changes + +### 2.1 IOutboxMessage — 3 new properties + +```csharp +public interface IOutboxMessage +{ + // Existing (unchanged) + Guid Id { get; } + string EventType { get; } + string EventPayload { get; } + DateTimeOffset CreatedAtUtc { get; } + DateTimeOffset? ProcessedAtUtc { get; set; } + DateTimeOffset? DeadLetteredAtUtc { get; set; } + string? ErrorMessage { get; set; } + int RetryCount { get; set; } + string? CorrelationId { get; set; } + string? TenantId { get; set; } + + // V2 additions + DateTimeOffset? NextRetryAtUtc { get; set; } + string? LockedByInstanceId { get; set; } + DateTimeOffset? LockedUntilUtc { get; set; } +} +``` + +- `NextRetryAtUtc` — when this message becomes eligible for retry (null = immediately eligible) +- `LockedByInstanceId` — which processor instance claimed this message +- `LockedUntilUtc` — lock expiry; stale locks auto-release when this time passes + +### 2.2 OutboxMessage — matching properties + +`OutboxMessage` gains the same three properties with public getters/setters to match `IOutboxMessage`. + +### 2.3 IOutboxStore — revised interface + +```csharp +public interface IOutboxStore +{ + // Unchanged + Task SaveAsync(IOutboxMessage message, CancellationToken cancellationToken = default); + Task MarkProcessedAsync(Guid messageId, CancellationToken cancellationToken = default); + Task MarkDeadLetteredAsync(Guid messageId, CancellationToken cancellationToken = default); + Task DeleteProcessedAsync(TimeSpan olderThan, CancellationToken cancellationToken = default); + Task DeleteDeadLetteredAsync(TimeSpan olderThan, CancellationToken cancellationToken = default); + + // Changed signature — now takes nextRetryAtUtc + Task MarkFailedAsync(Guid messageId, string error, DateTimeOffset nextRetryAtUtc, CancellationToken cancellationToken = default); + + // New — atomic claim replaces GetPendingAsync + Task> ClaimAsync(string instanceId, int batchSize, TimeSpan lockDuration, CancellationToken cancellationToken = default); + + // New — dead letter management + Task> GetDeadLettersAsync(int batchSize, int offset = 0, CancellationToken cancellationToken = default); + Task ReplayDeadLetterAsync(Guid messageId, CancellationToken cancellationToken = default); +} +``` + +**Removed:** `GetPendingAsync` — replaced entirely by `ClaimAsync`. + +### 2.4 IBackoffStrategy — new abstraction + +```csharp +public interface IBackoffStrategy +{ + TimeSpan ComputeDelay(int retryCount); +} +``` + +Default implementation: + +```csharp +public class ExponentialBackoffStrategy : IBackoffStrategy +{ + private readonly TimeSpan _baseDelay; + private readonly TimeSpan _maxDelay; + private readonly double _multiplier; + + public ExponentialBackoffStrategy(TimeSpan baseDelay, TimeSpan maxDelay, double multiplier = 2.0) + { + _baseDelay = baseDelay; + _maxDelay = maxDelay; + _multiplier = multiplier; + } + + public TimeSpan ComputeDelay(int retryCount) + => TimeSpan.FromSeconds( + Math.Min( + _baseDelay.TotalSeconds * Math.Pow(_multiplier, retryCount), + _maxDelay.TotalSeconds)); +} +``` + +### 2.5 OutboxOptions — new properties + +```csharp +public class OutboxOptions +{ + // Existing (unchanged) + public TimeSpan PollingInterval { get; set; } = TimeSpan.FromSeconds(5); + public int BatchSize { get; set; } = 100; + public int MaxRetries { get; set; } = 5; + public TimeSpan CleanupAge { get; set; } = TimeSpan.FromDays(7); + public TimeSpan CleanupInterval { get; set; } = TimeSpan.FromHours(1); + public string TableName { get; set; } = "__OutboxMessages"; + + // V2 additions + public TimeSpan LockDuration { get; set; } = TimeSpan.FromMinutes(5); + public TimeSpan BackoffBaseDelay { get; set; } = TimeSpan.FromSeconds(5); + public TimeSpan BackoffMaxDelay { get; set; } = TimeSpan.FromMinutes(30); + public double BackoffMultiplier { get; set; } = 2.0; + public string InboxTableName { get; set; } = "__InboxMessages"; +} +``` + +--- + +## 3. Distributed Locking + +### 3.1 Provider Detection + +Each ORM handles provider detection differently: + +- **EF Core:** Auto-detects from `DbContext.Database.ProviderName` (`Microsoft.EntityFrameworkCore.SqlServer` or `Npgsql.EntityFrameworkCore.PostgreSQL`). No injected provider needed. +- **Dapper / Linq2Db:** Uses injected `ILockStatementProvider` to select SQL dialect. + +```csharp +public interface ILockStatementProvider +{ + string ProviderName { get; } // "SqlServer", "PostgreSql" +} + +public class SqlServerLockStatementProvider : ILockStatementProvider +{ + public string ProviderName => "SqlServer"; +} + +public class PostgreSqlLockStatementProvider : ILockStatementProvider +{ + public string ProviderName => "PostgreSql"; +} +``` + +Unsupported providers throw `NotSupportedException` with a clear message. + +### 3.2 ClaimAsync SQL + +**SQL Server:** + +```sql +WITH batch AS ( + SELECT TOP(@batchSize) Id + FROM __OutboxMessages WITH (UPDLOCK, ROWLOCK, READPAST) + WHERE ProcessedAtUtc IS NULL + AND DeadLetteredAtUtc IS NULL + AND RetryCount < @maxRetries + AND (NextRetryAtUtc IS NULL OR NextRetryAtUtc <= @now) + AND (LockedUntilUtc IS NULL OR LockedUntilUtc <= @now) + ORDER BY CreatedAtUtc +) +UPDATE o +SET o.LockedByInstanceId = @instanceId, o.LockedUntilUtc = @lockUntil +OUTPUT INSERTED.* +FROM __OutboxMessages o +INNER JOIN batch ON o.Id = batch.Id; +``` + +`UPDLOCK, ROWLOCK, READPAST` ensures concurrent instances skip rows already being claimed by another instance, preventing deadlocks and double-dispatch. + +**PostgreSQL:** + +```sql +UPDATE "__OutboxMessages" o +SET "LockedByInstanceId" = @instanceId, "LockedUntilUtc" = @lockUntil +FROM ( + SELECT "Id" FROM "__OutboxMessages" + WHERE "ProcessedAtUtc" IS NULL + AND "DeadLetteredAtUtc" IS NULL + AND "RetryCount" < @maxRetries + AND ("NextRetryAtUtc" IS NULL OR "NextRetryAtUtc" <= @now) + AND ("LockedUntilUtc" IS NULL OR "LockedUntilUtc" <= @now) + ORDER BY "CreatedAtUtc" + LIMIT @batchSize + FOR UPDATE SKIP LOCKED +) AS batch +WHERE o."Id" = batch."Id" +RETURNING o.*; +``` + +Both queries atomically: +1. Filter to eligible messages (not processed, not dead-lettered, under max retries, past retry delay, not locked or lock expired) +2. Claim by setting `LockedByInstanceId` and `LockedUntilUtc` +3. Return claimed messages in a single round-trip + +### 3.3 Future Provider Extensibility + +MySQL (`UPDATE ... ORDER BY ... LIMIT` with separate SELECT) and Oracle (`FOR UPDATE SKIP LOCKED`) follow the same pattern — add a new `ILockStatementProvider` implementation and SQL dialect. No interface changes required. + +### 3.4 Index + +Updated composite index for ClaimAsync performance: + +``` +IX_OutboxMessages_Pending: (ProcessedAtUtc, DeadLetteredAtUtc, NextRetryAtUtc, LockedUntilUtc, CreatedAtUtc) +``` + +Replaces the V1 index on `(ProcessedAtUtc, DeadLetteredAtUtc, CreatedAtUtc)`. + +--- + +## 4. OutboxProcessingService Changes + +### 4.1 Instance Identity + +```csharp +public class OutboxProcessingService : BackgroundService +{ + private readonly string _instanceId = Guid.NewGuid().ToString("N"); + private readonly IBackoffStrategy _backoffStrategy; + // ... existing fields unchanged +} +``` + +### 4.2 ProcessBatchAsync — revised flow + +> **Note:** The pseudocode below omits `.ConfigureAwait(false)` for readability. The real implementation must use `.ConfigureAwait(false)` on all `await` calls, consistent with V1. + +```csharp +public async Task ProcessBatchAsync(CancellationToken cancellationToken) +{ + using var scope = _serviceProvider.CreateScope(); + var store = scope.ServiceProvider.GetRequiredService(); + var serializer = scope.ServiceProvider.GetRequiredService(); + var producers = scope.ServiceProvider.GetServices(); + var subscriptionManager = scope.ServiceProvider.GetRequiredService(); + var inboxStore = scope.ServiceProvider.GetService(); // Optional + + // Atomic claim replaces GetPendingAsync + var claimed = await store.ClaimAsync( + _instanceId, _options.BatchSize, _options.LockDuration, cancellationToken); + + foreach (var message in claimed) + { + try + { + // Auto-check inbox (if registered) + if (inboxStore != null) + { + if (await inboxStore.ExistsAsync(message.Id, "OutboxProcessingService", cancellationToken)) + { + await store.MarkProcessedAsync(message.Id, cancellationToken); + continue; + } + } + + var @event = serializer.Deserialize(message.EventType, message.EventPayload); + var filteredProducers = subscriptionManager.HasSubscriptions + ? subscriptionManager.GetProducersForEvent(producers, @event.GetType()) + : producers; + + foreach (var producer in filteredProducers) + { + await producer.ProduceEventAsync((dynamic)@event, cancellationToken); + } + + // Record in inbox before marking processed (if registered) + if (inboxStore != null) + { + await inboxStore.RecordAsync(new InboxMessage + { + MessageId = message.Id, + ConsumerType = "OutboxProcessingService", + EventType = message.EventType, + ReceivedAtUtc = DateTimeOffset.UtcNow + }, cancellationToken); + } + + await store.MarkProcessedAsync(message.Id, cancellationToken); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + _logger.LogWarning(ex, "Failed to dispatch outbox message {Id} (retry {Retry})", + message.Id, message.RetryCount); + + if (message.RetryCount + 1 >= _options.MaxRetries) + { + await store.MarkDeadLetteredAsync(message.Id, cancellationToken); + } + else + { + var delay = _backoffStrategy.ComputeDelay(message.RetryCount + 1); + var nextRetryAt = DateTimeOffset.UtcNow + delay; + await store.MarkFailedAsync(message.Id, ex.Message, nextRetryAt, cancellationToken); + } + } + } + + // Periodic cleanup (throttled by CleanupInterval) + if (DateTimeOffset.UtcNow - _lastCleanupUtc >= _options.CleanupInterval) + { + await store.DeleteProcessedAsync(_options.CleanupAge, cancellationToken); + await store.DeleteDeadLetteredAsync(_options.CleanupAge, cancellationToken); + if (inboxStore != null) + { + await inboxStore.CleanupAsync(_options.CleanupAge, cancellationToken); + } + _lastCleanupUtc = DateTimeOffset.UtcNow; + } +} +``` + +### 4.3 DI Registration Changes + +In `AddOutbox()`: + +```csharp +// Backoff strategy (singleton, replaceable) +builder.Services.TryAddSingleton(sp => +{ + var opts = sp.GetRequiredService>().Value; + return new ExponentialBackoffStrategy(opts.BackoffBaseDelay, opts.BackoffMaxDelay, opts.BackoffMultiplier); +}); +``` + +Users can register a custom `IBackoffStrategy` before calling `AddOutbox` to override. + +### 4.4 OutboxEventRouter Changes + +`OutboxEventRouter.RouteEventsAsync()` currently calls `GetPendingAsync` and `MarkFailedAsync` with V1 signatures. V2 changes its behavior: + +**Before (V1):** `RouteEventsAsync()` reads all pending messages from the store, dispatches, marks processed/failed. + +**After (V2):** `PersistBufferedEventsAsync` retains the persisted message IDs and deserialized events in a private list. `RouteEventsAsync()` dispatches only those just-persisted events (no store read). On success → `MarkProcessedAsync`. On failure → log warning and skip (the background processor picks it up on the next `ClaimAsync` poll with backoff). + +**Implementation sketch:** + +```csharp +// New private field +private readonly List<(Guid MessageId, ISerializableEvent Event)> _persistedEvents = new(); + +// In PersistBufferedEventsAsync, after SaveAsync: +_persistedEvents.Add((message.Id, @event)); + +// RouteEventsAsync() revised: +public async Task RouteEventsAsync(CancellationToken cancellationToken = default) +{ + if (_persistedEvents.Count == 0) return; + + var producers = _serviceProvider.GetServices(); + + foreach (var (messageId, @event) in _persistedEvents) + { + try + { + var filteredProducers = _subscriptionManager.HasSubscriptions + ? _subscriptionManager.GetProducersForEvent(producers, @event.GetType()) + : producers; + + foreach (var producer in filteredProducers) + { + await producer.ProduceEventAsync(@event, cancellationToken).ConfigureAwait(false); + } + + await _outboxStore.MarkProcessedAsync(messageId, cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Best-effort dispatch failed for message {Id}; background processor will retry", messageId); + } + } +} +``` + +This means `RouteEventsAsync()`: +- **No longer calls** `GetPendingAsync` (removed from interface) +- **No longer calls** `MarkFailedAsync` (failures left for background processor) +- Only calls `MarkProcessedAsync` on success +- Becomes a best-effort immediate dispatch of the current scope's events only + +The `RouteEventsAsync(IEnumerable, CancellationToken)` overload (direct dispatch without store) is unchanged. + +--- + +## 5. Inbox / Idempotency + +### 5.1 IInboxMessage + +```csharp +public interface IInboxMessage +{ + Guid MessageId { get; } + string EventType { get; } + string? ConsumerType { get; } + DateTimeOffset ReceivedAtUtc { get; } +} + +public class InboxMessage : IInboxMessage +{ + public Guid MessageId { get; set; } + public string EventType { get; set; } = string.Empty; + public string? ConsumerType { get; set; } + public DateTimeOffset ReceivedAtUtc { get; set; } +} +``` + +### 5.2 IInboxStore + +```csharp +public interface IInboxStore +{ + Task ExistsAsync(Guid messageId, string? consumerType = null, CancellationToken cancellationToken = default); + Task RecordAsync(IInboxMessage message, CancellationToken cancellationToken = default); + Task CleanupAsync(TimeSpan olderThan, CancellationToken cancellationToken = default); +} +``` + +- `ExistsAsync` — returns true if the message was already processed by this consumer +- `RecordAsync` — records processing; throws on duplicate (unique constraint) +- `CleanupAsync` — deletes entries older than the specified age + +### 5.3 Table Schema + +``` +__InboxMessages +├── MessageId (Guid) +├── EventType (string) +├── ConsumerType (string, NOT NULL at DB level — C# property is `string?`, stored as "" when null) +├── ReceivedAtUtc (DateTimeOffset) +├── PK: (MessageId, ConsumerType) +└── IX_InboxMessages_Cleanup: (ReceivedAtUtc) +``` + +Composite PK on `(MessageId, ConsumerType)` allows the same message to be processed by multiple different consumers while preventing duplicate processing by the same consumer. + +### 5.4 Mode 1: Standalone Opt-In + +Consumers check the inbox explicitly. The `MessageId` is a domain-specific deduplication key chosen by the consumer — typically a domain event ID, correlation ID, or any stable identifier the consumer can derive from the event. `ISerializableEvent` does not define an `Id` property; the concrete event type is responsible for carrying an appropriate identifier. + +```csharp +// Assumes OrderCreatedEvent has an OrderId property suitable for deduplication +public class OrderCreatedHandler : IAppEventHandler +{ + private readonly IInboxStore _inbox; + + public async Task HandleAsync(OrderCreatedEvent @event, CancellationToken ct) + { + if (await _inbox.ExistsAsync(@event.OrderId, GetType().FullName, ct)) + return; + + // ... handle event ... + + await _inbox.RecordAsync(new InboxMessage + { + MessageId = @event.OrderId, + ConsumerType = GetType().FullName, + EventType = @event.GetType().FullName!, + ReceivedAtUtc = DateTimeOffset.UtcNow + }, ct); + } +} +``` + +> **Mode 2 (integrated auto-check)** uses `OutboxMessage.Id` as the `MessageId`, which is always available. Mode 1 requires the consumer to choose an appropriate deduplication key from the concrete event type. + +### 5.5 Mode 2: Integrated Auto-Check + +When `IInboxStore` is registered, `OutboxProcessingService` automatically wraps each dispatch with an idempotency check (see Section 4.2). No consumer code changes needed. Resolved via `GetService()` — silently skipped if not registered. + +### 5.6 DI Registration + +```csharp +public static IPersistenceBuilder AddInbox( + this IPersistenceBuilder builder) + where TInboxStore : class, IInboxStore +{ + builder.Services.AddScoped(); + return builder; +} +``` + +Separate from `AddOutbox` — can be used independently or together. Inbox cleanup piggybacks on `OutboxProcessingService`'s existing cleanup cycle when `IInboxStore` is registered. + +--- + +## 6. Dead Letter Replay + +### 6.1 GetDeadLettersAsync + +Query: `WHERE DeadLetteredAtUtc IS NOT NULL`, ordered by `DeadLetteredAtUtc DESC` (most recent first), with `batchSize` and `offset` for paging. Returns full message details including `ErrorMessage` for diagnostics. + +### 6.2 ReplayDeadLetterAsync + +Resets a dead-lettered message to pending state: + +``` +DeadLetteredAtUtc = null +ProcessedAtUtc = null +ErrorMessage = null +RetryCount = 0 +NextRetryAtUtc = null +LockedByInstanceId = null +LockedUntilUtc = null +``` + +After replay, the message re-enters the normal `ClaimAsync` pipeline with a full retry budget. + +Throws `InvalidOperationException` if the message doesn't exist or isn't currently dead-lettered. + +### 6.3 No Bulk Replay + +Single-message replay only. Bulk replay is a future enhancement. Callers loop if they need bulk behavior. + +### 6.4 Index + +``` +IX_OutboxMessages_DeadLettered: (DeadLetteredAtUtc DESC) WHERE DeadLetteredAtUtc IS NOT NULL +``` + +Filtered index for efficient dead letter queries. + +--- + +## 7. Store Implementations + +Each ORM project implements both `IOutboxStore` (updated) and `IInboxStore` (new): + +| Project | Outbox Store | Inbox Store | +|---------|-------------|-------------| +| RCommon.EfCore | `EFCoreOutboxStore` (updated) | `EFCoreInboxStore` (new) | +| RCommon.Dapper | `DapperOutboxStore` (updated) | `DapperInboxStore` (new) | +| RCommon.Linq2Db | `Linq2DbOutboxStore` (updated) | `Linq2DbInboxStore` (new) | + +### 7.1 EF Core + +- `ClaimAsync`: Raw SQL via `Database.SqlQueryRaw()`, dialect selected by `Database.ProviderName` +- `EFCoreInboxStore`: Standard EF Core CRUD on `DbSet` +- `InboxMessageConfiguration`: Entity configuration with composite PK and cleanup index +- `ModelBuilderExtensions`: Updated to include inbox entity configuration + +### 7.2 Dapper + +- `ClaimAsync`: Raw SQL selected by `ILockStatementProvider.ProviderName` +- `DapperInboxStore`: Standard Dapper queries (`INSERT`, `SELECT EXISTS`, `DELETE`) + +### 7.3 Linq2Db + +- `ClaimAsync`: Raw SQL selected by `ILockStatementProvider.ProviderName` +- `Linq2DbInboxStore`: Linq2Db LINQ API for CRUD, raw SQL for claim + +--- + +## 8. Files Changed + +### New files (RCommon.Persistence) +- `Outbox/IBackoffStrategy.cs` +- `Outbox/ExponentialBackoffStrategy.cs` +- `Outbox/ILockStatementProvider.cs` +- `Outbox/SqlServerLockStatementProvider.cs` +- `Outbox/PostgreSqlLockStatementProvider.cs` +- `Inbox/IInboxMessage.cs` +- `Inbox/InboxMessage.cs` +- `Inbox/IInboxStore.cs` +- `Inbox/InboxPersistenceBuilderExtensions.cs` + +### Modified files (RCommon.Persistence) +- `Outbox/IOutboxMessage.cs` — 3 new properties +- `Outbox/OutboxMessage.cs` — 3 new properties +- `Outbox/IOutboxStore.cs` — remove `GetPendingAsync`, change `MarkFailedAsync`, add `ClaimAsync`, `GetDeadLettersAsync`, `ReplayDeadLetterAsync` +- `Outbox/OutboxOptions.cs` — 5 new properties +- `Outbox/OutboxProcessingService.cs` — instance ID, claim-based polling, backoff, inbox auto-check +- `Outbox/OutboxPersistenceBuilderExtensions.cs` — register `IBackoffStrategy` +- `Outbox/OutboxEventRouter.cs` — remove `GetPendingAsync`/`MarkFailedAsync` calls, retain persisted events for immediate dispatch + +### Modified files (ORM projects) +- `RCommon.EfCore/Outbox/EFCoreOutboxStore.cs` — implement `ClaimAsync`, `GetDeadLettersAsync`, `ReplayDeadLetterAsync`, update `MarkFailedAsync` +- `RCommon.EfCore/Outbox/OutboxMessageConfiguration.cs` — new columns, updated index +- `RCommon.EfCore/Outbox/ModelBuilderExtensions.cs` — add inbox configuration +- `RCommon.Dapper/Outbox/DapperOutboxStore.cs` — same updates +- `RCommon.Dapper/DapperPersistenceBuilder.cs` — `ILockStatementProvider` registration support +- `RCommon.Linq2Db/Outbox/Linq2DbOutboxStore.cs` — same updates +- `RCommon.Linq2Db/Linq2DbPersistenceBuilder.cs` — `ILockStatementProvider` registration support + +### New files (ORM projects) +- `RCommon.EfCore/Inbox/EFCoreInboxStore.cs` +- `RCommon.EfCore/Inbox/InboxMessageConfiguration.cs` +- `RCommon.Dapper/Inbox/DapperInboxStore.cs` +- `RCommon.Linq2Db/Inbox/Linq2DbInboxStore.cs` + +### Test files (new and modified) +- Updated: `Tests/RCommon.EfCore.Tests/EFCoreOutboxStoreTests.cs` +- Updated: `Tests/RCommon.Dapper.Tests/DapperOutboxStoreTests.cs` +- Updated: `Tests/RCommon.Linq2Db.Tests/Linq2DbOutboxStoreTests.cs` +- New: `Tests/RCommon.EfCore.Tests/EFCoreInboxStoreTests.cs` +- New: `Tests/RCommon.Dapper.Tests/DapperInboxStoreTests.cs` +- New: `Tests/RCommon.Linq2Db.Tests/Linq2DbInboxStoreTests.cs` +- New: `Tests/RCommon.Persistence.Tests/ExponentialBackoffStrategyTests.cs` +- Updated: `Tests/RCommon.Persistence.Tests/OutboxProcessingServiceTests.cs` +- Updated: `Tests/RCommon.Persistence.Tests/OutboxEventRouterTests.cs` — remove `GetPendingAsync`/`MarkFailedAsync` mocks, test retained-event dispatch +- Updated: `Tests/RCommon.Persistence.Tests/OutboxEntityEventTrackerTests.cs` — update mocks for changed `RouteEventsAsync` behavior +- Updated: `Tests/RCommon.Persistence.Tests/OutboxConcurrencyTests.cs` — update mocks for removed `GetPendingAsync` + +--- + +## 9. Design Decisions + +| Decision | Rationale | +|----------|-----------| +| Break interfaces directly | User decision — no backward compat shims | +| Remove `GetPendingAsync` entirely | `ClaimAsync` is a strict superset; keeping both creates confusion about which to use | +| EF Core auto-detects provider | `Database.ProviderName` is always available; no extra DI registration needed | +| Dapper/Linq2Db use `ILockStatementProvider` | No DbContext to introspect; explicit provider selection is clearer | +| `IBackoffStrategy` as singleton | Stateless computation — one instance serves all scoped stores | +| Inbox `RecordAsync` throws on duplicate | Relies on DB unique constraint; simpler than `TryRecord` + boolean return | +| Inbox composite PK `(MessageId, ConsumerType)` | Same message, different consumers = OK. Same message, same consumer = blocked | +| Inbox cleanup in outbox service | Piggybacks on existing cleanup cycle; avoids a second background service | +| Single-message dead letter replay | Prevents accidental bulk replay; callers can loop if needed | +| `GetService` for `IInboxStore` in processing service | Fully opt-in — inbox silently disabled if not registered |