Skip to content

Commit

Permalink
♻️ 86 replace automapper with manual mappers (#439)
Browse files Browse the repository at this point in the history
  • Loading branch information
danielmackay authored Nov 18, 2024
1 parent 5b55536 commit ae77958
Show file tree
Hide file tree
Showing 29 changed files with 108 additions and 164 deletions.
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

0 comments on commit ae77958

Please sign in to comment.