Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

♻️ 86 replace automapper with manual mappers #439

Merged
merged 8 commits into from
Nov 18, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,6 @@ This is a template for creating a new project using [Clean Architecture](https:/
- 📦 ErrorOr - fluent result pattern (instead of exceptions)
- 📦 FluentValidation - for validating requests
- as per [ssw.com.au/rules/use-fluent-validation/](https://ssw.com.au/rules/use-fluent-validation/)
- 📦 AutoMapper - for mapping between objects
- 🆔 Strongly Typed IDs - to combat primitive obsession
- e.g. pass `CustomerId` type into methods instead of `int`, or `Guid`
- Entity Framework can automatically convert the int, Guid, nvarchar(..) to strongly typed ID.
Expand Down
51 changes: 51 additions & 0 deletions docs/adr/20241118-replace-automapper-with-manual-mapping.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# Replace AutoMapper with manual mapping

- Status: Approved
- Deciders: Daniel Mackay, Matt Goldman
- Date: 2024-11-18
- Tags: mappers

Technical Story: https://github.com/SSWConsulting/SSW.CleanArchitecture/issues/86

## Context and Problem Statement

We currently use AutoMapper to map the output of queries in the CA Template. While saving some work, this can also lead more complicate mappers, and runtime issues due to missing fields or mappings.

While mappers solve a problem in a certain set of cases, they can also introduce complexity and runtime issues, and are not a sensible default.

## Decision Drivers

- Reduce runtime errors
- Reduce tooling removing 'unused' properties

## Considered Options

1. AutoMapper
2. Manual Mapper

## Decision Outcome

Chosen option: "Option 2 - Manual Mapper", because it reduces the runtime errors, and makes both simple and complex mapping scenarios easier to understand.

### Consequences <!-- optional -->

- ✅ Once Automapper is removed, we can remove the mapping profiles from the code
- ✅ DTOs can now use records, making the code much simpler
- ✅ With much more concise code, we can fit everything in one file
- ✅ With everything in one file, we can remove a layer of folders

## Pros and Cons of the Options

### Option 1 - AutoMapper

- ✅ Less code to write for simple mapping scenarios
- ❌ Mapping becomes complex for complicated scenarios
- ❌ Can lead to runtime issues due to missing fields or mappings
- ❌ Need to learn a new library

### Option 2 - Manual Mapper

- ✅ Mapping becomes simple for both simple and complicated scenarios
- ✅ Reduced runtime issues due to missing fields or mappings
- ✅ No need to learn a new library
- ❌ More code needed for mapping
1 change: 0 additions & 1 deletion src/Application/Application.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="Ardalis.Specification.EntityFrameworkCore" Version="8.0.0" />
<PackageReference Include="AutoMapper.Extensions.Microsoft.DependencyInjection" Version="12.0.1" />
<PackageReference Include="ErrorOr" Version="2.0.1" />
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="11.10.0" />
<PackageReference Include="MediatR" Version="12.4.1" />
Expand Down
1 change: 0 additions & 1 deletion src/Application/DependencyInjection.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ public static IServiceCollection AddApplication(this IServiceCollection services
{
var applicationAssembly = typeof(DependencyInjection).Assembly;

services.AddAutoMapper(applicationAssembly);
services.AddValidatorsFromAssembly(applicationAssembly);

services.AddMediatR(config =>
Expand Down
3 changes: 1 addition & 2 deletions src/Application/GlobalUsings.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
global using AutoMapper;
global using FluentValidation;
global using FluentValidation;
global using MediatR;
global using Ardalis.Specification.EntityFrameworkCore;
global using ErrorOr;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,9 @@ public sealed record CreateHeroCommand(
string Alias,
IEnumerable<CreateHeroPowerDto> Powers) : IRequest<ErrorOr<Guid>>;

// ReSharper disable once UnusedType.Global
public sealed class CreateHeroCommandHandler(IApplicationDbContext dbContext)
public record CreateHeroPowerDto(string Name, int PowerLevel);

internal sealed class CreateHeroCommandHandler(IApplicationDbContext dbContext)
: IRequestHandler<CreateHeroCommand, ErrorOr<Guid>>
{
public async Task<ErrorOr<Guid>> Handle(CreateHeroCommand request, CancellationToken cancellationToken)
Expand Down

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,9 @@ public sealed record UpdateHeroCommand(
public Guid HeroId { get; set; }
}

// ReSharper disable once UnusedType.Global
public sealed class UpdateHeroCommandHandler(IApplicationDbContext dbContext)
public record UpdateHeroPowerDto(string Name, int PowerLevel);

internal sealed class UpdateHeroCommandHandler(IApplicationDbContext dbContext)
: IRequestHandler<UpdateHeroCommand, ErrorOr<Guid>>
{
public async Task<ErrorOr<Guid>> Handle(UpdateHeroCommand request, CancellationToken cancellationToken)
Expand Down

This file was deleted.

This file was deleted.

Original file line number Diff line number Diff line change
@@ -1,20 +1,27 @@
using AutoMapper.QueryableExtensions;
using SSW.CleanArchitecture.Application.Common.Interfaces;

namespace SSW.CleanArchitecture.Application.UseCases.Heroes.Queries.GetAllHeroes;

public record GetAllHeroesQuery : IRequest<IReadOnlyList<HeroDto>>;

public sealed class GetAllHeroesQueryHandler(
IMapper mapper,
IApplicationDbContext dbContext) : IRequestHandler<GetAllHeroesQuery, IReadOnlyList<HeroDto>>
public record HeroDto(Guid Id, string Name, string Alias, int PowerLevel, IEnumerable<HeroPowerDto> Powers);

public record HeroPowerDto(string Name, int PowerLevel);

internal sealed class GetAllHeroesQueryHandler(IApplicationDbContext dbContext)
: IRequestHandler<GetAllHeroesQuery, IReadOnlyList<HeroDto>>
{
public async Task<IReadOnlyList<HeroDto>> Handle(
GetAllHeroesQuery request,
CancellationToken cancellationToken)
{
return await dbContext.Heroes
.ProjectTo<HeroDto>(mapper.ConfigurationProvider)
.Select(h => new HeroDto(
h.Id.Value,
h.Name,
h.Alias,
h.PowerLevel,
h.Powers.Select(p => new HeroPowerDto(p.Name, p.PowerLevel))))
.ToListAsync(cancellationToken);
}
}
16 changes: 0 additions & 16 deletions src/Application/UseCases/Heroes/Queries/GetAllHeroes/HeroDto.cs

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,7 @@ namespace SSW.CleanArchitecture.Application.UseCases.Teams.Commands.AddHeroToTea

public sealed record AddHeroToTeamCommand(Guid TeamId, Guid HeroId) : IRequest<ErrorOr<Success>>;

// ReSharper disable once UnusedType.Global
public sealed class AddHeroToTeamCommandHandler(IApplicationDbContext dbContext)
internal sealed class AddHeroToTeamCommandHandler(IApplicationDbContext dbContext)
: IRequestHandler<AddHeroToTeamCommand, ErrorOr<Success>>
{
public async Task<ErrorOr<Success>> Handle(AddHeroToTeamCommand request, CancellationToken cancellationToken)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,7 @@ namespace SSW.CleanArchitecture.Application.UseCases.Teams.Commands.CompleteMiss

public sealed record CompleteMissionCommand(Guid TeamId) : IRequest<ErrorOr<Success>>;

// ReSharper disable once UnusedType.Global
public sealed class CompleteMissionCommandHandler(IApplicationDbContext dbContext)
internal sealed class CompleteMissionCommandHandler(IApplicationDbContext dbContext)
: IRequestHandler<CompleteMissionCommand, ErrorOr<Success>>
{
public async Task<ErrorOr<Success>> Handle(CompleteMissionCommand request, CancellationToken cancellationToken)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,7 @@ namespace SSW.CleanArchitecture.Application.UseCases.Teams.Commands.CreateTeam;

public sealed record CreateTeamCommand(string Name) : IRequest<ErrorOr<Success>>;

// ReSharper disable once UnusedType.Global
public sealed class CreateTeamCommandHandler(IApplicationDbContext dbContext)
internal sealed class CreateTeamCommandHandler(IApplicationDbContext dbContext)
: IRequestHandler<CreateTeamCommand, ErrorOr<Success>>
{
public async Task<ErrorOr<Success>> Handle(CreateTeamCommand request, CancellationToken cancellationToken)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,7 @@ public sealed record ExecuteMissionCommand(string Description) : IRequest<ErrorO
[JsonIgnore] public Guid TeamId { get; set; }
}

// ReSharper disable once UnusedType.Global
public sealed class ExecuteMissionCommandHandler(IApplicationDbContext dbContext)
internal sealed class ExecuteMissionCommandHandler(IApplicationDbContext dbContext)
: IRequestHandler<ExecuteMissionCommand, ErrorOr<Success>>
{
public async Task<ErrorOr<Success>> Handle(ExecuteMissionCommand request, CancellationToken cancellationToken)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

namespace SSW.CleanArchitecture.Application.UseCases.Teams.Events;

public class PowerLevelUpdatedEventHandler(
internal sealed class PowerLevelUpdatedEventHandler(
IApplicationDbContext dbContext,
ILogger<PowerLevelUpdatedEventHandler> logger)
: INotificationHandler<PowerLevelUpdatedEvent>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,20 +1,20 @@
using AutoMapper.QueryableExtensions;
using SSW.CleanArchitecture.Application.Common.Interfaces;

namespace SSW.CleanArchitecture.Application.UseCases.Teams.Queries.GetAllTeams;

public record GetAllTeamsQuery : IRequest<IReadOnlyList<TeamDto>>;

public sealed class GetAllTeamsQueryHandler(
IMapper mapper,
IApplicationDbContext dbContext) : IRequestHandler<GetAllTeamsQuery, IReadOnlyList<TeamDto>>
public record TeamDto(Guid Id, string Name);

internal sealed class GetAllTeamsQueryHandler(IApplicationDbContext dbContext)
: IRequestHandler<GetAllTeamsQuery, IReadOnlyList<TeamDto>>
{
public async Task<IReadOnlyList<TeamDto>> Handle(
GetAllTeamsQuery request,
CancellationToken cancellationToken)
{
return await dbContext.Teams
.ProjectTo<TeamDto>(mapper.ConfigurationProvider)
.Select(t => new TeamDto(t.Id.Value, t.Name))
.ToListAsync(cancellationToken);
}
}

This file was deleted.

7 changes: 0 additions & 7 deletions src/Application/UseCases/Teams/Queries/GetAllTeams/TeamDto.cs

This file was deleted.

30 changes: 10 additions & 20 deletions src/Application/UseCases/Teams/Queries/GetTeam/GetTeamQuery.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,12 @@ namespace SSW.CleanArchitecture.Application.UseCases.Teams.Queries.GetTeam;

public record GetTeamQuery(Guid TeamId) : IRequest<ErrorOr<TeamDto>>;

public sealed class GetAllTeamsQueryHandler(IApplicationDbContext dbContext) : IRequestHandler<GetTeamQuery, ErrorOr<TeamDto>>
public record TeamDto(Guid Id, string Name, IEnumerable<HeroDto> Heroes);

public record HeroDto(Guid Id, string Name);

internal sealed class GetAllTeamsQueryHandler(IApplicationDbContext dbContext)
: IRequestHandler<GetTeamQuery, ErrorOr<TeamDto>>
{
public async Task<ErrorOr<TeamDto>> Handle(
GetTeamQuery request,
Expand All @@ -15,30 +20,15 @@ public async Task<ErrorOr<TeamDto>> Handle(

var team = await dbContext.Teams
.Where(t => t.Id == teamId)
.Select(t => new TeamDto
{
Id = t.Id.Value,
Name = t.Name,
Heroes = t.Heroes.Select(h => new HeroDto { Id = h.Id.Value, Name = h.Name }).ToList()
})
.Select(t => new TeamDto(
t.Id.Value,
t.Name,
t.Heroes.Select(h => new HeroDto(h.Id.Value, h.Name))))
.FirstOrDefaultAsync(cancellationToken);

if (team is null)
return TeamErrors.NotFound;

return team;
}
}

public class TeamDto
{
public Guid Id { get; init; }
public required string Name { get; init; }
public List<HeroDto> Heroes { get; init; } = [];
}

public class HeroDto
{
public Guid Id { get; init; }
public required string Name { get; init; }
}
18 changes: 10 additions & 8 deletions templates/command/Commands/CommandName/CommandNameCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,19 +4,21 @@ namespace SSW.CleanArchitecture.Application.UseCases.EntityNames.Commands.Comman

public record CommandNameCommand() : IRequest<ErrorOr<Success>>;

public class CommandNameCommandHandler : IRequestHandler<CommandNameCommand, ErrorOr<Success>>
internal sealed class CommandNameCommandHandler(IApplicationDbContext dbContext)
: IRequestHandler<CommandNameCommand, ErrorOr<Success>>
{
private readonly IApplicationDbContext _dbContext;

public CommandNameCommandHandler(IApplicationDbContext dbContext)
{
_dbContext = dbContext;
}

public async Task<ErrorOr<Success>> Handle(CommandNameCommand request, CancellationToken cancellationToken)
{
// TODO: Add your business logic and persistence here

throw new NotImplementedException();
}
}

public class CommandNameCommandValidator : AbstractValidator<CommandNameCommand>
{
public CommandNameCommandValidator()
{
// TODO: Add your validation rules here. For example: RuleFor(p => p.Foo).NotEmpty()
}
}

This file was deleted.

6 changes: 0 additions & 6 deletions templates/query/Queries/QueryName/EntityNameDto.cs

This file was deleted.

Loading