outbox checkpoint

This commit is contained in:
2026-03-15 17:11:31 +01:00
parent f3a51dafad
commit a39453667a
63 changed files with 1410 additions and 55 deletions

View File

@@ -70,10 +70,10 @@
"username": "service-account-mccn-api", "username": "service-account-mccn-api",
"enabled": true, "enabled": true,
"serviceAccountClientId": "mccn-api", "serviceAccountClientId": "mccn-api",
"clientRoleMappings": { "clientRoles": {
"realm-management": [ "realm-management": [
{ "name": "manage-users" }, "manage-users",
{ "name": "view-users" } "view-users"
] ]
} }
} }

View File

@@ -1,36 +1,33 @@
<Solution> <Solution>
<Folder Name="/src/"/> <Folder Name="/src/" />
<Folder Name="/src/API/"> <Folder Name="/src/API/">
<Project Path="src/API/Mccn.Api/Mccn.Api.csproj"/> <Project Path="src/API/Mccn.Api/Mccn.Api.csproj" />
</Folder> </Folder>
<Folder Name="/src/Common/"> <Folder Name="/src/Common/">
<Project Path="src/Common/Mccn.Common.Application/Mccn.Common.Application.csproj"/> <Project Path="src/Common/Mccn.Common.Application/Mccn.Common.Application.csproj" />
<Project Path="src/Common/Mccn.Common.Domain/Mccn.Common.Domain.csproj"/> <Project Path="src/Common/Mccn.Common.Domain/Mccn.Common.Domain.csproj" />
<Project Path="src/Common/Mccn.Common.Infrastructure/Mccn.Common.Infrastructure.csproj"/> <Project Path="src/Common/Mccn.Common.Infrastructure/Mccn.Common.Infrastructure.csproj" />
<Project Path="src/Common/Mccn.Common.Presentation/Mccn.Common.Presentation.csproj"/> <Project Path="src/Common/Mccn.Common.Presentation/Mccn.Common.Presentation.csproj" />
</Folder> </Folder>
<Folder Name="/src/Modules/"/> <Folder Name="/src/Modules/" />
<Folder Name="/src/Modules/Hello/"> <Folder Name="/src/Modules/Hello/">
<Project Path="src/Modules/Hello/Mccn.Modules.Hello.Application/Mccn.Modules.Hello.Application.csproj"/> <Project Path="src/Modules/Hello/Mccn.Modules.Hello.Application/Mccn.Modules.Hello.Application.csproj" />
<Project <Project Path="src/Modules/Hello/Mccn.Modules.Hello.ArchitectureTests/Mccn.Modules.Hello.ArchitectureTests.csproj" />
Path="src/Modules/Hello/Mccn.Modules.Hello.ArchitectureTests/Mccn.Modules.Hello.ArchitectureTests.csproj"/> <Project Path="src/Modules/Hello/Mccn.Modules.Hello.Infrastructure/Mccn.Modules.Hello.Infrastructure.csproj" />
<Project Path="src/Modules/Hello/Mccn.Modules.Hello.Infrastructure/Mccn.Modules.Hello.Infrastructure.csproj"/> <Project Path="src/Modules/Hello/Mccn.Modules.Hello.IntegrationTests/Mccn.Modules.Hello.IntegrationTests.csproj" />
<Project <Project Path="src/Modules/Hello/Mccn.Modules.Hello.Presentation/Mccn.Modules.Hello.Presentation.csproj" />
Path="src/Modules/Hello/Mccn.Modules.Hello.IntegrationTests/Mccn.Modules.Hello.IntegrationTests.csproj"/>
<Project Path="src/Modules/Hello/Mccn.Modules.Hello.Presentation/Mccn.Modules.Hello.Presentation.csproj"/>
</Folder> </Folder>
<Folder Name="/src/Modules/Users/"> <Folder Name="/src/Modules/Users/">
<Project Path="src/Modules/Users/Mccn.Modules.Users.Application/Mccn.Modules.Users.Application.csproj"/> <Project Path="src/Modules/Users/Mccn.Modules.Users.Application/Mccn.Modules.Users.Application.csproj" />
<Project <Project Path="src/Modules/Users/Mccn.Modules.Users.ArchitectureTests/Mccn.Modules.Users.ArchitectureTests.csproj" />
Path="src/Modules/Users/Mccn.Modules.Users.ArchitectureTests/Mccn.Modules.Users.ArchitectureTests.csproj"/> <Project Path="src/Modules/Users/Mccn.Modules.Users.Domain/Mccn.Modules.Users.Domain.csproj" />
<Project Path="src/Modules/Users/Mccn.Modules.Users.Domain/Mccn.Modules.Users.Domain.csproj"/> <Project Path="src/Modules/Users/Mccn.Modules.Users.Infrastructure/Mccn.Modules.Users.Infrastructure.csproj" />
<Project Path="src/Modules/Users/Mccn.Modules.Users.Infrastructure/Mccn.Modules.Users.Infrastructure.csproj"/> <Project Path="src/Modules/Users/Mccn.Modules.Users.IntegrationEvents/Mccn.Modules.Users.IntegrationEvents.csproj" />
<Project <Project Path="src/Modules/Users/Mccn.Modules.Users.IntegrationTests/Mccn.Modules.Users.IntegrationTests.csproj" />
Path="src/Modules/Users/Mccn.Modules.Users.IntegrationTests/Mccn.Modules.Users.IntegrationTests.csproj"/> <Project Path="src/Modules/Users/Mccn.Modules.Users.Presentation/Mccn.Modules.Users.Presentation.csproj" />
<Project Path="src/Modules/Users/Mccn.Modules.Users.Presentation/Mccn.Modules.Users.Presentation.csproj"/> <Project Path="src/Modules/Users/Mccn.Modules.Users.UnitTests/Mccn.Modules.Users.UnitTests.csproj" />
<Project Path="src/Modules/Users/Mccn.Modules.Users.UnitTests/Mccn.Modules.Users.UnitTests.csproj"/>
</Folder> </Folder>
<Folder Name="/test/"> <Folder Name="/test/">
<Project Path="test/Mccn.ArchitectureTests/Mccn.ArchitectureTests.csproj"/> <Project Path="test/Mccn.ArchitectureTests/Mccn.ArchitectureTests.csproj" />
</Folder> </Folder>
</Solution> </Solution>

View File

@@ -42,6 +42,18 @@ services:
ports: ports:
- 6379:6379 - 6379:6379
mccn.queue:
image: rabbitmq:4-management
container_name: mccn.queue
environment:
RABBITMQ_DEFAULT_USER: guest
RABBITMQ_DEFAULT_PASS: guest
volumes:
- ./.containers/queue:/var/lib/rabbitmq
ports:
- 5672:5672
- 15672:15672
mccn.jaeger: mccn.jaeger:
image: jaegertracing/all-in-one:latest image: jaegertracing/all-in-one:latest
container_name: mccn.jaeger container_name: mccn.jaeger

View File

@@ -1,3 +1,4 @@
using Mccn.Modules.Hello.Infrastructure.Database;
using Mccn.Modules.Users.Infrastructure.Database; using Mccn.Modules.Users.Infrastructure.Database;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@@ -9,6 +10,7 @@ internal static class MigrationExtensions
{ {
using IServiceScope scope = app.Services.CreateScope(); using IServiceScope scope = app.Services.CreateScope();
await MigrateAsync<UsersDbContext>(scope.ServiceProvider); await MigrateAsync<UsersDbContext>(scope.ServiceProvider);
await MigrateAsync<HelloDbContext>(scope.ServiceProvider);
} }
private static async Task MigrateAsync<TContext>(IServiceProvider serviceProvider) where TContext : DbContext private static async Task MigrateAsync<TContext>(IServiceProvider serviceProvider) where TContext : DbContext

View File

@@ -43,10 +43,10 @@ POST {{baseAddress}}/users/register
Content-Type: {{content_type}} Content-Type: {{content_type}}
{ {
"email": "user@example.com", "email": "andrej+test6@mccn.dev",
"firstName": "Jane", "firstName": "Andrej",
"lastName": "Doe", "lastName": "Jovanovic",
"password": "Test1234!@#$anin149141" "password": "BestOfTheBest123"
} }
### Get user profile (requires auth) ### Get user profile (requires auth)
@@ -68,3 +68,8 @@ GET {{baseAddress}}/hello?name=World
### Say hello to the authenticated user (requires auth) ### Say hello to the authenticated user (requires auth)
GET {{baseAddress}}/hello/me GET {{baseAddress}}/hello/me
Authorization: Bearer {{token}} Authorization: Bearer {{token}}
### Get welcomed users — lists every user the Hello module was notified about via the inbox.
# Register a user first (see above), wait ~10 seconds for the outbox and inbox jobs to run,
# then call this endpoint to confirm the integration event was received and processed.
GET {{baseAddress}}/hello/welcomed-users

View File

@@ -17,7 +17,10 @@ builder.Host.UseSerilog((context, config) => { config.ReadFrom.Configuration(con
builder.Services.AddEndpointsApiExplorer(); builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(); builder.Services.AddSwaggerGen();
builder.Services.AddInfrastructure(builder.Configuration); builder.Services.AddInfrastructure(
builder.Configuration,
Mccn.Modules.Users.Infrastructure.DependencyInjection.ConfigureConsumers,
Mccn.Modules.Hello.Infrastructure.DependencyInjection.ConfigureConsumers);
builder.Services.AddApplication( builder.Services.AddApplication(
typeof(RegisterUserCommand).Assembly, typeof(RegisterUserCommand).Assembly,

View File

@@ -1,6 +1,7 @@
using System.Reflection; using System.Reflection;
using FluentValidation; using FluentValidation;
using Mccn.Common.Application.Behaviors; using Mccn.Common.Application.Behaviors;
using Mccn.Common.Application.EventBus;
using MediatR; using MediatR;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
@@ -18,6 +19,24 @@ public static class DependencyInjection
services.AddTransient(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>)); services.AddTransient(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>));
RegisterDomainEventHandlers(services, moduleAssemblies);
return services; return services;
} }
private static void RegisterDomainEventHandlers(IServiceCollection services, Assembly[] assemblies)
{
foreach (Assembly assembly in assemblies)
{
IEnumerable<(Type Implementation, Type Interface)> handlers = assembly.GetTypes()
.Where(t => !t.IsAbstract && !t.IsInterface)
.SelectMany(t => t.GetInterfaces()
.Where(i => i.IsGenericType &&
i.GetGenericTypeDefinition() == typeof(IDomainEventHandler<>))
.Select(i => (Implementation: t, Interface: i)));
foreach ((Type implementation, Type iface) in handlers)
services.AddTransient(iface, implementation);
}
}
} }

View File

@@ -0,0 +1,12 @@
using Mccn.Common.Domain.Abstractions;
namespace Mccn.Common.Application.EventBus;
public abstract class DomainEventHandler<TDomainEvent> : IDomainEventHandler<TDomainEvent>
where TDomainEvent : IDomainEvent
{
public abstract Task Handle(TDomainEvent domainEvent, CancellationToken cancellationToken);
Task IDomainEventHandler.Handle(IDomainEvent domainEvent, CancellationToken cancellationToken) =>
Handle((TDomainEvent)domainEvent, cancellationToken);
}

View File

@@ -0,0 +1,14 @@
using Mccn.Common.Domain.Abstractions;
namespace Mccn.Common.Application.EventBus;
public interface IDomainEventHandler
{
Task Handle(IDomainEvent domainEvent, CancellationToken cancellationToken);
}
public interface IDomainEventHandler<in TDomainEvent> : IDomainEventHandler
where TDomainEvent : IDomainEvent
{
Task Handle(TDomainEvent domainEvent, CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,7 @@
namespace Mccn.Common.Application.EventBus;
public interface IEventBus
{
Task PublishAsync<T>(T integrationEvent, CancellationToken cancellationToken = default)
where T : IIntegrationEvent;
}

View File

@@ -0,0 +1,7 @@
namespace Mccn.Common.Application.EventBus;
public interface IIntegrationEvent
{
Guid Id { get; }
DateTime OccurredOnUtc { get; }
}

View File

@@ -0,0 +1,12 @@
namespace Mccn.Common.Application.EventBus;
public interface IIntegrationEventHandler
{
Task Handle(IIntegrationEvent integrationEvent, CancellationToken cancellationToken);
}
public interface IIntegrationEventHandler<in TIntegrationEvent> : IIntegrationEventHandler
where TIntegrationEvent : IIntegrationEvent
{
Task Handle(TIntegrationEvent integrationEvent, CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,3 @@
namespace Mccn.Common.Application.EventBus;
public abstract record IntegrationEvent(Guid Id, DateTime OccurredOnUtc) : IIntegrationEvent;

View File

@@ -0,0 +1,10 @@
namespace Mccn.Common.Application.EventBus;
public abstract class IntegrationEventHandler<TIntegrationEvent> : IIntegrationEventHandler<TIntegrationEvent>
where TIntegrationEvent : IIntegrationEvent
{
public abstract Task Handle(TIntegrationEvent integrationEvent, CancellationToken cancellationToken);
Task IIntegrationEventHandler.Handle(IIntegrationEvent integrationEvent, CancellationToken cancellationToken) =>
Handle((TIntegrationEvent)integrationEvent, cancellationToken);
}

View File

@@ -0,0 +1,3 @@
namespace Mccn.Common.Domain.Abstractions;
public abstract record DomainEvent(Guid Id, DateTime OccurredOnUtc) : IDomainEvent;

View File

@@ -2,6 +2,8 @@ namespace Mccn.Common.Domain.Abstractions;
public abstract class Entity public abstract class Entity
{ {
private readonly List<IDomainEvent> _domainEvents = [];
protected Entity(Guid id) protected Entity(Guid id)
{ {
Id = id; Id = id;
@@ -12,4 +14,10 @@ public abstract class Entity
} }
public Guid Id { get; init; } public Guid Id { get; init; }
public IReadOnlyList<IDomainEvent> DomainEvents => _domainEvents.AsReadOnly();
protected void RaiseDomainEvent(IDomainEvent domainEvent) => _domainEvents.Add(domainEvent);
public void ClearDomainEvents() => _domainEvents.Clear();
} }

View File

@@ -0,0 +1,7 @@
namespace Mccn.Common.Domain.Abstractions;
public interface IDomainEvent
{
Guid Id { get; }
DateTime OccurredOnUtc { get; }
}

View File

@@ -1,9 +1,13 @@
using MassTransit;
using Mccn.Common.Application.EventBus;
using Mccn.Common.Infrastructure.Authentication; using Mccn.Common.Infrastructure.Authentication;
using Mccn.Common.Infrastructure.Caching; using Mccn.Common.Infrastructure.Caching;
using Mccn.Common.Infrastructure.EventBus;
using Mccn.Common.Infrastructure.ExceptionHandlers; using Mccn.Common.Infrastructure.ExceptionHandlers;
using Mccn.Common.Infrastructure.Observability; using Mccn.Common.Infrastructure.Observability;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Quartz;
namespace Mccn.Common.Infrastructure; namespace Mccn.Common.Infrastructure;
@@ -11,7 +15,8 @@ public static class DependencyInjection
{ {
public static IServiceCollection AddInfrastructure( public static IServiceCollection AddInfrastructure(
this IServiceCollection services, this IServiceCollection services,
IConfiguration configuration) IConfiguration configuration,
params Action<IRegistrationConfigurator>[] moduleConsumers)
{ {
services.AddJwtAuthentication(configuration); services.AddJwtAuthentication(configuration);
@@ -26,6 +31,24 @@ public static class DependencyInjection
services.AddHttpContextAccessor(); services.AddHttpContextAccessor();
services.AddMassTransit(configure =>
{
foreach (Action<IRegistrationConfigurator> configureConsumers in moduleConsumers)
configureConsumers(configure);
configure.SetKebabCaseEndpointNameFormatter();
configure.UsingRabbitMq((context, cfg) =>
{
cfg.Host(configuration.GetConnectionString("MessageBroker"));
cfg.ConfigureEndpoints(context);
});
});
services.AddScoped<IEventBus, EventBus.EventBus>();
services.AddQuartz();
services.AddQuartzHostedService(options => options.WaitForJobsToComplete = true);
return services; return services;
} }
} }

View File

@@ -0,0 +1,13 @@
using MassTransit;
using Mccn.Common.Application.EventBus;
namespace Mccn.Common.Infrastructure.EventBus;
internal sealed class EventBus(IBus bus) : IEventBus
{
public async Task PublishAsync<T>(T integrationEvent, CancellationToken cancellationToken = default)
where T : IIntegrationEvent
{
await bus.Publish(integrationEvent, cancellationToken);
}
}

View File

@@ -0,0 +1,11 @@
namespace Mccn.Common.Infrastructure.Inbox;
public sealed class InboxMessage
{
public Guid Id { get; init; }
public string Type { get; init; } = string.Empty;
public string Content { get; init; } = string.Empty;
public DateTime OccurredOnUtc { get; init; }
public DateTime? ProcessedOnUtc { get; set; }
public string? Error { get; set; }
}

View File

@@ -0,0 +1,7 @@
namespace Mccn.Common.Infrastructure.Inbox;
public sealed class InboxMessageConsumer
{
public Guid InboxMessageId { get; init; }
public string Name { get; init; } = string.Empty;
}

View File

@@ -10,12 +10,16 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="MassTransit" Version="8.3.7"/>
<PackageReference Include="MassTransit.RabbitMQ" Version="8.3.7"/>
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.4"/>
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.5"/> <PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.5"/>
<PackageReference Include="Microsoft.Extensions.Caching.StackExchangeRedis" Version="10.0.5"/> <PackageReference Include="Microsoft.Extensions.Caching.StackExchangeRedis" Version="10.0.5"/>
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.15.0"/> <PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.15.0"/>
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.15.0"/> <PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.15.0"/>
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.15.1"/> <PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.15.1"/>
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.15.0"/> <PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.15.0"/>
<PackageReference Include="Quartz.Extensions.Hosting" Version="3.14.0"/>
<PackageReference Include="Serilog.AspNetCore" Version="10.0.0"/> <PackageReference Include="Serilog.AspNetCore" Version="10.0.0"/>
<PackageReference Include="Serilog.Sinks.Seq" Version="9.0.0"/> <PackageReference Include="Serilog.Sinks.Seq" Version="9.0.0"/>
</ItemGroup> </ItemGroup>

View File

@@ -19,6 +19,7 @@ public static class ObservabilityExtensions
tracing tracing
.AddAspNetCoreInstrumentation() .AddAspNetCoreInstrumentation()
.AddHttpClientInstrumentation() .AddHttpClientInstrumentation()
.AddSource("MassTransit")
.AddOtlpExporter(options => .AddOtlpExporter(options =>
{ {
options.Endpoint = new Uri( options.Endpoint = new Uri(

View File

@@ -0,0 +1,43 @@
using System.Text.Json;
using Mccn.Common.Domain.Abstractions;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
namespace Mccn.Common.Infrastructure.Outbox;
public sealed class InsertOutboxMessagesInterceptor : SaveChangesInterceptor
{
public override ValueTask<InterceptionResult<int>> SavingChangesAsync(
DbContextEventData eventData,
InterceptionResult<int> result,
CancellationToken cancellationToken = default)
{
if (eventData.Context is not null)
InsertOutboxMessages(eventData.Context);
return base.SavingChangesAsync(eventData, result, cancellationToken);
}
private static void InsertOutboxMessages(DbContext context)
{
List<OutboxMessage> outboxMessages = context.ChangeTracker
.Entries<Entity>()
.Select(e => e.Entity)
.SelectMany(entity =>
{
IReadOnlyList<IDomainEvent> domainEvents = entity.DomainEvents;
entity.ClearDomainEvents();
return domainEvents;
})
.Select(domainEvent => new OutboxMessage
{
Id = domainEvent.Id,
Type = domainEvent.GetType().Name,
Content = JsonSerializer.Serialize(domainEvent, domainEvent.GetType()),
OccurredOnUtc = domainEvent.OccurredOnUtc
})
.ToList();
context.Set<OutboxMessage>().AddRange(outboxMessages);
}
}

View File

@@ -0,0 +1,11 @@
namespace Mccn.Common.Infrastructure.Outbox;
public sealed class OutboxMessage
{
public Guid Id { get; init; }
public string Type { get; init; } = string.Empty;
public string Content { get; init; } = string.Empty;
public DateTime OccurredOnUtc { get; init; }
public DateTime? ProcessedOnUtc { get; set; }
public string? Error { get; set; }
}

View File

@@ -0,0 +1,6 @@
namespace Mccn.Modules.Hello.Application.Abstractions;
public interface IUnitOfWork
{
Task SaveChangesAsync(CancellationToken cancellationToken = default);
}

View File

@@ -2,6 +2,7 @@
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\..\..\Common\Mccn.Common.Application\Mccn.Common.Application.csproj"/> <ProjectReference Include="..\..\..\Common\Mccn.Common.Application\Mccn.Common.Application.csproj"/>
<ProjectReference Include="..\..\Users\Mccn.Modules.Users.IntegrationEvents\Mccn.Modules.Users.IntegrationEvents.csproj"/>
</ItemGroup> </ItemGroup>
<PropertyGroup> <PropertyGroup>

View File

@@ -0,0 +1,5 @@
using Mccn.Common.Application.Abstractions;
namespace Mccn.Modules.Hello.Application.WelcomedUsers;
public sealed record GetWelcomedUsersQuery : IQuery<IReadOnlyList<WelcomedUserResponse>>;

View File

@@ -0,0 +1,16 @@
using Mccn.Common.Application.Abstractions;
using Mccn.Common.Domain.Abstractions;
namespace Mccn.Modules.Hello.Application.WelcomedUsers;
internal sealed class GetWelcomedUsersQueryHandler(IWelcomedUserRepository repository)
: IQueryHandler<GetWelcomedUsersQuery, IReadOnlyList<WelcomedUserResponse>>
{
public async Task<Result<IReadOnlyList<WelcomedUserResponse>>> Handle(
GetWelcomedUsersQuery request,
CancellationToken cancellationToken)
{
IReadOnlyList<WelcomedUserResponse> users = await repository.GetAllAsync(cancellationToken);
return Result.Success(users);
}
}

View File

@@ -0,0 +1,14 @@
namespace Mccn.Modules.Hello.Application.WelcomedUsers;
public interface IWelcomedUserRepository
{
Task AddAsync(
Guid userId,
string email,
string firstName,
string lastName,
DateTime welcomedAt,
CancellationToken cancellationToken = default);
Task<IReadOnlyList<WelcomedUserResponse>> GetAllAsync(CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,26 @@
using Mccn.Common.Application.EventBus;
using Mccn.Modules.Hello.Application.Abstractions;
using Mccn.Modules.Users.IntegrationEvents;
namespace Mccn.Modules.Hello.Application.WelcomedUsers;
public sealed class UserRegisteredIntegrationEventHandler(
IWelcomedUserRepository repository,
IUnitOfWork unitOfWork)
: IntegrationEventHandler<UserRegisteredIntegrationEvent>
{
public override async Task Handle(
UserRegisteredIntegrationEvent integrationEvent,
CancellationToken cancellationToken)
{
await repository.AddAsync(
integrationEvent.UserId,
integrationEvent.Email,
integrationEvent.FirstName,
integrationEvent.LastName,
integrationEvent.OccurredOnUtc,
cancellationToken);
await unitOfWork.SaveChangesAsync(cancellationToken);
}
}

View File

@@ -0,0 +1,8 @@
namespace Mccn.Modules.Hello.Application.WelcomedUsers;
public sealed record WelcomedUserResponse(
Guid UserId,
string Email,
string FirstName,
string LastName,
DateTime WelcomedAt);

View File

@@ -0,0 +1,16 @@
using Mccn.Common.Infrastructure.Inbox;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Mccn.Modules.Hello.Infrastructure.Database.Configurations;
internal sealed class InboxMessageConfiguration : IEntityTypeConfiguration<InboxMessage>
{
public void Configure(EntityTypeBuilder<InboxMessage> builder)
{
builder.ToTable("inbox_messages");
builder.HasKey(i => i.Id);
builder.Property(i => i.Type).IsRequired();
builder.Property(i => i.Content).IsRequired();
}
}

View File

@@ -0,0 +1,14 @@
using Mccn.Common.Infrastructure.Inbox;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Mccn.Modules.Hello.Infrastructure.Database.Configurations;
internal sealed class InboxMessageConsumerConfiguration : IEntityTypeConfiguration<InboxMessageConsumer>
{
public void Configure(EntityTypeBuilder<InboxMessageConsumer> builder)
{
builder.ToTable("inbox_message_consumers");
builder.HasKey(c => new { c.InboxMessageId, c.Name });
}
}

View File

@@ -0,0 +1,17 @@
using Mccn.Modules.Hello.Infrastructure.Database.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Mccn.Modules.Hello.Infrastructure.Database.Configurations;
internal sealed class WelcomedUserConfiguration : IEntityTypeConfiguration<WelcomedUser>
{
public void Configure(EntityTypeBuilder<WelcomedUser> builder)
{
builder.ToTable("welcomed_users");
builder.HasKey(w => w.Id);
builder.Property(w => w.Email).IsRequired();
builder.Property(w => w.FirstName).IsRequired();
builder.Property(w => w.LastName).IsRequired();
}
}

View File

@@ -0,0 +1,23 @@
using Mccn.Common.Infrastructure.Inbox;
using Mccn.Modules.Hello.Infrastructure.Database.Models;
using Microsoft.EntityFrameworkCore;
namespace Mccn.Modules.Hello.Infrastructure.Database;
public sealed class HelloDbContext : DbContext
{
public HelloDbContext(DbContextOptions<HelloDbContext> options)
: base(options)
{
}
public DbSet<WelcomedUser> WelcomedUsers { get; set; }
public DbSet<InboxMessage> InboxMessages { get; set; }
public DbSet<InboxMessageConsumer> InboxMessageConsumers { get; set; }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.HasDefaultSchema(Schemas.Hello);
modelBuilder.ApplyConfigurationsFromAssembly(typeof(HelloDbContext).Assembly);
}
}

View File

@@ -0,0 +1,11 @@
namespace Mccn.Modules.Hello.Infrastructure.Database.Models;
public sealed class WelcomedUser
{
public Guid Id { get; init; }
public Guid UserId { get; init; }
public string Email { get; init; } = string.Empty;
public string FirstName { get; init; } = string.Empty;
public string LastName { get; init; } = string.Empty;
public DateTime WelcomedAt { get; init; }
}

View File

@@ -0,0 +1,6 @@
namespace Mccn.Modules.Hello.Infrastructure.Database;
internal static class Schemas
{
internal const string Hello = "hello";
}

View File

@@ -1,16 +1,54 @@
using MassTransit;
using Mccn.Common.Application.EventBus;
using Mccn.Modules.Hello.Application.Abstractions;
using Mccn.Modules.Hello.Application.WelcomedUsers;
using Mccn.Modules.Hello.Infrastructure.Database;
using Mccn.Modules.Hello.Infrastructure.Inbox;
using Mccn.Modules.Hello.Infrastructure.WelcomedUsers;
using Mccn.Modules.Users.IntegrationEvents;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Quartz;
namespace Mccn.Modules.Hello.Infrastructure; namespace Mccn.Modules.Hello.Infrastructure;
public static class DependencyInjection public static class DependencyInjection
{ {
public static void ConfigureConsumers(IRegistrationConfigurator configurator)
{
configurator.AddConsumer<IntegrationEventConsumer<UserRegisteredIntegrationEvent>>();
}
public static IServiceCollection AddHelloModule( public static IServiceCollection AddHelloModule(
this IServiceCollection services, this IServiceCollection services,
IConfiguration configuration) IConfiguration configuration)
{ {
// Hello module has no database/infrastructure dependencies. services.AddDbContext<HelloDbContext>(options =>
// This is where you'd add them when the module grows. {
options.UseNpgsql(
configuration.GetConnectionString("Database"),
o => o.MigrationsHistoryTable("__ef_migrations_history", Schemas.Hello))
.UseSnakeCaseNamingConvention();
});
services.AddScoped<IWelcomedUserRepository, WelcomedUserRepository>();
services.AddScoped<IUnitOfWork, UnitOfWork>();
// Register integration event handlers so ProcessInboxJob can resolve them.
services.AddScoped<
IIntegrationEventHandler<UserRegisteredIntegrationEvent>,
UserRegisteredIntegrationEventHandler>();
services.AddQuartz(q =>
{
var jobKey = new JobKey(nameof(ProcessInboxJob));
q.AddJob<ProcessInboxJob>(opts => opts.WithIdentity(jobKey));
q.AddTrigger(opts => opts
.ForJob(jobKey)
.WithSimpleSchedule(s => s.WithIntervalInSeconds(10).RepeatForever()));
});
return services; return services;
} }
} }

View File

@@ -0,0 +1,28 @@
using System.Text.Json;
using MassTransit;
using Mccn.Common.Application.EventBus;
using Mccn.Common.Infrastructure.Inbox;
using Mccn.Modules.Hello.Infrastructure.Database;
namespace Mccn.Modules.Hello.Infrastructure.Inbox;
internal sealed class IntegrationEventConsumer<TIntegrationEvent>(HelloDbContext dbContext)
: IConsumer<TIntegrationEvent>
where TIntegrationEvent : class, IIntegrationEvent
{
public async Task Consume(ConsumeContext<TIntegrationEvent> context)
{
TIntegrationEvent integrationEvent = context.Message;
var inboxMessage = new InboxMessage
{
Id = integrationEvent.Id,
Type = integrationEvent.GetType().Name,
Content = JsonSerializer.Serialize(integrationEvent, integrationEvent.GetType()),
OccurredOnUtc = integrationEvent.OccurredOnUtc
};
await dbContext.InboxMessages.AddAsync(inboxMessage, context.CancellationToken);
await dbContext.SaveChangesAsync(context.CancellationToken);
}
}

View File

@@ -0,0 +1,103 @@
using System.Reflection;
using System.Text.Json;
using Mccn.Common.Application.EventBus;
using Mccn.Common.Infrastructure.Inbox;
using Mccn.Modules.Hello.Application.WelcomedUsers;
using Mccn.Modules.Hello.Infrastructure.Database;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Quartz;
namespace Mccn.Modules.Hello.Infrastructure.Inbox;
[DisallowConcurrentExecution]
internal sealed class ProcessInboxJob(
HelloDbContext dbContext,
IServiceScopeFactory serviceScopeFactory,
ILogger<ProcessInboxJob> logger) : IJob
{
// Scan the Application assembly for integration event handler types.
private static readonly Assembly ApplicationAssembly =
typeof(UserRegisteredIntegrationEventHandler).Assembly;
public async Task Execute(IJobExecutionContext context)
{
List<InboxMessage> messages = await dbContext.InboxMessages
.Where(m => m.ProcessedOnUtc == null)
.OrderBy(m => m.OccurredOnUtc)
.Take(20)
.ToListAsync(context.CancellationToken);
foreach (InboxMessage message in messages)
{
try
{
Type? eventType = ApplicationAssembly.GetReferencedAssemblies()
.Select(Assembly.Load)
.Concat([ApplicationAssembly])
.SelectMany(a => a.GetTypes())
.FirstOrDefault(t => t.Name == message.Type);
if (eventType is null)
{
logger.LogWarning("Could not find integration event type {Type}", message.Type);
message.ProcessedOnUtc = DateTime.UtcNow;
message.Error = $"Unknown integration event type: {message.Type}";
continue;
}
IIntegrationEvent? integrationEvent =
(IIntegrationEvent?)JsonSerializer.Deserialize(message.Content, eventType);
if (integrationEvent is null)
{
message.ProcessedOnUtc = DateTime.UtcNow;
message.Error = "Failed to deserialize integration event.";
continue;
}
using IServiceScope scope = serviceScopeFactory.CreateScope();
Type handlerInterfaceType = typeof(IIntegrationEventHandler<>).MakeGenericType(eventType);
IEnumerable<IIntegrationEventHandler> handlers = scope.ServiceProvider
.GetServices(handlerInterfaceType)
.Cast<IIntegrationEventHandler>();
foreach (IIntegrationEventHandler handler in handlers)
{
string consumerName = handler.GetType().Name;
bool alreadyProcessed = await dbContext.InboxMessageConsumers
.AnyAsync(
c => c.InboxMessageId == message.Id && c.Name == consumerName,
context.CancellationToken);
if (alreadyProcessed)
continue;
await handler.Handle(integrationEvent, context.CancellationToken);
dbContext.InboxMessageConsumers.Add(new InboxMessageConsumer
{
InboxMessageId = message.Id,
Name = consumerName
});
await dbContext.SaveChangesAsync(context.CancellationToken);
}
message.ProcessedOnUtc = DateTime.UtcNow;
}
catch (Exception ex)
{
logger.LogError(ex, "Error processing inbox message {MessageId}", message.Id);
message.Error = ex.Message;
message.ProcessedOnUtc = DateTime.UtcNow;
}
}
await dbContext.SaveChangesAsync(context.CancellationToken);
}
}

View File

@@ -5,6 +5,12 @@
<ProjectReference Include="..\..\..\Common\Mccn.Common.Infrastructure\Mccn.Common.Infrastructure.csproj"/> <ProjectReference Include="..\..\..\Common\Mccn.Common.Infrastructure\Mccn.Common.Infrastructure.csproj"/>
</ItemGroup> </ItemGroup>
<ItemGroup>
<PackageReference Include="EFCore.NamingConventions" Version="10.0.1"/>
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.4"/>
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.1"/>
</ItemGroup>
<PropertyGroup> <PropertyGroup>
<TargetFramework>net10.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>

View File

@@ -0,0 +1,118 @@
// <auto-generated />
using System;
using Mccn.Modules.Hello.Infrastructure.Database;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Mccn.Modules.Hello.Infrastructure.Migrations
{
[DbContext(typeof(HelloDbContext))]
[Migration("20260315153129_InitialCreate")]
partial class InitialCreate
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasDefaultSchema("hello")
.HasAnnotation("ProductVersion", "10.0.4")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Mccn.Common.Infrastructure.Inbox.InboxMessage", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<string>("Content")
.IsRequired()
.HasColumnType("text")
.HasColumnName("content");
b.Property<string>("Error")
.HasColumnType("text")
.HasColumnName("error");
b.Property<DateTime>("OccurredOnUtc")
.HasColumnType("timestamp with time zone")
.HasColumnName("occurred_on_utc");
b.Property<DateTime?>("ProcessedOnUtc")
.HasColumnType("timestamp with time zone")
.HasColumnName("processed_on_utc");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("text")
.HasColumnName("type");
b.HasKey("Id")
.HasName("pk_inbox_messages");
b.ToTable("inbox_messages", "hello");
});
modelBuilder.Entity("Mccn.Common.Infrastructure.Inbox.InboxMessageConsumer", b =>
{
b.Property<Guid>("InboxMessageId")
.HasColumnType("uuid")
.HasColumnName("inbox_message_id");
b.Property<string>("Name")
.HasColumnType("text")
.HasColumnName("name");
b.HasKey("InboxMessageId", "Name")
.HasName("pk_inbox_message_consumers");
b.ToTable("inbox_message_consumers", "hello");
});
modelBuilder.Entity("Mccn.Modules.Hello.Infrastructure.Database.Models.WelcomedUser", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<string>("Email")
.IsRequired()
.HasColumnType("text")
.HasColumnName("email");
b.Property<string>("FirstName")
.IsRequired()
.HasColumnType("text")
.HasColumnName("first_name");
b.Property<string>("LastName")
.IsRequired()
.HasColumnType("text")
.HasColumnName("last_name");
b.Property<Guid>("UserId")
.HasColumnType("uuid")
.HasColumnName("user_id");
b.Property<DateTime>("WelcomedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("welcomed_at");
b.HasKey("Id")
.HasName("pk_welcomed_users");
b.ToTable("welcomed_users", "hello");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,81 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Mccn.Modules.Hello.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class InitialCreate : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.EnsureSchema(
name: "hello");
migrationBuilder.CreateTable(
name: "inbox_message_consumers",
schema: "hello",
columns: table => new
{
inbox_message_id = table.Column<Guid>(type: "uuid", nullable: false),
name = table.Column<string>(type: "text", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("pk_inbox_message_consumers", x => new { x.inbox_message_id, x.name });
});
migrationBuilder.CreateTable(
name: "inbox_messages",
schema: "hello",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false),
type = table.Column<string>(type: "text", nullable: false),
content = table.Column<string>(type: "text", nullable: false),
occurred_on_utc = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
processed_on_utc = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
error = table.Column<string>(type: "text", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("pk_inbox_messages", x => x.id);
});
migrationBuilder.CreateTable(
name: "welcomed_users",
schema: "hello",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false),
user_id = table.Column<Guid>(type: "uuid", nullable: false),
email = table.Column<string>(type: "text", nullable: false),
first_name = table.Column<string>(type: "text", nullable: false),
last_name = table.Column<string>(type: "text", nullable: false),
welcomed_at = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("pk_welcomed_users", x => x.id);
});
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "inbox_message_consumers",
schema: "hello");
migrationBuilder.DropTable(
name: "inbox_messages",
schema: "hello");
migrationBuilder.DropTable(
name: "welcomed_users",
schema: "hello");
}
}
}

View File

@@ -0,0 +1,115 @@
// <auto-generated />
using System;
using Mccn.Modules.Hello.Infrastructure.Database;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Mccn.Modules.Hello.Infrastructure.Migrations
{
[DbContext(typeof(HelloDbContext))]
partial class HelloDbContextModelSnapshot : ModelSnapshot
{
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasDefaultSchema("hello")
.HasAnnotation("ProductVersion", "10.0.4")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Mccn.Common.Infrastructure.Inbox.InboxMessage", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<string>("Content")
.IsRequired()
.HasColumnType("text")
.HasColumnName("content");
b.Property<string>("Error")
.HasColumnType("text")
.HasColumnName("error");
b.Property<DateTime>("OccurredOnUtc")
.HasColumnType("timestamp with time zone")
.HasColumnName("occurred_on_utc");
b.Property<DateTime?>("ProcessedOnUtc")
.HasColumnType("timestamp with time zone")
.HasColumnName("processed_on_utc");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("text")
.HasColumnName("type");
b.HasKey("Id")
.HasName("pk_inbox_messages");
b.ToTable("inbox_messages", "hello");
});
modelBuilder.Entity("Mccn.Common.Infrastructure.Inbox.InboxMessageConsumer", b =>
{
b.Property<Guid>("InboxMessageId")
.HasColumnType("uuid")
.HasColumnName("inbox_message_id");
b.Property<string>("Name")
.HasColumnType("text")
.HasColumnName("name");
b.HasKey("InboxMessageId", "Name")
.HasName("pk_inbox_message_consumers");
b.ToTable("inbox_message_consumers", "hello");
});
modelBuilder.Entity("Mccn.Modules.Hello.Infrastructure.Database.Models.WelcomedUser", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<string>("Email")
.IsRequired()
.HasColumnType("text")
.HasColumnName("email");
b.Property<string>("FirstName")
.IsRequired()
.HasColumnType("text")
.HasColumnName("first_name");
b.Property<string>("LastName")
.IsRequired()
.HasColumnType("text")
.HasColumnName("last_name");
b.Property<Guid>("UserId")
.HasColumnType("uuid")
.HasColumnName("user_id");
b.Property<DateTime>("WelcomedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("welcomed_at");
b.HasKey("Id")
.HasName("pk_welcomed_users");
b.ToTable("welcomed_users", "hello");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,10 @@
using Mccn.Modules.Hello.Application.Abstractions;
using Mccn.Modules.Hello.Infrastructure.Database;
namespace Mccn.Modules.Hello.Infrastructure.WelcomedUsers;
internal sealed class UnitOfWork(HelloDbContext dbContext) : IUnitOfWork
{
public async Task SaveChangesAsync(CancellationToken cancellationToken = default) =>
await dbContext.SaveChangesAsync(cancellationToken);
}

View File

@@ -0,0 +1,44 @@
using Mccn.Modules.Hello.Application.WelcomedUsers;
using Mccn.Modules.Hello.Infrastructure.Database;
using Mccn.Modules.Hello.Infrastructure.Database.Models;
using Microsoft.EntityFrameworkCore;
namespace Mccn.Modules.Hello.Infrastructure.WelcomedUsers;
internal sealed class WelcomedUserRepository(HelloDbContext dbContext) : IWelcomedUserRepository
{
public async Task AddAsync(
Guid userId,
string email,
string firstName,
string lastName,
DateTime welcomedAt,
CancellationToken cancellationToken = default)
{
var welcomedUser = new WelcomedUser
{
Id = Guid.NewGuid(),
UserId = userId,
Email = email,
FirstName = firstName,
LastName = lastName,
WelcomedAt = welcomedAt
};
await dbContext.WelcomedUsers.AddAsync(welcomedUser, cancellationToken);
}
public async Task<IReadOnlyList<WelcomedUserResponse>> GetAllAsync(
CancellationToken cancellationToken = default)
{
return await dbContext.WelcomedUsers
.OrderByDescending(w => w.WelcomedAt)
.Select(w => new WelcomedUserResponse(
w.UserId,
w.Email,
w.FirstName,
w.LastName,
w.WelcomedAt))
.ToListAsync(cancellationToken);
}
}

View File

@@ -0,0 +1,25 @@
using Mccn.Common.Domain.Abstractions;
using Mccn.Common.Presentation.Endpoints;
using Mccn.Modules.Hello.Application.WelcomedUsers;
using MediatR;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
namespace Mccn.Modules.Hello.Presentation.Hello;
internal sealed class GetWelcomedUsers : IEndpoint
{
public void MapEndpoint(IEndpointRouteBuilder app)
{
app.MapGet("hello/welcomed-users", async (ISender sender) =>
{
Result<IReadOnlyList<WelcomedUserResponse>> result =
await sender.Send(new GetWelcomedUsersQuery());
return Results.Ok(result.Value);
})
.WithTags("Hello")
.AllowAnonymous();
}
}

View File

@@ -2,6 +2,7 @@
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\Mccn.Modules.Users.Domain\Mccn.Modules.Users.Domain.csproj"/> <ProjectReference Include="..\Mccn.Modules.Users.Domain\Mccn.Modules.Users.Domain.csproj"/>
<ProjectReference Include="..\Mccn.Modules.Users.IntegrationEvents\Mccn.Modules.Users.IntegrationEvents.csproj"/>
<ProjectReference Include="..\..\..\Common\Mccn.Common.Application\Mccn.Common.Application.csproj"/> <ProjectReference Include="..\..\..\Common\Mccn.Common.Application\Mccn.Common.Application.csproj"/>
</ItemGroup> </ItemGroup>

View File

@@ -0,0 +1,22 @@
using Mccn.Common.Application.EventBus;
using Mccn.Modules.Users.Domain.Users;
using Mccn.Modules.Users.IntegrationEvents;
namespace Mccn.Modules.Users.Application.Users.Register;
internal sealed class UserRegisteredDomainEventHandler(IEventBus eventBus)
: DomainEventHandler<UserRegisteredDomainEvent>
{
public override async Task Handle(
UserRegisteredDomainEvent domainEvent,
CancellationToken cancellationToken)
{
await eventBus.PublishAsync(new UserRegisteredIntegrationEvent(
domainEvent.Id,
domainEvent.OccurredOnUtc,
domainEvent.UserId,
domainEvent.Email,
domainEvent.FirstName,
domainEvent.LastName), cancellationToken);
}
}

View File

@@ -20,7 +20,7 @@ public sealed class User : Entity
string lastName, string lastName,
string identityId) string identityId)
{ {
return new User var user = new User
{ {
Id = id, Id = id,
Email = email, Email = email,
@@ -28,5 +28,15 @@ public sealed class User : Entity
LastName = lastName, LastName = lastName,
IdentityId = identityId IdentityId = identityId
}; };
user.RaiseDomainEvent(new UserRegisteredDomainEvent(
Guid.NewGuid(),
DateTime.UtcNow,
user.Id,
user.Email,
user.FirstName,
user.LastName));
return user;
} }
} }

View File

@@ -0,0 +1,11 @@
using Mccn.Common.Domain.Abstractions;
namespace Mccn.Modules.Users.Domain.Users;
public sealed record UserRegisteredDomainEvent(
Guid Id,
DateTime OccurredOnUtc,
Guid UserId,
string Email,
string FirstName,
string LastName) : DomainEvent(Id, OccurredOnUtc);

View File

@@ -0,0 +1,16 @@
using Mccn.Common.Infrastructure.Outbox;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Mccn.Modules.Users.Infrastructure.Database.Configurations;
internal sealed class OutboxMessageConfiguration : IEntityTypeConfiguration<OutboxMessage>
{
public void Configure(EntityTypeBuilder<OutboxMessage> builder)
{
builder.ToTable("outbox_messages");
builder.HasKey(o => o.Id);
builder.Property(o => o.Type).IsRequired();
builder.Property(o => o.Content).IsRequired();
}
}

View File

@@ -0,0 +1,111 @@
// <auto-generated />
using System;
using Mccn.Modules.Users.Infrastructure.Database;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Mccn.Modules.Users.Infrastructure.Database.Migrations
{
[DbContext(typeof(UsersDbContext))]
[Migration("20260315153115_AddOutboxMessages")]
partial class AddOutboxMessages
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasDefaultSchema("users")
.HasAnnotation("ProductVersion", "10.0.4")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Mccn.Common.Infrastructure.Outbox.OutboxMessage", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<string>("Content")
.IsRequired()
.HasColumnType("text")
.HasColumnName("content");
b.Property<string>("Error")
.HasColumnType("text")
.HasColumnName("error");
b.Property<DateTime>("OccurredOnUtc")
.HasColumnType("timestamp with time zone")
.HasColumnName("occurred_on_utc");
b.Property<DateTime?>("ProcessedOnUtc")
.HasColumnType("timestamp with time zone")
.HasColumnName("processed_on_utc");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("text")
.HasColumnName("type");
b.HasKey("Id")
.HasName("pk_outbox_messages");
b.ToTable("outbox_messages", "users");
});
modelBuilder.Entity("Mccn.Modules.Users.Domain.Users.User", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<string>("Email")
.IsRequired()
.HasMaxLength(300)
.HasColumnType("character varying(300)")
.HasColumnName("email");
b.Property<string>("FirstName")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("first_name");
b.Property<string>("IdentityId")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("identity_id");
b.Property<string>("LastName")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("last_name");
b.HasKey("Id")
.HasName("pk_users");
b.HasIndex("Email")
.IsUnique()
.HasDatabaseName("ix_users_email");
b.HasIndex("IdentityId")
.IsUnique()
.HasDatabaseName("ix_users_identity_id");
b.ToTable("users", "users");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,40 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Mccn.Modules.Users.Infrastructure.Database.Migrations
{
/// <inheritdoc />
public partial class AddOutboxMessages : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "outbox_messages",
schema: "users",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false),
type = table.Column<string>(type: "text", nullable: false),
content = table.Column<string>(type: "text", nullable: false),
occurred_on_utc = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
processed_on_utc = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
error = table.Column<string>(type: "text", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("pk_outbox_messages", x => x.id);
});
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "outbox_messages",
schema: "users");
}
}
}

View File

@@ -18,11 +18,46 @@ namespace Mccn.Modules.Users.Infrastructure.Database.Migrations
#pragma warning disable 612, 618 #pragma warning disable 612, 618
modelBuilder modelBuilder
.HasDefaultSchema("users") .HasDefaultSchema("users")
.HasAnnotation("ProductVersion", "10.0.5") .HasAnnotation("ProductVersion", "10.0.4")
.HasAnnotation("Relational:MaxIdentifierLength", 63); .HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Mccn.Common.Infrastructure.Outbox.OutboxMessage", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<string>("Content")
.IsRequired()
.HasColumnType("text")
.HasColumnName("content");
b.Property<string>("Error")
.HasColumnType("text")
.HasColumnName("error");
b.Property<DateTime>("OccurredOnUtc")
.HasColumnType("timestamp with time zone")
.HasColumnName("occurred_on_utc");
b.Property<DateTime?>("ProcessedOnUtc")
.HasColumnType("timestamp with time zone")
.HasColumnName("processed_on_utc");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("text")
.HasColumnName("type");
b.HasKey("Id")
.HasName("pk_outbox_messages");
b.ToTable("outbox_messages", "users");
});
modelBuilder.Entity("Mccn.Modules.Users.Domain.Users.User", b => modelBuilder.Entity("Mccn.Modules.Users.Domain.Users.User", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")

View File

@@ -1,16 +1,13 @@
using Mccn.Common.Infrastructure.Outbox;
using Mccn.Modules.Users.Domain.Users; using Mccn.Modules.Users.Domain.Users;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
namespace Mccn.Modules.Users.Infrastructure.Database; namespace Mccn.Modules.Users.Infrastructure.Database;
public sealed class UsersDbContext : DbContext public sealed class UsersDbContext(DbContextOptions<UsersDbContext> options) : DbContext(options)
{ {
public UsersDbContext(DbContextOptions<UsersDbContext> options)
: base(options)
{
}
public DbSet<User> Users { get; set; } public DbSet<User> Users { get; set; }
public DbSet<OutboxMessage> OutboxMessages { get; set; }
protected override void OnModelCreating(ModelBuilder modelBuilder) protected override void OnModelCreating(ModelBuilder modelBuilder)
{ {

View File

@@ -1,26 +1,38 @@
using MassTransit;
using Mccn.Common.Infrastructure.Outbox;
using Mccn.Modules.Users.Application.Abstractions; using Mccn.Modules.Users.Application.Abstractions;
using Mccn.Modules.Users.Domain.Users; using Mccn.Modules.Users.Domain.Users;
using Mccn.Modules.Users.Infrastructure.Database; using Mccn.Modules.Users.Infrastructure.Database;
using Mccn.Modules.Users.Infrastructure.Keycloak; using Mccn.Modules.Users.Infrastructure.Keycloak;
using Mccn.Modules.Users.Infrastructure.Outbox;
using Mccn.Modules.Users.Infrastructure.Users; using Mccn.Modules.Users.Infrastructure.Users;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Quartz;
namespace Mccn.Modules.Users.Infrastructure; namespace Mccn.Modules.Users.Infrastructure;
public static class DependencyInjection public static class DependencyInjection
{ {
public static void ConfigureConsumers(IRegistrationConfigurator configurator)
{
// The Users module publishes events but does not consume any.
}
public static IServiceCollection AddUsersModule( public static IServiceCollection AddUsersModule(
this IServiceCollection services, this IServiceCollection services,
IConfiguration configuration) IConfiguration configuration)
{ {
services.AddDbContext<UsersDbContext>(options => services.AddSingleton<InsertOutboxMessagesInterceptor>();
services.AddDbContext<UsersDbContext>((sp, options) =>
{ {
options.UseNpgsql( options.UseNpgsql(
configuration.GetConnectionString("Database"), configuration.GetConnectionString("Database"),
o => o.MigrationsHistoryTable("__ef_migrations_history", Schemas.Users)) o => o.MigrationsHistoryTable("__ef_migrations_history", Schemas.Users))
.UseSnakeCaseNamingConvention(); .UseSnakeCaseNamingConvention()
.AddInterceptors(sp.GetRequiredService<InsertOutboxMessagesInterceptor>());
}); });
services.Configure<KeycloakOptions>( services.Configure<KeycloakOptions>(
@@ -40,6 +52,15 @@ public static class DependencyInjection
services.AddScoped<IUserRepository, UserRepository>(); services.AddScoped<IUserRepository, UserRepository>();
services.AddScoped<IUnitOfWork, UnitOfWork>(); services.AddScoped<IUnitOfWork, UnitOfWork>();
services.AddQuartz(q =>
{
var jobKey = new JobKey(nameof(ProcessOutboxJob));
q.AddJob<ProcessOutboxJob>(opts => opts.WithIdentity(jobKey));
q.AddTrigger(opts => opts
.ForJob(jobKey)
.WithSimpleSchedule(s => s.WithIntervalInSeconds(10).RepeatForever()));
});
return services; return services;
} }
} }

View File

@@ -0,0 +1,78 @@
using System.Reflection;
using System.Text.Json;
using Mccn.Common.Application.EventBus;
using Mccn.Common.Domain.Abstractions;
using Mccn.Common.Infrastructure.Outbox;
using Mccn.Modules.Users.Domain.Users;
using Mccn.Modules.Users.Infrastructure.Database;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Quartz;
namespace Mccn.Modules.Users.Infrastructure.Outbox;
[DisallowConcurrentExecution]
internal sealed class ProcessOutboxJob(
UsersDbContext dbContext,
IServiceScopeFactory serviceScopeFactory,
ILogger<ProcessOutboxJob> logger) : IJob
{
private static readonly Assembly DomainAssembly = typeof(User).Assembly;
public async Task Execute(IJobExecutionContext context)
{
List<OutboxMessage> messages = await dbContext.OutboxMessages
.Where(m => m.ProcessedOnUtc == null)
.OrderBy(m => m.OccurredOnUtc)
.Take(20)
.ToListAsync(context.CancellationToken);
foreach (OutboxMessage message in messages)
{
try
{
Type? eventType = DomainAssembly.GetTypes()
.FirstOrDefault(t => t.Name == message.Type);
if (eventType is null)
{
logger.LogWarning("Could not find domain event type {Type}", message.Type);
message.ProcessedOnUtc = DateTime.UtcNow;
message.Error = $"Unknown domain event type: {message.Type}";
continue;
}
IDomainEvent? domainEvent = (IDomainEvent?)JsonSerializer.Deserialize(message.Content, eventType);
if (domainEvent is null)
{
message.ProcessedOnUtc = DateTime.UtcNow;
message.Error = "Failed to deserialize domain event.";
continue;
}
using IServiceScope scope = serviceScopeFactory.CreateScope();
Type handlerInterfaceType = typeof(IDomainEventHandler<>).MakeGenericType(eventType);
IEnumerable<IDomainEventHandler> handlers = scope.ServiceProvider
.GetServices(handlerInterfaceType)
.Cast<IDomainEventHandler>();
foreach (IDomainEventHandler handler in handlers)
await handler.Handle(domainEvent, context.CancellationToken);
message.ProcessedOnUtc = DateTime.UtcNow;
}
catch (Exception ex)
{
logger.LogError(ex, "Error processing outbox message {MessageId}", message.Id);
message.Error = ex.Message;
message.ProcessedOnUtc = DateTime.UtcNow;
}
}
await dbContext.SaveChangesAsync(context.CancellationToken);
}
}

View File

@@ -0,0 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<ProjectReference Include="..\..\..\Common\Mccn.Common.Application\Mccn.Common.Application.csproj"/>
</ItemGroup>
</Project>

View File

@@ -0,0 +1,11 @@
using Mccn.Common.Application.EventBus;
namespace Mccn.Modules.Users.IntegrationEvents;
public sealed record UserRegisteredIntegrationEvent(
Guid Id,
DateTime OccurredOnUtc,
Guid UserId,
string Email,
string FirstName,
string LastName) : IntegrationEvent(Id, OccurredOnUtc);

View File

@@ -4,4 +4,23 @@ public abstract class BaseTest
{ {
protected const string UsersNamespace = "Mccn.Modules.Users"; protected const string UsersNamespace = "Mccn.Modules.Users";
protected const string HelloNamespace = "Mccn.Modules.Hello"; protected const string HelloNamespace = "Mccn.Modules.Hello";
// Implementation namespaces checked for cross-module isolation.
// *.IntegrationEvents is excluded because it is a public contract assembly
// that other modules are explicitly allowed to reference.
protected static readonly string[] UsersImplementationNamespaces =
[
"Mccn.Modules.Users.Domain",
"Mccn.Modules.Users.Application",
"Mccn.Modules.Users.Infrastructure",
"Mccn.Modules.Users.Presentation"
];
protected static readonly string[] HelloImplementationNamespaces =
[
"Mccn.Modules.Hello.Domain",
"Mccn.Modules.Hello.Application",
"Mccn.Modules.Hello.Infrastructure",
"Mccn.Modules.Hello.Presentation"
];
} }

View File

@@ -20,12 +20,17 @@ public class ModuleTests : BaseTest
typeof(DependencyInjection).Assembly typeof(DependencyInjection).Assembly
]; ];
// Check each Hello implementation namespace individually.
// Hello.IntegrationEvents would be an allowed reference (public contract), but Hello has none.
foreach (string ns in HelloImplementationNamespaces)
{
Types.InAssemblies(usersAssemblies) Types.InAssemblies(usersAssemblies)
.Should() .Should()
.NotHaveDependencyOn(HelloNamespace) .NotHaveDependencyOn(ns)
.GetResult() .GetResult()
.ShouldBeSuccessful(); .ShouldBeSuccessful();
} }
}
[Fact] [Fact]
public void HelloModule_ShouldNotHaveDependencyOn_UsersModule() public void HelloModule_ShouldNotHaveDependencyOn_UsersModule()
@@ -37,10 +42,16 @@ public class ModuleTests : BaseTest
typeof(Modules.Hello.Infrastructure.DependencyInjection).Assembly typeof(Modules.Hello.Infrastructure.DependencyInjection).Assembly
]; ];
// Check each Users implementation namespace individually.
// Users.IntegrationEvents is explicitly excluded as it is a public contract
// that other modules are permitted to reference.
foreach (string ns in UsersImplementationNamespaces)
{
Types.InAssemblies(helloAssemblies) Types.InAssemblies(helloAssemblies)
.Should() .Should()
.NotHaveDependencyOn(UsersNamespace) .NotHaveDependencyOn(ns)
.GetResult() .GetResult()
.ShouldBeSuccessful(); .ShouldBeSuccessful();
} }
}
} }