diff --git a/.files/mccn-realm-export.json b/.files/mccn-realm-export.json
index e79dc15..5b7441a 100644
--- a/.files/mccn-realm-export.json
+++ b/.files/mccn-realm-export.json
@@ -70,10 +70,10 @@
"username": "service-account-mccn-api",
"enabled": true,
"serviceAccountClientId": "mccn-api",
- "clientRoleMappings": {
+ "clientRoles": {
"realm-management": [
- { "name": "manage-users" },
- { "name": "view-users" }
+ "manage-users",
+ "view-users"
]
}
}
diff --git a/Mccn.slnx b/Mccn.slnx
index 5547c63..6e1fe3f 100644
--- a/Mccn.slnx
+++ b/Mccn.slnx
@@ -1,36 +1,33 @@
-
+
-
+
-
-
-
-
+
+
+
+
-
+
-
-
-
-
-
+
+
+
+
+
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
-
+
diff --git a/docker-compose.yml b/docker-compose.yml
index 7d4aada..cb23933 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -42,6 +42,18 @@ services:
ports:
- 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:
image: jaegertracing/all-in-one:latest
container_name: mccn.jaeger
diff --git a/src/API/Mccn.Api/Extensions/MigrationExtensions.cs b/src/API/Mccn.Api/Extensions/MigrationExtensions.cs
index 1898896..852bc09 100644
--- a/src/API/Mccn.Api/Extensions/MigrationExtensions.cs
+++ b/src/API/Mccn.Api/Extensions/MigrationExtensions.cs
@@ -1,3 +1,4 @@
+using Mccn.Modules.Hello.Infrastructure.Database;
using Mccn.Modules.Users.Infrastructure.Database;
using Microsoft.EntityFrameworkCore;
@@ -9,6 +10,7 @@ internal static class MigrationExtensions
{
using IServiceScope scope = app.Services.CreateScope();
await MigrateAsync(scope.ServiceProvider);
+ await MigrateAsync(scope.ServiceProvider);
}
private static async Task MigrateAsync(IServiceProvider serviceProvider) where TContext : DbContext
diff --git a/src/API/Mccn.Api/Mccn.Api.http b/src/API/Mccn.Api/Mccn.Api.http
index ff48036..a10a317 100644
--- a/src/API/Mccn.Api/Mccn.Api.http
+++ b/src/API/Mccn.Api/Mccn.Api.http
@@ -43,10 +43,10 @@ POST {{baseAddress}}/users/register
Content-Type: {{content_type}}
{
- "email": "user@example.com",
- "firstName": "Jane",
- "lastName": "Doe",
- "password": "Test1234!@#$anin149141"
+ "email": "andrej+test6@mccn.dev",
+ "firstName": "Andrej",
+ "lastName": "Jovanovic",
+ "password": "BestOfTheBest123"
}
### Get user profile (requires auth)
@@ -68,3 +68,8 @@ GET {{baseAddress}}/hello?name=World
### Say hello to the authenticated user (requires auth)
GET {{baseAddress}}/hello/me
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
diff --git a/src/API/Mccn.Api/Program.cs b/src/API/Mccn.Api/Program.cs
index 4f697ae..9492699 100644
--- a/src/API/Mccn.Api/Program.cs
+++ b/src/API/Mccn.Api/Program.cs
@@ -17,7 +17,10 @@ builder.Host.UseSerilog((context, config) => { config.ReadFrom.Configuration(con
builder.Services.AddEndpointsApiExplorer();
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(
typeof(RegisterUserCommand).Assembly,
diff --git a/src/Common/Mccn.Common.Application/DependencyInjection.cs b/src/Common/Mccn.Common.Application/DependencyInjection.cs
index 03e4a34..cb6b58e 100644
--- a/src/Common/Mccn.Common.Application/DependencyInjection.cs
+++ b/src/Common/Mccn.Common.Application/DependencyInjection.cs
@@ -1,6 +1,7 @@
using System.Reflection;
using FluentValidation;
using Mccn.Common.Application.Behaviors;
+using Mccn.Common.Application.EventBus;
using MediatR;
using Microsoft.Extensions.DependencyInjection;
@@ -18,6 +19,24 @@ public static class DependencyInjection
services.AddTransient(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>));
+ RegisterDomainEventHandlers(services, moduleAssemblies);
+
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);
+ }
+ }
}
\ No newline at end of file
diff --git a/src/Common/Mccn.Common.Application/EventBus/DomainEventHandler.cs b/src/Common/Mccn.Common.Application/EventBus/DomainEventHandler.cs
new file mode 100644
index 0000000..43b1751
--- /dev/null
+++ b/src/Common/Mccn.Common.Application/EventBus/DomainEventHandler.cs
@@ -0,0 +1,12 @@
+using Mccn.Common.Domain.Abstractions;
+
+namespace Mccn.Common.Application.EventBus;
+
+public abstract class DomainEventHandler : IDomainEventHandler
+ where TDomainEvent : IDomainEvent
+{
+ public abstract Task Handle(TDomainEvent domainEvent, CancellationToken cancellationToken);
+
+ Task IDomainEventHandler.Handle(IDomainEvent domainEvent, CancellationToken cancellationToken) =>
+ Handle((TDomainEvent)domainEvent, cancellationToken);
+}
diff --git a/src/Common/Mccn.Common.Application/EventBus/IDomainEventHandler.cs b/src/Common/Mccn.Common.Application/EventBus/IDomainEventHandler.cs
new file mode 100644
index 0000000..9722a56
--- /dev/null
+++ b/src/Common/Mccn.Common.Application/EventBus/IDomainEventHandler.cs
@@ -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 : IDomainEventHandler
+ where TDomainEvent : IDomainEvent
+{
+ Task Handle(TDomainEvent domainEvent, CancellationToken cancellationToken);
+}
diff --git a/src/Common/Mccn.Common.Application/EventBus/IEventBus.cs b/src/Common/Mccn.Common.Application/EventBus/IEventBus.cs
new file mode 100644
index 0000000..88b68a6
--- /dev/null
+++ b/src/Common/Mccn.Common.Application/EventBus/IEventBus.cs
@@ -0,0 +1,7 @@
+namespace Mccn.Common.Application.EventBus;
+
+public interface IEventBus
+{
+ Task PublishAsync(T integrationEvent, CancellationToken cancellationToken = default)
+ where T : IIntegrationEvent;
+}
diff --git a/src/Common/Mccn.Common.Application/EventBus/IIntegrationEvent.cs b/src/Common/Mccn.Common.Application/EventBus/IIntegrationEvent.cs
new file mode 100644
index 0000000..4018483
--- /dev/null
+++ b/src/Common/Mccn.Common.Application/EventBus/IIntegrationEvent.cs
@@ -0,0 +1,7 @@
+namespace Mccn.Common.Application.EventBus;
+
+public interface IIntegrationEvent
+{
+ Guid Id { get; }
+ DateTime OccurredOnUtc { get; }
+}
diff --git a/src/Common/Mccn.Common.Application/EventBus/IIntegrationEventHandler.cs b/src/Common/Mccn.Common.Application/EventBus/IIntegrationEventHandler.cs
new file mode 100644
index 0000000..11e7568
--- /dev/null
+++ b/src/Common/Mccn.Common.Application/EventBus/IIntegrationEventHandler.cs
@@ -0,0 +1,12 @@
+namespace Mccn.Common.Application.EventBus;
+
+public interface IIntegrationEventHandler
+{
+ Task Handle(IIntegrationEvent integrationEvent, CancellationToken cancellationToken);
+}
+
+public interface IIntegrationEventHandler : IIntegrationEventHandler
+ where TIntegrationEvent : IIntegrationEvent
+{
+ Task Handle(TIntegrationEvent integrationEvent, CancellationToken cancellationToken);
+}
diff --git a/src/Common/Mccn.Common.Application/EventBus/IntegrationEvent.cs b/src/Common/Mccn.Common.Application/EventBus/IntegrationEvent.cs
new file mode 100644
index 0000000..6f4e852
--- /dev/null
+++ b/src/Common/Mccn.Common.Application/EventBus/IntegrationEvent.cs
@@ -0,0 +1,3 @@
+namespace Mccn.Common.Application.EventBus;
+
+public abstract record IntegrationEvent(Guid Id, DateTime OccurredOnUtc) : IIntegrationEvent;
diff --git a/src/Common/Mccn.Common.Application/EventBus/IntegrationEventHandler.cs b/src/Common/Mccn.Common.Application/EventBus/IntegrationEventHandler.cs
new file mode 100644
index 0000000..38c678d
--- /dev/null
+++ b/src/Common/Mccn.Common.Application/EventBus/IntegrationEventHandler.cs
@@ -0,0 +1,10 @@
+namespace Mccn.Common.Application.EventBus;
+
+public abstract class IntegrationEventHandler : IIntegrationEventHandler
+ where TIntegrationEvent : IIntegrationEvent
+{
+ public abstract Task Handle(TIntegrationEvent integrationEvent, CancellationToken cancellationToken);
+
+ Task IIntegrationEventHandler.Handle(IIntegrationEvent integrationEvent, CancellationToken cancellationToken) =>
+ Handle((TIntegrationEvent)integrationEvent, cancellationToken);
+}
diff --git a/src/Common/Mccn.Common.Domain/Abstractions/DomainEvent.cs b/src/Common/Mccn.Common.Domain/Abstractions/DomainEvent.cs
new file mode 100644
index 0000000..ab4d046
--- /dev/null
+++ b/src/Common/Mccn.Common.Domain/Abstractions/DomainEvent.cs
@@ -0,0 +1,3 @@
+namespace Mccn.Common.Domain.Abstractions;
+
+public abstract record DomainEvent(Guid Id, DateTime OccurredOnUtc) : IDomainEvent;
diff --git a/src/Common/Mccn.Common.Domain/Abstractions/Entity.cs b/src/Common/Mccn.Common.Domain/Abstractions/Entity.cs
index bc393eb..9c73e72 100644
--- a/src/Common/Mccn.Common.Domain/Abstractions/Entity.cs
+++ b/src/Common/Mccn.Common.Domain/Abstractions/Entity.cs
@@ -2,6 +2,8 @@ namespace Mccn.Common.Domain.Abstractions;
public abstract class Entity
{
+ private readonly List _domainEvents = [];
+
protected Entity(Guid id)
{
Id = id;
@@ -12,4 +14,10 @@ public abstract class Entity
}
public Guid Id { get; init; }
+
+ public IReadOnlyList DomainEvents => _domainEvents.AsReadOnly();
+
+ protected void RaiseDomainEvent(IDomainEvent domainEvent) => _domainEvents.Add(domainEvent);
+
+ public void ClearDomainEvents() => _domainEvents.Clear();
}
\ No newline at end of file
diff --git a/src/Common/Mccn.Common.Domain/Abstractions/IDomainEvent.cs b/src/Common/Mccn.Common.Domain/Abstractions/IDomainEvent.cs
new file mode 100644
index 0000000..8fb27a4
--- /dev/null
+++ b/src/Common/Mccn.Common.Domain/Abstractions/IDomainEvent.cs
@@ -0,0 +1,7 @@
+namespace Mccn.Common.Domain.Abstractions;
+
+public interface IDomainEvent
+{
+ Guid Id { get; }
+ DateTime OccurredOnUtc { get; }
+}
diff --git a/src/Common/Mccn.Common.Infrastructure/DependencyInjection.cs b/src/Common/Mccn.Common.Infrastructure/DependencyInjection.cs
index 8144b54..492a8d3 100644
--- a/src/Common/Mccn.Common.Infrastructure/DependencyInjection.cs
+++ b/src/Common/Mccn.Common.Infrastructure/DependencyInjection.cs
@@ -1,9 +1,13 @@
+using MassTransit;
+using Mccn.Common.Application.EventBus;
using Mccn.Common.Infrastructure.Authentication;
using Mccn.Common.Infrastructure.Caching;
+using Mccn.Common.Infrastructure.EventBus;
using Mccn.Common.Infrastructure.ExceptionHandlers;
using Mccn.Common.Infrastructure.Observability;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
+using Quartz;
namespace Mccn.Common.Infrastructure;
@@ -11,7 +15,8 @@ public static class DependencyInjection
{
public static IServiceCollection AddInfrastructure(
this IServiceCollection services,
- IConfiguration configuration)
+ IConfiguration configuration,
+ params Action[] moduleConsumers)
{
services.AddJwtAuthentication(configuration);
@@ -26,6 +31,24 @@ public static class DependencyInjection
services.AddHttpContextAccessor();
+ services.AddMassTransit(configure =>
+ {
+ foreach (Action configureConsumers in moduleConsumers)
+ configureConsumers(configure);
+
+ configure.SetKebabCaseEndpointNameFormatter();
+ configure.UsingRabbitMq((context, cfg) =>
+ {
+ cfg.Host(configuration.GetConnectionString("MessageBroker"));
+ cfg.ConfigureEndpoints(context);
+ });
+ });
+
+ services.AddScoped();
+
+ services.AddQuartz();
+ services.AddQuartzHostedService(options => options.WaitForJobsToComplete = true);
+
return services;
}
}
\ No newline at end of file
diff --git a/src/Common/Mccn.Common.Infrastructure/EventBus/EventBus.cs b/src/Common/Mccn.Common.Infrastructure/EventBus/EventBus.cs
new file mode 100644
index 0000000..839fa4e
--- /dev/null
+++ b/src/Common/Mccn.Common.Infrastructure/EventBus/EventBus.cs
@@ -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 integrationEvent, CancellationToken cancellationToken = default)
+ where T : IIntegrationEvent
+ {
+ await bus.Publish(integrationEvent, cancellationToken);
+ }
+}
diff --git a/src/Common/Mccn.Common.Infrastructure/Inbox/InboxMessage.cs b/src/Common/Mccn.Common.Infrastructure/Inbox/InboxMessage.cs
new file mode 100644
index 0000000..203509b
--- /dev/null
+++ b/src/Common/Mccn.Common.Infrastructure/Inbox/InboxMessage.cs
@@ -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; }
+}
diff --git a/src/Common/Mccn.Common.Infrastructure/Inbox/InboxMessageConsumer.cs b/src/Common/Mccn.Common.Infrastructure/Inbox/InboxMessageConsumer.cs
new file mode 100644
index 0000000..d97135f
--- /dev/null
+++ b/src/Common/Mccn.Common.Infrastructure/Inbox/InboxMessageConsumer.cs
@@ -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;
+}
diff --git a/src/Common/Mccn.Common.Infrastructure/Mccn.Common.Infrastructure.csproj b/src/Common/Mccn.Common.Infrastructure/Mccn.Common.Infrastructure.csproj
index d6ec4a2..51f3d6f 100644
--- a/src/Common/Mccn.Common.Infrastructure/Mccn.Common.Infrastructure.csproj
+++ b/src/Common/Mccn.Common.Infrastructure/Mccn.Common.Infrastructure.csproj
@@ -10,12 +10,16 @@
+
+
+
+
diff --git a/src/Common/Mccn.Common.Infrastructure/Observability/ObservabilityExtensions.cs b/src/Common/Mccn.Common.Infrastructure/Observability/ObservabilityExtensions.cs
index 8225517..8a207b6 100644
--- a/src/Common/Mccn.Common.Infrastructure/Observability/ObservabilityExtensions.cs
+++ b/src/Common/Mccn.Common.Infrastructure/Observability/ObservabilityExtensions.cs
@@ -19,6 +19,7 @@ public static class ObservabilityExtensions
tracing
.AddAspNetCoreInstrumentation()
.AddHttpClientInstrumentation()
+ .AddSource("MassTransit")
.AddOtlpExporter(options =>
{
options.Endpoint = new Uri(
diff --git a/src/Common/Mccn.Common.Infrastructure/Outbox/InsertOutboxMessagesInterceptor.cs b/src/Common/Mccn.Common.Infrastructure/Outbox/InsertOutboxMessagesInterceptor.cs
new file mode 100644
index 0000000..2407a79
--- /dev/null
+++ b/src/Common/Mccn.Common.Infrastructure/Outbox/InsertOutboxMessagesInterceptor.cs
@@ -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> SavingChangesAsync(
+ DbContextEventData eventData,
+ InterceptionResult 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 outboxMessages = context.ChangeTracker
+ .Entries()
+ .Select(e => e.Entity)
+ .SelectMany(entity =>
+ {
+ IReadOnlyList 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().AddRange(outboxMessages);
+ }
+}
diff --git a/src/Common/Mccn.Common.Infrastructure/Outbox/OutboxMessage.cs b/src/Common/Mccn.Common.Infrastructure/Outbox/OutboxMessage.cs
new file mode 100644
index 0000000..2acc616
--- /dev/null
+++ b/src/Common/Mccn.Common.Infrastructure/Outbox/OutboxMessage.cs
@@ -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; }
+}
diff --git a/src/Modules/Hello/Mccn.Modules.Hello.Application/Abstractions/IUnitOfWork.cs b/src/Modules/Hello/Mccn.Modules.Hello.Application/Abstractions/IUnitOfWork.cs
new file mode 100644
index 0000000..1d84f80
--- /dev/null
+++ b/src/Modules/Hello/Mccn.Modules.Hello.Application/Abstractions/IUnitOfWork.cs
@@ -0,0 +1,6 @@
+namespace Mccn.Modules.Hello.Application.Abstractions;
+
+public interface IUnitOfWork
+{
+ Task SaveChangesAsync(CancellationToken cancellationToken = default);
+}
diff --git a/src/Modules/Hello/Mccn.Modules.Hello.Application/Mccn.Modules.Hello.Application.csproj b/src/Modules/Hello/Mccn.Modules.Hello.Application/Mccn.Modules.Hello.Application.csproj
index 28d75a0..2926f86 100644
--- a/src/Modules/Hello/Mccn.Modules.Hello.Application/Mccn.Modules.Hello.Application.csproj
+++ b/src/Modules/Hello/Mccn.Modules.Hello.Application/Mccn.Modules.Hello.Application.csproj
@@ -2,6 +2,7 @@
+
diff --git a/src/Modules/Hello/Mccn.Modules.Hello.Application/WelcomedUsers/GetWelcomedUsersQuery.cs b/src/Modules/Hello/Mccn.Modules.Hello.Application/WelcomedUsers/GetWelcomedUsersQuery.cs
new file mode 100644
index 0000000..398746a
--- /dev/null
+++ b/src/Modules/Hello/Mccn.Modules.Hello.Application/WelcomedUsers/GetWelcomedUsersQuery.cs
@@ -0,0 +1,5 @@
+using Mccn.Common.Application.Abstractions;
+
+namespace Mccn.Modules.Hello.Application.WelcomedUsers;
+
+public sealed record GetWelcomedUsersQuery : IQuery>;
diff --git a/src/Modules/Hello/Mccn.Modules.Hello.Application/WelcomedUsers/GetWelcomedUsersQueryHandler.cs b/src/Modules/Hello/Mccn.Modules.Hello.Application/WelcomedUsers/GetWelcomedUsersQueryHandler.cs
new file mode 100644
index 0000000..d16455e
--- /dev/null
+++ b/src/Modules/Hello/Mccn.Modules.Hello.Application/WelcomedUsers/GetWelcomedUsersQueryHandler.cs
@@ -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>
+{
+ public async Task>> Handle(
+ GetWelcomedUsersQuery request,
+ CancellationToken cancellationToken)
+ {
+ IReadOnlyList users = await repository.GetAllAsync(cancellationToken);
+ return Result.Success(users);
+ }
+}
diff --git a/src/Modules/Hello/Mccn.Modules.Hello.Application/WelcomedUsers/IWelcomedUserRepository.cs b/src/Modules/Hello/Mccn.Modules.Hello.Application/WelcomedUsers/IWelcomedUserRepository.cs
new file mode 100644
index 0000000..f8e7518
--- /dev/null
+++ b/src/Modules/Hello/Mccn.Modules.Hello.Application/WelcomedUsers/IWelcomedUserRepository.cs
@@ -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> GetAllAsync(CancellationToken cancellationToken = default);
+}
diff --git a/src/Modules/Hello/Mccn.Modules.Hello.Application/WelcomedUsers/UserRegisteredIntegrationEventHandler.cs b/src/Modules/Hello/Mccn.Modules.Hello.Application/WelcomedUsers/UserRegisteredIntegrationEventHandler.cs
new file mode 100644
index 0000000..c7c0f3e
--- /dev/null
+++ b/src/Modules/Hello/Mccn.Modules.Hello.Application/WelcomedUsers/UserRegisteredIntegrationEventHandler.cs
@@ -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
+{
+ 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);
+ }
+}
diff --git a/src/Modules/Hello/Mccn.Modules.Hello.Application/WelcomedUsers/WelcomedUserResponse.cs b/src/Modules/Hello/Mccn.Modules.Hello.Application/WelcomedUsers/WelcomedUserResponse.cs
new file mode 100644
index 0000000..c8cb945
--- /dev/null
+++ b/src/Modules/Hello/Mccn.Modules.Hello.Application/WelcomedUsers/WelcomedUserResponse.cs
@@ -0,0 +1,8 @@
+namespace Mccn.Modules.Hello.Application.WelcomedUsers;
+
+public sealed record WelcomedUserResponse(
+ Guid UserId,
+ string Email,
+ string FirstName,
+ string LastName,
+ DateTime WelcomedAt);
diff --git a/src/Modules/Hello/Mccn.Modules.Hello.Infrastructure/Database/Configurations/InboxMessageConfiguration.cs b/src/Modules/Hello/Mccn.Modules.Hello.Infrastructure/Database/Configurations/InboxMessageConfiguration.cs
new file mode 100644
index 0000000..af8d37e
--- /dev/null
+++ b/src/Modules/Hello/Mccn.Modules.Hello.Infrastructure/Database/Configurations/InboxMessageConfiguration.cs
@@ -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
+{
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder.ToTable("inbox_messages");
+ builder.HasKey(i => i.Id);
+ builder.Property(i => i.Type).IsRequired();
+ builder.Property(i => i.Content).IsRequired();
+ }
+}
diff --git a/src/Modules/Hello/Mccn.Modules.Hello.Infrastructure/Database/Configurations/InboxMessageConsumerConfiguration.cs b/src/Modules/Hello/Mccn.Modules.Hello.Infrastructure/Database/Configurations/InboxMessageConsumerConfiguration.cs
new file mode 100644
index 0000000..2b29ecf
--- /dev/null
+++ b/src/Modules/Hello/Mccn.Modules.Hello.Infrastructure/Database/Configurations/InboxMessageConsumerConfiguration.cs
@@ -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
+{
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder.ToTable("inbox_message_consumers");
+ builder.HasKey(c => new { c.InboxMessageId, c.Name });
+ }
+}
diff --git a/src/Modules/Hello/Mccn.Modules.Hello.Infrastructure/Database/Configurations/WelcomedUserConfiguration.cs b/src/Modules/Hello/Mccn.Modules.Hello.Infrastructure/Database/Configurations/WelcomedUserConfiguration.cs
new file mode 100644
index 0000000..58a8617
--- /dev/null
+++ b/src/Modules/Hello/Mccn.Modules.Hello.Infrastructure/Database/Configurations/WelcomedUserConfiguration.cs
@@ -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
+{
+ public void Configure(EntityTypeBuilder 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();
+ }
+}
diff --git a/src/Modules/Hello/Mccn.Modules.Hello.Infrastructure/Database/HelloDbContext.cs b/src/Modules/Hello/Mccn.Modules.Hello.Infrastructure/Database/HelloDbContext.cs
new file mode 100644
index 0000000..15aac48
--- /dev/null
+++ b/src/Modules/Hello/Mccn.Modules.Hello.Infrastructure/Database/HelloDbContext.cs
@@ -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 options)
+ : base(options)
+ {
+ }
+
+ public DbSet WelcomedUsers { get; set; }
+ public DbSet InboxMessages { get; set; }
+ public DbSet InboxMessageConsumers { get; set; }
+
+ protected override void OnModelCreating(ModelBuilder modelBuilder)
+ {
+ modelBuilder.HasDefaultSchema(Schemas.Hello);
+ modelBuilder.ApplyConfigurationsFromAssembly(typeof(HelloDbContext).Assembly);
+ }
+}
diff --git a/src/Modules/Hello/Mccn.Modules.Hello.Infrastructure/Database/Models/WelcomedUser.cs b/src/Modules/Hello/Mccn.Modules.Hello.Infrastructure/Database/Models/WelcomedUser.cs
new file mode 100644
index 0000000..464ea72
--- /dev/null
+++ b/src/Modules/Hello/Mccn.Modules.Hello.Infrastructure/Database/Models/WelcomedUser.cs
@@ -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; }
+}
diff --git a/src/Modules/Hello/Mccn.Modules.Hello.Infrastructure/Database/Schemas.cs b/src/Modules/Hello/Mccn.Modules.Hello.Infrastructure/Database/Schemas.cs
new file mode 100644
index 0000000..b4fde24
--- /dev/null
+++ b/src/Modules/Hello/Mccn.Modules.Hello.Infrastructure/Database/Schemas.cs
@@ -0,0 +1,6 @@
+namespace Mccn.Modules.Hello.Infrastructure.Database;
+
+internal static class Schemas
+{
+ internal const string Hello = "hello";
+}
diff --git a/src/Modules/Hello/Mccn.Modules.Hello.Infrastructure/DependencyInjection.cs b/src/Modules/Hello/Mccn.Modules.Hello.Infrastructure/DependencyInjection.cs
index 60833ce..a31597d 100644
--- a/src/Modules/Hello/Mccn.Modules.Hello.Infrastructure/DependencyInjection.cs
+++ b/src/Modules/Hello/Mccn.Modules.Hello.Infrastructure/DependencyInjection.cs
@@ -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.DependencyInjection;
+using Quartz;
namespace Mccn.Modules.Hello.Infrastructure;
public static class DependencyInjection
{
+ public static void ConfigureConsumers(IRegistrationConfigurator configurator)
+ {
+ configurator.AddConsumer>();
+ }
+
public static IServiceCollection AddHelloModule(
this IServiceCollection services,
IConfiguration configuration)
{
- // Hello module has no database/infrastructure dependencies.
- // This is where you'd add them when the module grows.
+ services.AddDbContext(options =>
+ {
+ options.UseNpgsql(
+ configuration.GetConnectionString("Database"),
+ o => o.MigrationsHistoryTable("__ef_migrations_history", Schemas.Hello))
+ .UseSnakeCaseNamingConvention();
+ });
+
+ services.AddScoped();
+ services.AddScoped();
+
+ // Register integration event handlers so ProcessInboxJob can resolve them.
+ services.AddScoped<
+ IIntegrationEventHandler,
+ UserRegisteredIntegrationEventHandler>();
+
+ services.AddQuartz(q =>
+ {
+ var jobKey = new JobKey(nameof(ProcessInboxJob));
+ q.AddJob(opts => opts.WithIdentity(jobKey));
+ q.AddTrigger(opts => opts
+ .ForJob(jobKey)
+ .WithSimpleSchedule(s => s.WithIntervalInSeconds(10).RepeatForever()));
+ });
+
return services;
}
}
\ No newline at end of file
diff --git a/src/Modules/Hello/Mccn.Modules.Hello.Infrastructure/Inbox/IntegrationEventConsumer.cs b/src/Modules/Hello/Mccn.Modules.Hello.Infrastructure/Inbox/IntegrationEventConsumer.cs
new file mode 100644
index 0000000..5c45364
--- /dev/null
+++ b/src/Modules/Hello/Mccn.Modules.Hello.Infrastructure/Inbox/IntegrationEventConsumer.cs
@@ -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(HelloDbContext dbContext)
+ : IConsumer
+ where TIntegrationEvent : class, IIntegrationEvent
+{
+ public async Task Consume(ConsumeContext 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);
+ }
+}
diff --git a/src/Modules/Hello/Mccn.Modules.Hello.Infrastructure/Inbox/ProcessInboxJob.cs b/src/Modules/Hello/Mccn.Modules.Hello.Infrastructure/Inbox/ProcessInboxJob.cs
new file mode 100644
index 0000000..fa96387
--- /dev/null
+++ b/src/Modules/Hello/Mccn.Modules.Hello.Infrastructure/Inbox/ProcessInboxJob.cs
@@ -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 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 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 handlers = scope.ServiceProvider
+ .GetServices(handlerInterfaceType)
+ .Cast();
+
+ 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);
+ }
+}
diff --git a/src/Modules/Hello/Mccn.Modules.Hello.Infrastructure/Mccn.Modules.Hello.Infrastructure.csproj b/src/Modules/Hello/Mccn.Modules.Hello.Infrastructure/Mccn.Modules.Hello.Infrastructure.csproj
index 8edd226..053687b 100644
--- a/src/Modules/Hello/Mccn.Modules.Hello.Infrastructure/Mccn.Modules.Hello.Infrastructure.csproj
+++ b/src/Modules/Hello/Mccn.Modules.Hello.Infrastructure/Mccn.Modules.Hello.Infrastructure.csproj
@@ -5,6 +5,12 @@
+
+
+
+
+
+
net10.0
enable
diff --git a/src/Modules/Hello/Mccn.Modules.Hello.Infrastructure/Migrations/20260315153129_InitialCreate.Designer.cs b/src/Modules/Hello/Mccn.Modules.Hello.Infrastructure/Migrations/20260315153129_InitialCreate.Designer.cs
new file mode 100644
index 0000000..fc7a701
--- /dev/null
+++ b/src/Modules/Hello/Mccn.Modules.Hello.Infrastructure/Migrations/20260315153129_InitialCreate.Designer.cs
@@ -0,0 +1,118 @@
+//
+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
+ {
+ ///
+ 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("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("Content")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("content");
+
+ b.Property("Error")
+ .HasColumnType("text")
+ .HasColumnName("error");
+
+ b.Property("OccurredOnUtc")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("occurred_on_utc");
+
+ b.Property("ProcessedOnUtc")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("processed_on_utc");
+
+ b.Property("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("InboxMessageId")
+ .HasColumnType("uuid")
+ .HasColumnName("inbox_message_id");
+
+ b.Property("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("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("Email")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("email");
+
+ b.Property("FirstName")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("first_name");
+
+ b.Property("LastName")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("last_name");
+
+ b.Property("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.Property("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
+ }
+ }
+}
diff --git a/src/Modules/Hello/Mccn.Modules.Hello.Infrastructure/Migrations/20260315153129_InitialCreate.cs b/src/Modules/Hello/Mccn.Modules.Hello.Infrastructure/Migrations/20260315153129_InitialCreate.cs
new file mode 100644
index 0000000..b3cc652
--- /dev/null
+++ b/src/Modules/Hello/Mccn.Modules.Hello.Infrastructure/Migrations/20260315153129_InitialCreate.cs
@@ -0,0 +1,81 @@
+using System;
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace Mccn.Modules.Hello.Infrastructure.Migrations
+{
+ ///
+ public partial class InitialCreate : Migration
+ {
+ ///
+ 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(type: "uuid", nullable: false),
+ name = table.Column(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(type: "uuid", nullable: false),
+ type = table.Column(type: "text", nullable: false),
+ content = table.Column(type: "text", nullable: false),
+ occurred_on_utc = table.Column(type: "timestamp with time zone", nullable: false),
+ processed_on_utc = table.Column(type: "timestamp with time zone", nullable: true),
+ error = table.Column(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(type: "uuid", nullable: false),
+ user_id = table.Column(type: "uuid", nullable: false),
+ email = table.Column(type: "text", nullable: false),
+ first_name = table.Column(type: "text", nullable: false),
+ last_name = table.Column(type: "text", nullable: false),
+ welcomed_at = table.Column(type: "timestamp with time zone", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("pk_welcomed_users", x => x.id);
+ });
+ }
+
+ ///
+ 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");
+ }
+ }
+}
diff --git a/src/Modules/Hello/Mccn.Modules.Hello.Infrastructure/Migrations/HelloDbContextModelSnapshot.cs b/src/Modules/Hello/Mccn.Modules.Hello.Infrastructure/Migrations/HelloDbContextModelSnapshot.cs
new file mode 100644
index 0000000..8cc4e5f
--- /dev/null
+++ b/src/Modules/Hello/Mccn.Modules.Hello.Infrastructure/Migrations/HelloDbContextModelSnapshot.cs
@@ -0,0 +1,115 @@
+//
+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("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("Content")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("content");
+
+ b.Property("Error")
+ .HasColumnType("text")
+ .HasColumnName("error");
+
+ b.Property("OccurredOnUtc")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("occurred_on_utc");
+
+ b.Property("ProcessedOnUtc")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("processed_on_utc");
+
+ b.Property("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("InboxMessageId")
+ .HasColumnType("uuid")
+ .HasColumnName("inbox_message_id");
+
+ b.Property("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("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("Email")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("email");
+
+ b.Property("FirstName")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("first_name");
+
+ b.Property("LastName")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("last_name");
+
+ b.Property("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.Property("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
+ }
+ }
+}
diff --git a/src/Modules/Hello/Mccn.Modules.Hello.Infrastructure/WelcomedUsers/UnitOfWork.cs b/src/Modules/Hello/Mccn.Modules.Hello.Infrastructure/WelcomedUsers/UnitOfWork.cs
new file mode 100644
index 0000000..92d745b
--- /dev/null
+++ b/src/Modules/Hello/Mccn.Modules.Hello.Infrastructure/WelcomedUsers/UnitOfWork.cs
@@ -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);
+}
diff --git a/src/Modules/Hello/Mccn.Modules.Hello.Infrastructure/WelcomedUsers/WelcomedUserRepository.cs b/src/Modules/Hello/Mccn.Modules.Hello.Infrastructure/WelcomedUsers/WelcomedUserRepository.cs
new file mode 100644
index 0000000..08cc868
--- /dev/null
+++ b/src/Modules/Hello/Mccn.Modules.Hello.Infrastructure/WelcomedUsers/WelcomedUserRepository.cs
@@ -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> 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);
+ }
+}
diff --git a/src/Modules/Hello/Mccn.Modules.Hello.Presentation/Hello/GetWelcomedUsers.cs b/src/Modules/Hello/Mccn.Modules.Hello.Presentation/Hello/GetWelcomedUsers.cs
new file mode 100644
index 0000000..cbbb839
--- /dev/null
+++ b/src/Modules/Hello/Mccn.Modules.Hello.Presentation/Hello/GetWelcomedUsers.cs
@@ -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> result =
+ await sender.Send(new GetWelcomedUsersQuery());
+
+ return Results.Ok(result.Value);
+ })
+ .WithTags("Hello")
+ .AllowAnonymous();
+ }
+}
diff --git a/src/Modules/Users/Mccn.Modules.Users.Application/Mccn.Modules.Users.Application.csproj b/src/Modules/Users/Mccn.Modules.Users.Application/Mccn.Modules.Users.Application.csproj
index 6cefc33..0200c41 100644
--- a/src/Modules/Users/Mccn.Modules.Users.Application/Mccn.Modules.Users.Application.csproj
+++ b/src/Modules/Users/Mccn.Modules.Users.Application/Mccn.Modules.Users.Application.csproj
@@ -2,6 +2,7 @@
+
diff --git a/src/Modules/Users/Mccn.Modules.Users.Application/Users/Register/UserRegisteredDomainEventHandler.cs b/src/Modules/Users/Mccn.Modules.Users.Application/Users/Register/UserRegisteredDomainEventHandler.cs
new file mode 100644
index 0000000..7097884
--- /dev/null
+++ b/src/Modules/Users/Mccn.Modules.Users.Application/Users/Register/UserRegisteredDomainEventHandler.cs
@@ -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
+{
+ 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);
+ }
+}
diff --git a/src/Modules/Users/Mccn.Modules.Users.Domain/Users/User.cs b/src/Modules/Users/Mccn.Modules.Users.Domain/Users/User.cs
index 2210280..f4af6b7 100644
--- a/src/Modules/Users/Mccn.Modules.Users.Domain/Users/User.cs
+++ b/src/Modules/Users/Mccn.Modules.Users.Domain/Users/User.cs
@@ -20,7 +20,7 @@ public sealed class User : Entity
string lastName,
string identityId)
{
- return new User
+ var user = new User
{
Id = id,
Email = email,
@@ -28,5 +28,15 @@ public sealed class User : Entity
LastName = lastName,
IdentityId = identityId
};
+
+ user.RaiseDomainEvent(new UserRegisteredDomainEvent(
+ Guid.NewGuid(),
+ DateTime.UtcNow,
+ user.Id,
+ user.Email,
+ user.FirstName,
+ user.LastName));
+
+ return user;
}
}
\ No newline at end of file
diff --git a/src/Modules/Users/Mccn.Modules.Users.Domain/Users/UserRegisteredDomainEvent.cs b/src/Modules/Users/Mccn.Modules.Users.Domain/Users/UserRegisteredDomainEvent.cs
new file mode 100644
index 0000000..4162ebf
--- /dev/null
+++ b/src/Modules/Users/Mccn.Modules.Users.Domain/Users/UserRegisteredDomainEvent.cs
@@ -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);
diff --git a/src/Modules/Users/Mccn.Modules.Users.Infrastructure/Database/Configurations/OutboxMessageConfiguration.cs b/src/Modules/Users/Mccn.Modules.Users.Infrastructure/Database/Configurations/OutboxMessageConfiguration.cs
new file mode 100644
index 0000000..b0e8685
--- /dev/null
+++ b/src/Modules/Users/Mccn.Modules.Users.Infrastructure/Database/Configurations/OutboxMessageConfiguration.cs
@@ -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
+{
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder.ToTable("outbox_messages");
+ builder.HasKey(o => o.Id);
+ builder.Property(o => o.Type).IsRequired();
+ builder.Property(o => o.Content).IsRequired();
+ }
+}
diff --git a/src/Modules/Users/Mccn.Modules.Users.Infrastructure/Database/Migrations/20260315153115_AddOutboxMessages.Designer.cs b/src/Modules/Users/Mccn.Modules.Users.Infrastructure/Database/Migrations/20260315153115_AddOutboxMessages.Designer.cs
new file mode 100644
index 0000000..5914678
--- /dev/null
+++ b/src/Modules/Users/Mccn.Modules.Users.Infrastructure/Database/Migrations/20260315153115_AddOutboxMessages.Designer.cs
@@ -0,0 +1,111 @@
+//
+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
+ {
+ ///
+ 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("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("Content")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("content");
+
+ b.Property("Error")
+ .HasColumnType("text")
+ .HasColumnName("error");
+
+ b.Property("OccurredOnUtc")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("occurred_on_utc");
+
+ b.Property("ProcessedOnUtc")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("processed_on_utc");
+
+ b.Property("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("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("Email")
+ .IsRequired()
+ .HasMaxLength(300)
+ .HasColumnType("character varying(300)")
+ .HasColumnName("email");
+
+ b.Property("FirstName")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)")
+ .HasColumnName("first_name");
+
+ b.Property("IdentityId")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)")
+ .HasColumnName("identity_id");
+
+ b.Property("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
+ }
+ }
+}
diff --git a/src/Modules/Users/Mccn.Modules.Users.Infrastructure/Database/Migrations/20260315153115_AddOutboxMessages.cs b/src/Modules/Users/Mccn.Modules.Users.Infrastructure/Database/Migrations/20260315153115_AddOutboxMessages.cs
new file mode 100644
index 0000000..525c162
--- /dev/null
+++ b/src/Modules/Users/Mccn.Modules.Users.Infrastructure/Database/Migrations/20260315153115_AddOutboxMessages.cs
@@ -0,0 +1,40 @@
+using System;
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace Mccn.Modules.Users.Infrastructure.Database.Migrations
+{
+ ///
+ public partial class AddOutboxMessages : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.CreateTable(
+ name: "outbox_messages",
+ schema: "users",
+ columns: table => new
+ {
+ id = table.Column(type: "uuid", nullable: false),
+ type = table.Column(type: "text", nullable: false),
+ content = table.Column(type: "text", nullable: false),
+ occurred_on_utc = table.Column(type: "timestamp with time zone", nullable: false),
+ processed_on_utc = table.Column(type: "timestamp with time zone", nullable: true),
+ error = table.Column(type: "text", nullable: true)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("pk_outbox_messages", x => x.id);
+ });
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropTable(
+ name: "outbox_messages",
+ schema: "users");
+ }
+ }
+}
diff --git a/src/Modules/Users/Mccn.Modules.Users.Infrastructure/Database/Migrations/UsersDbContextModelSnapshot.cs b/src/Modules/Users/Mccn.Modules.Users.Infrastructure/Database/Migrations/UsersDbContextModelSnapshot.cs
index 22ac259..a3cf7b7 100644
--- a/src/Modules/Users/Mccn.Modules.Users.Infrastructure/Database/Migrations/UsersDbContextModelSnapshot.cs
+++ b/src/Modules/Users/Mccn.Modules.Users.Infrastructure/Database/Migrations/UsersDbContextModelSnapshot.cs
@@ -18,11 +18,46 @@ namespace Mccn.Modules.Users.Infrastructure.Database.Migrations
#pragma warning disable 612, 618
modelBuilder
.HasDefaultSchema("users")
- .HasAnnotation("ProductVersion", "10.0.5")
+ .HasAnnotation("ProductVersion", "10.0.4")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+ modelBuilder.Entity("Mccn.Common.Infrastructure.Outbox.OutboxMessage", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("Content")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("content");
+
+ b.Property("Error")
+ .HasColumnType("text")
+ .HasColumnName("error");
+
+ b.Property("OccurredOnUtc")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("occurred_on_utc");
+
+ b.Property("ProcessedOnUtc")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("processed_on_utc");
+
+ b.Property("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("Id")
diff --git a/src/Modules/Users/Mccn.Modules.Users.Infrastructure/Database/UsersDbContext.cs b/src/Modules/Users/Mccn.Modules.Users.Infrastructure/Database/UsersDbContext.cs
index a298e89..2b86e46 100644
--- a/src/Modules/Users/Mccn.Modules.Users.Infrastructure/Database/UsersDbContext.cs
+++ b/src/Modules/Users/Mccn.Modules.Users.Infrastructure/Database/UsersDbContext.cs
@@ -1,16 +1,13 @@
+using Mccn.Common.Infrastructure.Outbox;
using Mccn.Modules.Users.Domain.Users;
using Microsoft.EntityFrameworkCore;
namespace Mccn.Modules.Users.Infrastructure.Database;
-public sealed class UsersDbContext : DbContext
+public sealed class UsersDbContext(DbContextOptions options) : DbContext(options)
{
- public UsersDbContext(DbContextOptions options)
- : base(options)
- {
- }
-
public DbSet Users { get; set; }
+ public DbSet OutboxMessages { get; set; }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
diff --git a/src/Modules/Users/Mccn.Modules.Users.Infrastructure/DependencyInjection.cs b/src/Modules/Users/Mccn.Modules.Users.Infrastructure/DependencyInjection.cs
index ab44d8d..1a274b1 100644
--- a/src/Modules/Users/Mccn.Modules.Users.Infrastructure/DependencyInjection.cs
+++ b/src/Modules/Users/Mccn.Modules.Users.Infrastructure/DependencyInjection.cs
@@ -1,26 +1,38 @@
+using MassTransit;
+using Mccn.Common.Infrastructure.Outbox;
using Mccn.Modules.Users.Application.Abstractions;
using Mccn.Modules.Users.Domain.Users;
using Mccn.Modules.Users.Infrastructure.Database;
using Mccn.Modules.Users.Infrastructure.Keycloak;
+using Mccn.Modules.Users.Infrastructure.Outbox;
using Mccn.Modules.Users.Infrastructure.Users;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
+using Quartz;
namespace Mccn.Modules.Users.Infrastructure;
public static class DependencyInjection
{
+ public static void ConfigureConsumers(IRegistrationConfigurator configurator)
+ {
+ // The Users module publishes events but does not consume any.
+ }
+
public static IServiceCollection AddUsersModule(
this IServiceCollection services,
IConfiguration configuration)
{
- services.AddDbContext(options =>
+ services.AddSingleton();
+
+ services.AddDbContext((sp, options) =>
{
options.UseNpgsql(
configuration.GetConnectionString("Database"),
o => o.MigrationsHistoryTable("__ef_migrations_history", Schemas.Users))
- .UseSnakeCaseNamingConvention();
+ .UseSnakeCaseNamingConvention()
+ .AddInterceptors(sp.GetRequiredService());
});
services.Configure(
@@ -40,6 +52,15 @@ public static class DependencyInjection
services.AddScoped();
services.AddScoped();
+ services.AddQuartz(q =>
+ {
+ var jobKey = new JobKey(nameof(ProcessOutboxJob));
+ q.AddJob(opts => opts.WithIdentity(jobKey));
+ q.AddTrigger(opts => opts
+ .ForJob(jobKey)
+ .WithSimpleSchedule(s => s.WithIntervalInSeconds(10).RepeatForever()));
+ });
+
return services;
}
}
\ No newline at end of file
diff --git a/src/Modules/Users/Mccn.Modules.Users.Infrastructure/Outbox/ProcessOutboxJob.cs b/src/Modules/Users/Mccn.Modules.Users.Infrastructure/Outbox/ProcessOutboxJob.cs
new file mode 100644
index 0000000..3a82824
--- /dev/null
+++ b/src/Modules/Users/Mccn.Modules.Users.Infrastructure/Outbox/ProcessOutboxJob.cs
@@ -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 logger) : IJob
+{
+ private static readonly Assembly DomainAssembly = typeof(User).Assembly;
+
+ public async Task Execute(IJobExecutionContext context)
+ {
+ List 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 handlers = scope.ServiceProvider
+ .GetServices(handlerInterfaceType)
+ .Cast();
+
+ 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);
+ }
+}
diff --git a/src/Modules/Users/Mccn.Modules.Users.IntegrationEvents/Mccn.Modules.Users.IntegrationEvents.csproj b/src/Modules/Users/Mccn.Modules.Users.IntegrationEvents/Mccn.Modules.Users.IntegrationEvents.csproj
new file mode 100644
index 0000000..a322519
--- /dev/null
+++ b/src/Modules/Users/Mccn.Modules.Users.IntegrationEvents/Mccn.Modules.Users.IntegrationEvents.csproj
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
diff --git a/src/Modules/Users/Mccn.Modules.Users.IntegrationEvents/UserRegisteredIntegrationEvent.cs b/src/Modules/Users/Mccn.Modules.Users.IntegrationEvents/UserRegisteredIntegrationEvent.cs
new file mode 100644
index 0000000..2ecc470
--- /dev/null
+++ b/src/Modules/Users/Mccn.Modules.Users.IntegrationEvents/UserRegisteredIntegrationEvent.cs
@@ -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);
diff --git a/test/Mccn.ArchitectureTests/Abstractions/BaseTest.cs b/test/Mccn.ArchitectureTests/Abstractions/BaseTest.cs
index 8997951..a0976d5 100644
--- a/test/Mccn.ArchitectureTests/Abstractions/BaseTest.cs
+++ b/test/Mccn.ArchitectureTests/Abstractions/BaseTest.cs
@@ -4,4 +4,23 @@ public abstract class BaseTest
{
protected const string UsersNamespace = "Mccn.Modules.Users";
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"
+ ];
}
\ No newline at end of file
diff --git a/test/Mccn.ArchitectureTests/Layers/ModuleTests.cs b/test/Mccn.ArchitectureTests/Layers/ModuleTests.cs
index 281bb1f..b02f6c8 100644
--- a/test/Mccn.ArchitectureTests/Layers/ModuleTests.cs
+++ b/test/Mccn.ArchitectureTests/Layers/ModuleTests.cs
@@ -20,11 +20,16 @@ public class ModuleTests : BaseTest
typeof(DependencyInjection).Assembly
];
- Types.InAssemblies(usersAssemblies)
- .Should()
- .NotHaveDependencyOn(HelloNamespace)
- .GetResult()
- .ShouldBeSuccessful();
+ // 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)
+ .Should()
+ .NotHaveDependencyOn(ns)
+ .GetResult()
+ .ShouldBeSuccessful();
+ }
}
[Fact]
@@ -37,10 +42,16 @@ public class ModuleTests : BaseTest
typeof(Modules.Hello.Infrastructure.DependencyInjection).Assembly
];
- Types.InAssemblies(helloAssemblies)
- .Should()
- .NotHaveDependencyOn(UsersNamespace)
- .GetResult()
- .ShouldBeSuccessful();
+ // 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)
+ .Should()
+ .NotHaveDependencyOn(ns)
+ .GetResult()
+ .ShouldBeSuccessful();
+ }
}
}
\ No newline at end of file