Skip to content

Commit

Permalink
Refactoring CQRS structure for enhanced clarity and accessibility (#205)
Browse files Browse the repository at this point in the history
### Summary & Motivation

Previously, Commands, Queries, Handlers, and Validators were nested
within a static class named after the command or query. For instance,
the `CreateTenantCommand` static class included nested `Command`,
`Handler`, and `Validator` classes. This approach, while structured, led
to references like `CreateTenant.Command` in the API, which received
multiple remarks for its complexity and readability.

Addressing these concerns, the nested classes have now been elevated to
the top level and renamed accordingly. For example, what was previously
`CreateTenant.Command` is now `CreateTenantCommand`.

### Checklist

- [x] I have added a Label to the pull-request
- [x] I have added tests, and done manual regression tests
- [x] I have updated the documentation, if necessary
  • Loading branch information
tjementum authored Nov 12, 2023
2 parents 4e3b61b + 2800ac3 commit 035d524
Show file tree
Hide file tree
Showing 14 changed files with 211 additions and 247 deletions.
8 changes: 4 additions & 4 deletions application/account-management/Api/Tenants/TenantEndpoints.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,15 @@ public static void MapTenantEndpoints(this IEndpointRouteBuilder routes)
var group = routes.MapGroup(RoutesPrefix);

group.MapGet("/{id}", async Task<ApiResult<TenantResponseDto>> (TenantId id, ISender mediator)
=> await mediator.Send(new GetTenant.Query(id)));
=> await mediator.Send(new GetTenantQuery(id)));

group.MapPost("/", async Task<ApiResult> (CreateTenant.Command command, ISender mediator)
group.MapPost("/", async Task<ApiResult> (CreateTenantCommand command, ISender mediator)
=> (await mediator.Send(command)).AddResourceUri(RoutesPrefix));

group.MapPut("/{id}", async Task<ApiResult> (TenantId id, UpdateTenant.Command command, ISender mediator)
group.MapPut("/{id}", async Task<ApiResult> (TenantId id, UpdateTenantCommand command, ISender mediator)
=> await mediator.Send(command with {Id = id}));

group.MapDelete("/{id}", async Task<ApiResult> (TenantId id, ISender mediator)
=> await mediator.Send(new DeleteTenant.Command(id)));
=> await mediator.Send(new DeleteTenantCommand(id)));
}
}
10 changes: 5 additions & 5 deletions application/account-management/Api/Users/UserEndpoints.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,15 @@ public static void MapUserEndpoints(this IEndpointRouteBuilder routes)
var group = routes.MapGroup(RoutesPrefix);

group.MapGet("/{id}", async Task<ApiResult<UserResponseDto>> (UserId id, ISender mediator)
=> await mediator.Send(new GetUser.Query(id)));
=> await mediator.Send(new GetUserQuery(id)));

group.MapPost("/", async Task<ApiResult> (CreateUser.Command command, ISender mediator)
group.MapPost("/", async Task<ApiResult> (CreateUserCommand command, ISender mediator)
=> (await mediator.Send(command)).AddResourceUri(RoutesPrefix));

group.MapPut("/{id}", async Task<ApiResult> (UserId id, UpdateUser.Command command, ISender mediator)
=> await mediator.Send(command with {Id = id}));
group.MapPut("/{id}", async Task<ApiResult> (UserId id, UpdateUserCommand command, ISender mediator)
=> await mediator.Send(command with { Id = id }));

group.MapDelete("/{id}", async Task<ApiResult> (UserId id, ISender mediator)
=> await mediator.Send(new DeleteUser.Command(id)));
=> await mediator.Send(new DeleteUserCommand(id)));
}
}
87 changes: 42 additions & 45 deletions application/account-management/Application/Tenants/CreateTenant.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,56 +5,53 @@

namespace PlatformPlatform.AccountManagement.Application.Tenants;

public static class CreateTenant
public sealed record CreateTenantCommand(string Subdomain, string Name, string? Phone, string Email)
: ICommand, ITenantValidation, IRequest<Result<TenantId>>;

[UsedImplicitly]
public sealed class CreateTenantHandler : IRequestHandler<CreateTenantCommand, Result<TenantId>>
{
public sealed record Command(string Subdomain, string Name, string? Phone, string Email)
: ICommand, ITenantValidation, IRequest<Result<TenantId>>;
private readonly ISender _mediator;
private readonly ITenantRepository _tenantRepository;

public CreateTenantHandler(ITenantRepository tenantRepository, ISender mediator)
{
_tenantRepository = tenantRepository;
_mediator = mediator;
}

public async Task<Result<TenantId>> Handle(CreateTenantCommand command, CancellationToken cancellationToken)
{
var tenant = Tenant.Create(command.Subdomain, command.Name, command.Phone);
await _tenantRepository.AddAsync(tenant, cancellationToken);

await CreateTenantOwnerAsync(tenant.Id, command.Email, cancellationToken);
return tenant.Id;
}

[UsedImplicitly]
public sealed class Handler : IRequestHandler<Command, Result<TenantId>>
private async Task CreateTenantOwnerAsync(TenantId tenantId, string tenantOwnerEmail,
CancellationToken cancellationToken)
{
private readonly ISender _mediator;
private readonly ITenantRepository _tenantRepository;

public Handler(ITenantRepository tenantRepository, ISender mediator)
{
_tenantRepository = tenantRepository;
_mediator = mediator;
}

public async Task<Result<TenantId>> Handle(Command command, CancellationToken cancellationToken)
{
var tenant = Tenant.Create(command.Subdomain, command.Name, command.Phone);
await _tenantRepository.AddAsync(tenant, cancellationToken);

await CreateTenantOwnerAsync(tenant.Id, command.Email, cancellationToken);
return tenant.Id;
}

private async Task CreateTenantOwnerAsync(TenantId tenantId, string tenantOwnerEmail,
CancellationToken cancellationToken)
{
var createTenantOwnerUserCommand = new CreateUser.Command(tenantId, tenantOwnerEmail, UserRole.TenantOwner);
var result = await _mediator.Send(createTenantOwnerUserCommand, cancellationToken);

if (!result.IsSuccess) throw new UnreachableException($"Create Tenant Owner: {result.GetErrorSummary()}");
}
var createTenantOwnerUserCommand = new CreateUserCommand(tenantId, tenantOwnerEmail, UserRole.TenantOwner);
var result = await _mediator.Send(createTenantOwnerUserCommand, cancellationToken);

if (!result.IsSuccess) throw new UnreachableException($"Create Tenant Owner: {result.GetErrorSummary()}");
}
}

[UsedImplicitly]
public sealed class Validator : TenantValidator<Command>
[UsedImplicitly]
public sealed class CreateTenantValidator : TenantValidator<CreateTenantCommand>
{
public CreateTenantValidator(ITenantRepository tenantRepository)
{
public Validator(ITenantRepository tenantRepository)
{
RuleFor(x => x.Email).NotEmpty().SetValidator(new SharedValidations.Email());
RuleFor(x => x.Subdomain).NotEmpty();
RuleFor(x => x.Subdomain)
.Matches("^[a-z0-9]{3,30}$")
.WithMessage("Subdomain must be between 3-30 alphanumeric and lowercase characters.")
.MustAsync(async (subdomain, cancellationToken) =>
await tenantRepository.IsSubdomainFreeAsync(subdomain, cancellationToken))
.WithMessage("The subdomain is not available.")
.When(x => !string.IsNullOrEmpty(x.Subdomain));
}
RuleFor(x => x.Email).NotEmpty().SetValidator(new SharedValidations.Email());
RuleFor(x => x.Subdomain).NotEmpty();
RuleFor(x => x.Subdomain)
.Matches("^[a-z0-9]{3,30}$")
.WithMessage("Subdomain must be between 3-30 alphanumeric and lowercase characters.")
.MustAsync(async (subdomain, cancellationToken) =>
await tenantRepository.IsSubdomainFreeAsync(subdomain, cancellationToken))
.WithMessage("The subdomain is not available.")
.When(x => !string.IsNullOrEmpty(x.Subdomain));
}
}
52 changes: 23 additions & 29 deletions application/account-management/Application/Tenants/DeleteTenant.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,42 +3,36 @@

namespace PlatformPlatform.AccountManagement.Application.Tenants;

public static class DeleteTenant
public sealed record DeleteTenantCommand(TenantId Id) : ICommand, IRequest<Result>;

[UsedImplicitly]
public sealed class DeleteTenantHandler : IRequestHandler<DeleteTenantCommand, Result>
{
public sealed record Command(TenantId Id) : ICommand, IRequest<Result>;
private readonly ITenantRepository _tenantRepository;

[UsedImplicitly]
public sealed class Handler : IRequestHandler<Command, Result>
public DeleteTenantHandler(ITenantRepository tenantRepository)
{
private readonly ITenantRepository _tenantRepository;

public Handler(ITenantRepository tenantRepository)
{
_tenantRepository = tenantRepository;
}
_tenantRepository = tenantRepository;
}

public async Task<Result> Handle(Command command, CancellationToken cancellationToken)
{
var tenant = await _tenantRepository.GetByIdAsync(command.Id, cancellationToken);
if (tenant is null)
{
return Result.NotFound($"Tenant with id '{command.Id}' not found.");
}
public async Task<Result> Handle(DeleteTenantCommand command, CancellationToken cancellationToken)
{
var tenant = await _tenantRepository.GetByIdAsync(command.Id, cancellationToken);
if (tenant is null) return Result.NotFound($"Tenant with id '{command.Id}' not found.");

_tenantRepository.Remove(tenant);
return Result.Success();
}
_tenantRepository.Remove(tenant);
return Result.Success();
}
}

[UsedImplicitly]
public sealed class Validator : AbstractValidator<Command>
[UsedImplicitly]
public sealed class DeleteTenantValidator : AbstractValidator<DeleteTenantCommand>
{
public DeleteTenantValidator(IUserRepository userRepository)
{
public Validator(IUserRepository userRepository)
{
RuleFor(x => x.Id)
.MustAsync(async (tenantId, cancellationToken) =>
await userRepository.CountTenantUsersAsync(tenantId, cancellationToken) == 0)
.WithMessage("All users must be deleted before the tenant can be deleted.");
}
RuleFor(x => x.Id)
.MustAsync(async (tenantId, cancellationToken) =>
await userRepository.CountTenantUsersAsync(tenantId, cancellationToken) == 0)
.WithMessage("All users must be deleted before the tenant can be deleted.");
}
}
29 changes: 13 additions & 16 deletions application/account-management/Application/Tenants/GetTenant.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,25 +3,22 @@

namespace PlatformPlatform.AccountManagement.Application.Tenants;

public static class GetTenant
public sealed record GetTenantQuery(TenantId Id) : IRequest<Result<TenantResponseDto>>;

[UsedImplicitly]
public sealed class GetTenantHandler : IRequestHandler<GetTenantQuery, Result<TenantResponseDto>>
{
public sealed record Query(TenantId Id) : IRequest<Result<TenantResponseDto>>;
private readonly ITenantRepository _tenantRepository;

[UsedImplicitly]
public sealed class Handler : IRequestHandler<Query, Result<TenantResponseDto>>
public GetTenantHandler(ITenantRepository tenantRepository)
{
private readonly ITenantRepository _tenantRepository;

public Handler(ITenantRepository tenantRepository)
{
_tenantRepository = tenantRepository;
}
_tenantRepository = tenantRepository;
}

public async Task<Result<TenantResponseDto>> Handle(Query request, CancellationToken cancellationToken)
{
var tenant = await _tenantRepository.GetByIdAsync(request.Id, cancellationToken);
return tenant?.Adapt<TenantResponseDto>()
?? Result<TenantResponseDto>.NotFound($"Tenant with id '{request.Id}' not found.");
}
public async Task<Result<TenantResponseDto>> Handle(GetTenantQuery request, CancellationToken cancellationToken)
{
var tenant = await _tenantRepository.GetByIdAsync(request.Id, cancellationToken);
return tenant?.Adapt<TenantResponseDto>()
?? Result<TenantResponseDto>.NotFound($"Tenant with id '{request.Id}' not found.");
}
}
56 changes: 25 additions & 31 deletions application/account-management/Application/Tenants/UpdateTenant.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,44 +2,38 @@

namespace PlatformPlatform.AccountManagement.Application.Tenants;

public static class UpdateTenant
public sealed record UpdateTenantCommand : ICommand, ITenantValidation, IRequest<Result>
{
public sealed record Command : ICommand, ITenantValidation, IRequest<Result>
{
[JsonIgnore] // Removes the Id from the API contract
public TenantId Id { get; init; } = null!;
[JsonIgnore] // Removes the Id from the API contract
public TenantId Id { get; init; } = null!;

public required string Name { get; init; }
public required string Name { get; init; }

public string? Phone { get; init; }
}
public string? Phone { get; init; }
}

[UsedImplicitly]
public sealed class UpdateTenantHandler : IRequestHandler<UpdateTenantCommand, Result>
{
private readonly ITenantRepository _tenantRepository;

[UsedImplicitly]
public sealed class Handler : IRequestHandler<Command, Result>
public UpdateTenantHandler(ITenantRepository tenantRepository)
{
private readonly ITenantRepository _tenantRepository;

public Handler(ITenantRepository tenantRepository)
{
_tenantRepository = tenantRepository;
}

public async Task<Result> Handle(Command command, CancellationToken cancellationToken)
{
var tenant = await _tenantRepository.GetByIdAsync(command.Id, cancellationToken);
if (tenant is null)
{
return Result.NotFound($"Tenant with id '{command.Id}' not found.");
}

tenant.Update(command.Name, command.Phone);
_tenantRepository.Update(tenant);
return Result.Success();
}
_tenantRepository = tenantRepository;
}

[UsedImplicitly]
public sealed class Validator : TenantValidator<Command>
public async Task<Result> Handle(UpdateTenantCommand command, CancellationToken cancellationToken)
{
var tenant = await _tenantRepository.GetByIdAsync(command.Id, cancellationToken);
if (tenant is null) return Result.NotFound($"Tenant with id '{command.Id}' not found.");

tenant.Update(command.Name, command.Phone);
_tenantRepository.Update(tenant);
return Result.Success();
}
}

[UsedImplicitly]
public sealed class UpdateTenantValidator : TenantValidator<UpdateTenantCommand>
{
}
63 changes: 30 additions & 33 deletions application/account-management/Application/Users/CreateUser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,46 +3,43 @@

namespace PlatformPlatform.AccountManagement.Application.Users;

public static class CreateUser
public sealed record CreateUserCommand(TenantId TenantId, string Email, UserRole UserRole)
: ICommand, IUserValidation, IRequest<Result<UserId>>;

[UsedImplicitly]
public sealed class CreateUserHandler : IRequestHandler<CreateUserCommand, Result<UserId>>
{
public sealed record Command(TenantId TenantId, string Email, UserRole UserRole)
: ICommand, IUserValidation, IRequest<Result<UserId>>;
private readonly IUserRepository _userRepository;

[UsedImplicitly]
public sealed class Handler : IRequestHandler<Command, Result<UserId>>
public CreateUserHandler(IUserRepository userRepository)
{
private readonly IUserRepository _userRepository;

public Handler(IUserRepository userRepository)
{
_userRepository = userRepository;
}
_userRepository = userRepository;
}

public async Task<Result<UserId>> Handle(Command command, CancellationToken cancellationToken)
{
var user = User.Create(command.TenantId, command.Email, command.UserRole);
await _userRepository.AddAsync(user, cancellationToken);
return user.Id;
}
public async Task<Result<UserId>> Handle(CreateUserCommand command, CancellationToken cancellationToken)
{
var user = User.Create(command.TenantId, command.Email, command.UserRole);
await _userRepository.AddAsync(user, cancellationToken);
return user.Id;
}
}

[UsedImplicitly]
public sealed class Validator : UserValidator<Command>
[UsedImplicitly]
public sealed class CreateUserValidator : UserValidator<CreateUserCommand>
{
public CreateUserValidator(IUserRepository userRepository, ITenantRepository tenantRepository)
{
public Validator(IUserRepository userRepository, ITenantRepository tenantRepository)
{
RuleFor(x => x.TenantId)
.MustAsync(async (tenantId, cancellationToken) =>
await tenantRepository.ExistsAsync(tenantId, cancellationToken))
.WithMessage(x => $"The tenant '{x.TenantId}' does not exist.")
.When(x => !string.IsNullOrEmpty(x.Email));
RuleFor(x => x.TenantId)
.MustAsync(async (tenantId, cancellationToken) =>
await tenantRepository.ExistsAsync(tenantId, cancellationToken))
.WithMessage(x => $"The tenant '{x.TenantId}' does not exist.")
.When(x => !string.IsNullOrEmpty(x.Email));

RuleFor(x => x)
.MustAsync(async (x, cancellationToken)
=> await userRepository.IsEmailFreeAsync(x.TenantId, x.Email, cancellationToken))
.WithName("Email")
.WithMessage(x => $"The email '{x.Email}' is already in use by another user on this tenant.")
.When(x => !string.IsNullOrEmpty(x.Email));
}
RuleFor(x => x)
.MustAsync(async (x, cancellationToken)
=> await userRepository.IsEmailFreeAsync(x.TenantId, x.Email, cancellationToken))
.WithName("Email")
.WithMessage(x => $"The email '{x.Email}' is already in use by another user on this tenant.")
.When(x => !string.IsNullOrEmpty(x.Email));
}
}
Loading

0 comments on commit 035d524

Please sign in to comment.