diff --git a/readme.md b/readme.md index a4b5975..32cd96b 100644 --- a/readme.md +++ b/readme.md @@ -2,8 +2,6 @@ [Do you use Strongly Typed IDs to avoid Primitive Obsession](https://www.ssw.com.au/rules/do-you-use-strongly-typed-ids/) ValueObjects -Entities -DomainEvents - -Table-per-concrete-type (TPC), in which each concrete type in the .NET hierarchy is mapped to a different table in the database, where each table contains columns for all properties of the corresponding type. -https://learn.microsoft.com/en-us/ef/core/performance/modeling-for-performance#inheritance-mapping \ No newline at end of file +Entities, DomainEvents + - https://learn.microsoft.com/en-us/dotnet/architecture/microservices/microservice-ddd-cqrs-patterns/domain-events-design-implementation + - https://lostechies.com/jimmybogard/2014/05/13/a-better-domain-events-pattern/ \ No newline at end of file diff --git a/src/Simple.App/ExecutionContext.cs b/src/Simple.App/Contracts/ExecutionContext.cs similarity index 95% rename from src/Simple.App/ExecutionContext.cs rename to src/Simple.App/Contracts/ExecutionContext.cs index 7b93613..eb0dcd7 100644 --- a/src/Simple.App/ExecutionContext.cs +++ b/src/Simple.App/Contracts/ExecutionContext.cs @@ -1,7 +1,7 @@ using Simple.Domain.Tenants; using Simple.Domain.Users; -namespace Simple.App; +namespace Simple.App.Contracts; public interface IExecutionContext { diff --git a/src/Simple.App/Contracts/IDomainEventHandler.cs b/src/Simple.App/Contracts/IDomainEventHandler.cs new file mode 100644 index 0000000..cfb0f12 --- /dev/null +++ b/src/Simple.App/Contracts/IDomainEventHandler.cs @@ -0,0 +1,6 @@ +namespace Simple.App.Contracts; + +public interface IDomainEventHandler where T: IDomainEvent +{ + Task Handle(T domainEvent); +} \ No newline at end of file diff --git a/src/Simple.App/ServiceCollectionExtensions.cs b/src/Simple.App/ServiceCollectionExtensions.cs index 685f4d6..57fd143 100644 --- a/src/Simple.App/ServiceCollectionExtensions.cs +++ b/src/Simple.App/ServiceCollectionExtensions.cs @@ -1,5 +1,7 @@ using System.Reflection; using Microsoft.Extensions.DependencyInjection; +using Simple.App.Contracts; +using ExecutionContext = Simple.App.Contracts.ExecutionContext; namespace Simple.App; diff --git a/src/Simple.App/Simple.App.csproj b/src/Simple.App/Simple.App.csproj index 2394468..f296726 100644 --- a/src/Simple.App/Simple.App.csproj +++ b/src/Simple.App/Simple.App.csproj @@ -16,8 +16,4 @@ - - - - diff --git a/src/Simple.App/Surveys/Commands/CreateSurvey.cs b/src/Simple.App/Surveys/Commands/CreateSurvey.cs index 7f6e2ab..e0467dd 100644 --- a/src/Simple.App/Surveys/Commands/CreateSurvey.cs +++ b/src/Simple.App/Surveys/Commands/CreateSurvey.cs @@ -1,4 +1,5 @@ -using Simple.Domain.Surveys; +using Simple.App.Contracts; +using Simple.Domain.Surveys; using Simple.Domain.Surveys.Specifications; namespace Simple.App.Surveys.Commands; diff --git a/src/Simple.App/Tenants/DomainEvents/TenantCreatedHandler.cs b/src/Simple.App/Tenants/DomainEvents/TenantCreatedHandler.cs new file mode 100644 index 0000000..84453c7 --- /dev/null +++ b/src/Simple.App/Tenants/DomainEvents/TenantCreatedHandler.cs @@ -0,0 +1,13 @@ +using Simple.App.Contracts; +using Simple.Domain.Tenants.DomainEvents; + +namespace Simple.App.Tenants.DomainEvents; + +public class TenantCreatedHandler(ILogger log) : IDomainEventHandler +{ + public Task Handle(TenantCreatedEvent domainEvent) + { + log.LogInformation("Tenant Created: {Tenant}", domainEvent.Tenant); + return Task.CompletedTask; + } +} diff --git a/src/Simple.App/Users/Queries/ListUsers.cs b/src/Simple.App/Users/Queries/ListUsers.cs index fa6c35d..496ecaa 100644 --- a/src/Simple.App/Users/Queries/ListUsers.cs +++ b/src/Simple.App/Users/Queries/ListUsers.cs @@ -7,22 +7,23 @@ namespace Simple.App.Users.Queries; public static class ListUsers { - public record Query(Guid TenantId, int PageNumber, int PageSize) : IRequest; - - public record Results(IEnumerable Items); + public record Query(Guid TenantId, int PageNumber, int PageSize) : IRequest>; public record Result(Guid UserId, string UserName); public class Validator : AbstractValidator; - public class Handler(IReadRepository repo) : IRequestHandler + public class Handler(IReadRepository repo) : IRequestHandler> { - public async Task Handle(Query query, CancellationToken cancellationToken) + public async Task> Handle(Query query, CancellationToken cancellationToken) { var tenantId = new TenantId(query.TenantId); var page = new Page(query.PageNumber, query.PageSize); - var results = await repo.ListAsync(new UserListPaginatedSpec(tenantId, page), cancellationToken); - return new Results(results.Select(t => new Result(t.UserId, t.Name.FullName))); + var spec = new UserListPaginatedSpec(tenantId, page); + var list = await repo.ListAsync(spec, cancellationToken); + var count = await repo.CountAsync(spec, cancellationToken); + var items = list.Select(t => new Result(t.UserId, t.Name.FullName)).ToList(); + return new PaginatedResult(items, count, query.PageNumber, query.PageSize); } } } \ No newline at end of file diff --git a/src/Simple.Domain/Entity.cs b/src/Simple.Domain/Entity.cs new file mode 100644 index 0000000..6998fcc --- /dev/null +++ b/src/Simple.Domain/Entity.cs @@ -0,0 +1,25 @@ +namespace Simple.Domain; + +public interface IEntity +{ + IEnumerable DomainEvents { get; } + void AddDomainEvent(IDomainEvent eventItem); + void ClearDomainEvents(); +} + +public abstract class Entity : IEntity +{ + private readonly List _domainEvents = []; + + public IEnumerable DomainEvents => _domainEvents; + + public void AddDomainEvent(IDomainEvent eventItem) + { + _domainEvents.Add(eventItem); + } + + public void ClearDomainEvents() + { + _domainEvents.Clear(); + } +} \ No newline at end of file diff --git a/src/Simple.Domain/IAggregateRoot.cs b/src/Simple.Domain/IAggregateRoot.cs index ecef256..e16a1df 100644 --- a/src/Simple.Domain/IAggregateRoot.cs +++ b/src/Simple.Domain/IAggregateRoot.cs @@ -1,5 +1,3 @@ namespace Simple.Domain; -public interface IAggregateRoot -{ -} \ No newline at end of file +public interface IAggregateRoot; \ No newline at end of file diff --git a/src/Simple.Domain/IDomainEvent.cs b/src/Simple.Domain/IDomainEvent.cs new file mode 100644 index 0000000..f29f844 --- /dev/null +++ b/src/Simple.Domain/IDomainEvent.cs @@ -0,0 +1,8 @@ +using MediatR; + +namespace Simple.Domain; + +/// +/// https://learn.microsoft.com/en-us/dotnet/architecture/microservices/microservice-ddd-cqrs-patterns/domain-events-design-implementation +/// +public interface IDomainEvent : INotification; \ No newline at end of file diff --git a/src/Simple.Domain/Simple.Domain.csproj b/src/Simple.Domain/Simple.Domain.csproj index 81dec55..cf2545c 100644 --- a/src/Simple.Domain/Simple.Domain.csproj +++ b/src/Simple.Domain/Simple.Domain.csproj @@ -9,6 +9,7 @@ + diff --git a/src/Simple.Domain/Tenants/DomainEvents/TenantCreatedEvent.cs b/src/Simple.Domain/Tenants/DomainEvents/TenantCreatedEvent.cs new file mode 100644 index 0000000..5b25516 --- /dev/null +++ b/src/Simple.Domain/Tenants/DomainEvents/TenantCreatedEvent.cs @@ -0,0 +1,6 @@ +namespace Simple.Domain.Tenants.DomainEvents; + +public class TenantCreatedEvent(Tenant tenant) : IDomainEvent +{ + public Tenant Tenant { get; } = tenant; +} \ No newline at end of file diff --git a/src/Simple.Domain/Tenants/Tenant.cs b/src/Simple.Domain/Tenants/Tenant.cs index 71bfc86..3ea99e3 100644 --- a/src/Simple.Domain/Tenants/Tenant.cs +++ b/src/Simple.Domain/Tenants/Tenant.cs @@ -1,6 +1,8 @@ -namespace Simple.Domain.Tenants; +using Simple.Domain.Tenants.DomainEvents; -public class Tenant : IAggregateRoot +namespace Simple.Domain.Tenants; + +public class Tenant : Entity, IAggregateRoot { private Tenant() { @@ -12,6 +14,7 @@ public Tenant(TenantId id, TenantName name) TenantId = id; TenantName = name; CreatedAt = SystemTime.UtcNow(); + AddDomainEvent(new TenantCreatedEvent(this)); } public TenantId TenantId { get; private init; } = null!; diff --git a/src/Simple.Infra/Behaviours/LoggingBehaviour.cs b/src/Simple.Infra/Behaviours/LoggingBehaviour.cs index a6bb123..bbe8bb9 100644 --- a/src/Simple.Infra/Behaviours/LoggingBehaviour.cs +++ b/src/Simple.Infra/Behaviours/LoggingBehaviour.cs @@ -1,12 +1,11 @@ -using Newtonsoft.Json; +using System.Diagnostics; +using Newtonsoft.Json; namespace Simple.Infra.Behaviours; -public class LoggingBehaviour(ILogger log) : IPipelineBehavior - where TRequest : notnull - where TResponse : notnull +internal class LoggingBehaviour(ILogger> log) + : IPipelineBehavior where TRequest : notnull where TResponse : notnull { - public async Task Handle(TRequest request, RequestHandlerDelegate next, CancellationToken cancellationToken) { var name = request.GetType().FullName!.Split(".").Last(); @@ -29,17 +28,21 @@ private static bool IsQuery(string name) => private async Task HandleQuery(RequestHandlerDelegate next, string name, string body) { + var stopwatch = Stopwatch.StartNew(); log.LogInformation($"Start Query: {name} - {body}"); var result = await next(); - log.LogInformation($"End Query {name} - {body}"); + var duration = stopwatch.ElapsedMilliseconds; + log.LogDebug($"End Query {name} in {duration}ms - {body}"); return result; } private async Task HandleCommand(RequestHandlerDelegate next, string name, string body) { + var stopwatch = Stopwatch.StartNew(); log.LogInformation($"Start Command: {name} - {body}"); var response = await next(); - log.LogInformation($"End Command: {name} - {body}"); + var duration = stopwatch.ElapsedMilliseconds; + log.LogDebug($"End Command: {name} in {duration}ms - {body}"); return response; } } \ No newline at end of file diff --git a/src/Simple.Infra/Behaviours/TransactionalBehaviour.cs b/src/Simple.Infra/Behaviours/TransactionalBehaviour.cs index f3446b8..189c7b0 100644 --- a/src/Simple.Infra/Behaviours/TransactionalBehaviour.cs +++ b/src/Simple.Infra/Behaviours/TransactionalBehaviour.cs @@ -1,11 +1,10 @@ using Simple.Infra.Database; +using Simple.Infra.DomainEvents; namespace Simple.Infra.Behaviours; -public class TransactionalBehaviour(Db db, ILogger> log) - : IPipelineBehavior - where TRequest : notnull - where TResponse : notnull +internal class TransactionalBehaviour(Db db, IDomainEventDispatcher dispatcher, ILogger> log) + : IPipelineBehavior where TRequest : notnull where TResponse : notnull { public async Task Handle(TRequest request, RequestHandlerDelegate next, CancellationToken cancellationToken) { @@ -21,12 +20,13 @@ private static bool IsQuery(string name) => private async Task HandleCommand(RequestHandlerDelegate next, string name, CancellationToken cancellationToken) { - log.LogInformation($"Start Transaction: {name}"); + log.LogDebug($"Start Transaction: {name}"); await db.Database.BeginTransactionAsync(cancellationToken); var response = await next(); + await dispatcher.Publish(); await db.SaveChangesAsync(cancellationToken); await db.Database.CommitTransactionAsync(cancellationToken); - log.LogInformation($"End Transaction: {name}"); + log.LogDebug($"End Transaction: {name}"); return response; } } \ No newline at end of file diff --git a/src/Simple.Infra/Behaviours/ValidationBehaviour.cs b/src/Simple.Infra/Behaviours/ValidationBehaviour.cs index 48f1a68..0ba12fb 100644 --- a/src/Simple.Infra/Behaviours/ValidationBehaviour.cs +++ b/src/Simple.Infra/Behaviours/ValidationBehaviour.cs @@ -2,9 +2,8 @@ namespace Simple.Infra.Behaviours; -public class ValidationBehaviour(IEnumerable> validators, ILogger logs) : IPipelineBehavior - where TRequest : notnull - where TResponse : notnull +internal class ValidationBehaviour(IEnumerable> validators, ILogger> logs) + : IPipelineBehavior where TRequest : notnull where TResponse : notnull { public async Task Handle(TRequest request, RequestHandlerDelegate next, CancellationToken cancellationToken) { diff --git a/src/Simple.Infra/Database/Configuration/TenantConfiguration.cs b/src/Simple.Infra/Database/Configuration/TenantConfiguration.cs index e9158e6..079849c 100644 --- a/src/Simple.Infra/Database/Configuration/TenantConfiguration.cs +++ b/src/Simple.Infra/Database/Configuration/TenantConfiguration.cs @@ -22,5 +22,7 @@ public void Configure(EntityTypeBuilder builder) .HasMaxLength(NameMaxLength) .HasColumnName(NameColumn) .HasConversion(new TenantNameConverter()); + + builder.Ignore(x => x.DomainEvents); } } \ No newline at end of file diff --git a/src/Simple.Infra/DomainEvents/DomainEventDispatcher.cs b/src/Simple.Infra/DomainEvents/DomainEventDispatcher.cs new file mode 100644 index 0000000..80708a7 --- /dev/null +++ b/src/Simple.Infra/DomainEvents/DomainEventDispatcher.cs @@ -0,0 +1,31 @@ +using System.Collections.Immutable; +using Simple.Infra.Database; + +namespace Simple.Infra.DomainEvents; + +public interface IDomainEventDispatcher +{ + Task Publish(); +} + +internal class DomainEventDispatcher(Db db, IPublisher mediator, ILogger log) : IDomainEventDispatcher +{ + public async Task Publish() + { + var entities = db.ChangeTracker.Entries() + .Select(po => po.Entity) + .Where(po => po.DomainEvents.Any()) + .ToArray(); + + foreach (var entity in entities) + { + log.LogDebug($"Publishing domain events from {entity.GetType().Name}"); + foreach (var domainEvent in entity.DomainEvents) + { + log.LogDebug($"Publishing domain event {domainEvent.GetType().Name}"); + await mediator.Publish(domainEvent); + } + entity.ClearDomainEvents(); + } + } +} \ No newline at end of file diff --git a/src/Simple.Infra/ServiceCollectionExtensions.cs b/src/Simple.Infra/ServiceCollectionExtensions.cs index cf10b0c..12dfdb1 100644 --- a/src/Simple.Infra/ServiceCollectionExtensions.cs +++ b/src/Simple.Infra/ServiceCollectionExtensions.cs @@ -5,6 +5,7 @@ using Simple.Infra.Behaviours; using Simple.Infra.Database; using Simple.Infra.Database.Repositories; +using Simple.Infra.DomainEvents; using MigrationRunner = Simple.Infra.Database.Migrations.MigrationRunner; namespace Simple.Infra; @@ -45,6 +46,9 @@ public static IServiceCollection AddInfra(this IServiceCollection services, ICon // repositories services.AddScoped(typeof(IRepository<>), typeof(Repository<>)); services.AddScoped(typeof(IReadRepository<>), typeof(ReadRepository<>)); + + // ddd + services.AddScoped(); return services; } } \ No newline at end of file diff --git a/tests/Simple.IntegrationTests/BaseTest.cs b/tests/Simple.IntegrationTests/BaseTest.cs index c7dceee..caa387e 100644 --- a/tests/Simple.IntegrationTests/BaseTest.cs +++ b/tests/Simple.IntegrationTests/BaseTest.cs @@ -1,6 +1,7 @@ using MediatR; using Microsoft.Extensions.DependencyInjection; using Newtonsoft.Json; +using Simple.App.Contracts; using Simple.Domain.Tenants; using Simple.Domain.Users; diff --git a/tests/Simple.IntegrationTests/Fixtures/ServiceFixture.cs b/tests/Simple.IntegrationTests/Fixtures/ServiceFixture.cs index 5734277..4d4e523 100644 --- a/tests/Simple.IntegrationTests/Fixtures/ServiceFixture.cs +++ b/tests/Simple.IntegrationTests/Fixtures/ServiceFixture.cs @@ -23,7 +23,15 @@ public ServiceFixture() .AddInfra(_config) .AddLogging(builder => builder.AddXUnit(this, c => { - c.Filter = (category, level) => true; + builder.SetMinimumLevel(LogLevel.Debug); + c.Filter = (category, level) => + { + if (category.StartsWith("Simple")) + return level >= LogLevel.Information; + if (category.StartsWith("Microsoft.EntityFrameworkCore")) + return level >= LogLevel.Warning; + return level >= LogLevel.Warning; + }; })) .BuildServiceProvider(); ResetDatabase(); @@ -35,7 +43,7 @@ public void ResetDatabase() } public IServiceProvider ServiceProvider => _provider; - + public IConfiguration Configuration => _config; public void Dispose() @@ -44,5 +52,4 @@ public void Dispose() } public ITestOutputHelper? OutputHelper { get; set; } - } \ No newline at end of file diff --git a/tests/Simple.IntegrationTests/Tenants/Commands/TenantWorkflowTests.cs b/tests/Simple.IntegrationTests/Tenants/Commands/TenantWorkflowTests.cs index daa54bf..b816488 100644 --- a/tests/Simple.IntegrationTests/Tenants/Commands/TenantWorkflowTests.cs +++ b/tests/Simple.IntegrationTests/Tenants/Commands/TenantWorkflowTests.cs @@ -12,30 +12,24 @@ public async Task RegistrationTest() await Command(new Register.Command(tenant.TenantId, tenant.TenantName, user.UserId, user.FirstName, user.LastName, user.Email, user.Password)); var idResult = await Query(new GetTenantById.Query(tenant.TenantId)); - Log(idResult); idResult.TenantId.Should().Be(tenant.TenantId); idResult.TenantName.Should().Be(tenant.TenantName); var nameResult = await Query(new GetTenantByName.Query(tenant.TenantName)); - Log(nameResult); nameResult.TenantId.Should().Be(tenant.TenantId); nameResult.TenantName.Should().Be(tenant.TenantName); var emailResult = await Query(new GetUserByEmail.Query(user.Email)); - Log(emailResult); emailResult.UserId.Should().Be(user.UserId); var authResult = await Query(new CanAuthenticate.Query(user.Email, user.Password)); - Log(authResult); authResult.Success.Should().BeTrue(); authResult.UserId.Should().Be(user.UserId); var listResult = await Query(new ListTenants.Query(1, 100)); - Log(listResult); listResult.Items.Should().ContainSingle(t => t.TenantId == tenant.TenantId && t.TenantName == tenant.TenantName); var listUsersResult = await Query(new ListUsers.Query(tenant.TenantId, 1, 10)); - Log(listUsersResult); listUsersResult.Items.Should().ContainSingle(u => u.UserId == user.UserId); } } \ No newline at end of file diff --git a/tests/Simple.IntegrationTests/Users/Queries/ListUsersTest.cs b/tests/Simple.IntegrationTests/Users/Queries/ListUsersTest.cs new file mode 100644 index 0000000..12ee755 --- /dev/null +++ b/tests/Simple.IntegrationTests/Users/Queries/ListUsersTest.cs @@ -0,0 +1,36 @@ +namespace Simple.IntegrationTests.Users.Queries; + +[TestSubject(typeof(ListUsers))] +public class ListUsersTest(ServiceFixture service, ITestOutputHelper output) : BaseTest(service, output) +{ + [Fact] + public async Task Should_return_paged_list_of_users() + { + // Arrange + var tenantId = Guid.NewGuid(); + + // Act + await CreateTenant(tenantId); + + // Assert + await Assert(tenantId, 1, 10, 1, 1); + await Assert(tenantId, 2, 10, 0, 1); + } + + private async Task Assert(Guid tenantId, int pageNumber, int pageSize, int expectedItems, int expectedTotal) + { + var result1 = await Query(new ListUsers.Query(tenantId, pageNumber, pageSize)); + result1.Total.Should().Be(expectedTotal); + result1.Items.Should().HaveCount(expectedItems); + } + + private async Task CreateTenant(Guid tenantId) + { + var tenant = Fake.Tenant() with + { + TenantId = tenantId + }; + var user = Fake.User(); + await Command(new Register.Command(tenantId, tenant.TenantName, user.UserId, user.FirstName, user.LastName, user.Email, user.Password)); + } +} \ No newline at end of file diff --git a/tests/Simple.IntegrationTests/testsettings.json b/tests/Simple.IntegrationTests/testsettings.json index be8d6f3..29091fa 100644 --- a/tests/Simple.IntegrationTests/testsettings.json +++ b/tests/Simple.IntegrationTests/testsettings.json @@ -1,13 +1,3 @@ { - "Logging": { - "LogLevel": { - "Sample": "Debug", - "Default": "Debug", - "System": "Information", - "Microsoft": "Information" - } - }, - "ConnectionStrings": { - "Db": "Server=localhost;Port=5432;Database=db;User Id=postgres;Password=password;" - } + } \ No newline at end of file