hopefully not a horribly foolish refactoring

This commit is contained in:
john 2025-05-11 23:26:09 +02:00
parent 59d660165f
commit 1ecaf64dea
82 changed files with 782 additions and 398 deletions

View file

@ -1,3 +1,7 @@
namespace Femto.Common.Domain;
public class DomainException(string message) : Exception(message);
public class DomainException : Exception
{
public DomainException(string message, Exception innerException) : base(message, innerException) {}
public DomainException(string message) : base(message) {}
}

View file

@ -0,0 +1,7 @@
using MediatR;
namespace Femto.Common.Domain;
public interface ICommand : IRequest;
public interface ICommand<out T> : IRequest<T>;

View file

@ -0,0 +1,5 @@
using MediatR;
namespace Femto.Common.Domain;
public interface IQuery<out T> : IRequest<T>;

View file

@ -8,9 +8,10 @@
<ItemGroup>
<PackageReference Include="MediatR" Version="12.5.0" />
</ItemGroup>
<ItemGroup>
<Folder Include="Infrastructure\" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.4" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.4" />
<PackageReference Include="Npgsql" Version="9.0.3" />
<PackageReference Include="Quartz" Version="3.14.0" />
<PackageReference Include="Quartz.Extensions.DependencyInjection" Version="3.14.0" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,12 @@
using System.Data;
using Npgsql;
namespace Femto.Common.Infrastructure.DbConnection;
public class DbConnectionFactory(string connectionString) : IDbConnectionFactory
{
public IDbConnection GetConnection()
{
return new NpgsqlConnection(connectionString);
}
}

View file

@ -0,0 +1,8 @@
using System.Data;
namespace Femto.Common.Infrastructure.DbConnection;
public interface IDbConnectionFactory
{
IDbConnection GetConnection();
}

View file

@ -0,0 +1,18 @@
namespace Femto.Common.Infrastructure.Outbox;
/// <summary>
/// A mapping based on the CLR type name
/// Brittle in the case of types being moved as they will then not be able to be handled by the outbox. but simple in implementation
/// </summary>
public class ClrTypenameMessageMapping : IOutboxMessageMapping
{
public Type? GetTypeOfEvent(string eventName)
{
return Type.GetType(eventName);
}
public string? GetEventName(Type eventType)
{
return eventType.AssemblyQualifiedName;
}
}

View file

@ -0,0 +1,9 @@
using Microsoft.EntityFrameworkCore;
namespace Femto.Common.Infrastructure.Outbox;
public interface IOutboxContext
{
DbSet<OutboxEntry> Outbox { get; }
}

View file

@ -0,0 +1,8 @@
using MediatR;
namespace Femto.Common.Infrastructure.Outbox;
public interface IOutboxMessageHandler
{
Task Publish<TNotification>(TNotification notification, CancellationToken executionContextCancellationToken);
}

View file

@ -0,0 +1,7 @@
namespace Femto.Common.Infrastructure.Outbox;
public interface IOutboxMessageMapping
{
Type? GetTypeOfEvent(string eventName);
string? GetEventName(Type eventType);
}

View file

@ -0,0 +1,29 @@
using System.Reflection;
using System.Text.Json;
using Femto.Common.Attributes;
using Femto.Common.Integration;
using Microsoft.Extensions.DependencyInjection;
namespace Femto.Common.Infrastructure.Outbox;
public class Outbox<TContext>(TContext context, IOutboxMessageMapping mapping) where TContext : IOutboxContext
{
public async Task AddMessage<TMessage>(
Guid aggregateId,
TMessage message,
CancellationToken cancellationToken
)
where TMessage : IIntegrationEvent
{
var eventName = mapping.GetEventName(typeof(TMessage));
if (eventName is null)
throw new InvalidOperationException(
$"{typeof(TMessage).Name} does not have EventType attribute"
);
await context.Outbox.AddAsync(
new(message.EventId, aggregateId, eventName, JsonSerializer.Serialize(message)),
cancellationToken
);
}
}

View file

@ -0,0 +1,59 @@
namespace Femto.Common.Infrastructure.Outbox;
public class OutboxEntry
{
private const int MaxRetries = 5;
public Guid Id { get; private set; }
public string EventType { get; private set; } = null!;
public Guid AggregateId { get; private set; }
public string Payload { get; private set; } = null!;
public DateTime CreatedAt { get; private set; }
public DateTime? ProcessedAt { get; private set; }
public DateTime? NextRetryAt { get; private set; }
public int RetryCount { get; private set; } = 0;
public string? LastError { get; private set; }
public OutboxEntryStatus Status { get; private set; }
private OutboxEntry() { }
public OutboxEntry(Guid eventId, Guid aggregateId, string eventType, string payload)
{
this.Id = eventId;
this.EventType = eventType;
this.AggregateId = aggregateId;
this.Payload = payload;
this.CreatedAt = DateTime.UtcNow;
}
public void Succeed()
{
this.ProcessedAt = DateTime.UtcNow;
this.Status = OutboxEntryStatus.Completed;
}
public void Fail(string error)
{
if (this.RetryCount >= MaxRetries)
{
this.Status = OutboxEntryStatus.Failed;
}
else
{
this.LastError = error;
this.NextRetryAt = DateTime.UtcNow.AddSeconds(Math.Pow(2, this.RetryCount));
this.RetryCount++;
}
}
}
public enum OutboxEntryStatus
{
Pending,
Completed,
Failed
}

View file

@ -0,0 +1,6 @@
namespace Femto.Common.Infrastructure.Outbox;
public static class OutboxEventNameRegistry
{
}

View file

@ -0,0 +1,84 @@
using System.Text.Json;
using MediatR;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Quartz;
namespace Femto.Common.Infrastructure.Outbox;
[DisallowConcurrentExecution]
public class OutboxProcessor<TContext>(
TContext context,
ILogger<OutboxProcessor<TContext>> logger,
IOutboxMessageMapping mapping,
IOutboxMessageHandler handler
) : IJob
where TContext : DbContext, IOutboxContext
{
public async Task Execute(IJobExecutionContext executionContext)
{
try
{
var now = DateTime.UtcNow;
var messages = await context
.Outbox.Where(message => message.Status == OutboxEntryStatus.Pending)
.Where(message => message.NextRetryAt == null || message.NextRetryAt <= now)
.OrderBy(message => message.CreatedAt)
.ToListAsync(executionContext.CancellationToken);
logger.LogTrace("loaded {Count} outbox messages to process", messages.Count);
foreach (var message in messages)
{
try
{
var notificationType = mapping.GetTypeOfEvent(message.EventType);
if (notificationType is null)
{
logger.LogWarning(
"unmapped event type {Type}. skipping.",
message.EventType
);
continue;
}
var notification =
JsonSerializer.Deserialize(message.Payload, notificationType)
as INotification;
if (notification is null)
throw new Exception("notification is null");
logger.LogTrace(
"publishing outbox message {EventType}. Id: {Id}, AggregateId: {AggregateId}",
message.EventType,
message.Id,
message.AggregateId
);
await handler.Publish(notification, executionContext.CancellationToken);
message.Succeed();
}
catch (Exception e)
{
logger.LogError(
e,
"Error processing event {EventId} for aggregate {AggregateId}",
message.Id,
message.AggregateId
);
message.Fail(e.ToString());
}
await context.SaveChangesAsync(executionContext.CancellationToken);
}
}
catch (Exception e)
{
logger.LogError(e, "Error while processing outbox");
throw;
}
}
}

View file

@ -0,0 +1,41 @@
using System.Reflection;
using Femto.Common.Attributes;
using MediatR;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Quartz;
namespace Femto.Common.Infrastructure.Outbox;
public static class OutboxServiceExtension
{
public static void AddOutbox<TContext>(
this IServiceCollection services,
Func<IServiceProvider, TContext>? contextFactory = null
)
where TContext : DbContext, IOutboxContext
{
services.AddSingleton<IOutboxMessageMapping, ClrTypenameMessageMapping>();
services.AddScoped<IOutboxContext>(c =>
contextFactory?.Invoke(c) ?? c.GetRequiredService<TContext>()
);
services.AddScoped<Outbox<TContext>>();
services.AddQuartz(q =>
{
var jobKey = JobKey.Create(nameof(OutboxProcessor<TContext>));
q.AddJob<OutboxProcessor<TContext>>(jobKey)
.AddTrigger(trigger =>
trigger
.ForJob(jobKey)
.WithSimpleSchedule(schedule =>
schedule.WithIntervalInSeconds(1).RepeatForever()
)
);
});
}
}

View file

@ -0,0 +1,57 @@
using Femto.Common.Domain;
using MediatR;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace Femto.Common.Infrastructure;
public class SaveChangesPipelineBehaviour<TRequest, TResponse>(
DbContext context,
IPublisher publisher,
ILogger<SaveChangesPipelineBehaviour<TRequest, TResponse>> logger
) : IPipelineBehavior<TRequest, TResponse>
where TRequest : notnull
{
public async Task<TResponse> Handle(
TRequest request,
RequestHandlerDelegate<TResponse> next,
CancellationToken cancellationToken
)
{
var response = await next(cancellationToken);
if (context.ChangeTracker.HasChanges())
{
await this.EmitDomainEvents(cancellationToken);
logger.LogDebug("saving changes");
await context.SaveChangesAsync(cancellationToken);
}
return response;
}
private async Task EmitDomainEvents(CancellationToken cancellationToken)
{
var domainEvents = context
.ChangeTracker.Entries<Entity>()
.SelectMany(e =>
{
var events = e.Entity.DomainEvents;
e.Entity.ClearDomainEvents();
return events;
})
.ToList();
logger.LogTrace("loaded {Count} domain events", domainEvents.Count);
foreach (var domainEvent in domainEvents)
{
logger.LogTrace(
"publishing {Type} domain event {Id}",
domainEvent.GetType().Name,
domainEvent.EventId
);
await publisher.Publish(domainEvent, cancellationToken);
}
}
}

View file

@ -0,0 +1,37 @@
using System.Reflection;
using Femto.Common.Attributes;
using MediatR;
namespace Femto.Common.Util;
public static class EventTypeMapping
{
public static IDictionary<string, Type> GetEventTypeMapping(Assembly assembly)
{
var mapping = new Dictionary<string, Type>();
var types = assembly.GetTypes();
foreach (var type in types)
{
if (
!typeof(INotification).IsAssignableFrom(type)
|| type.IsAbstract
|| type.IsInterface
)
continue;
var attribute = type.GetCustomAttribute<EventTypeAttribute>();
if (attribute == null)
continue;
var eventName = attribute.Name;
if (!string.IsNullOrWhiteSpace(eventName))
{
mapping.TryAdd(eventName, type);
}
}
return mapping;
}
}