Since we have only one profile in one assembly, we leverage that class to access the assembly by passing the typeof(WebProfile).Assembly argument to the AddAutoMapper method. From there, AutoMapper scans for profiles in that assembly and finds the WebProfile class. If there were more than one, it would register all it finds.The beauty of scanning for types like this is that once you register AutoMapper with the IoC container, you can add profiles in any registered assemblies, and they get loaded automatically; there’s no need to do anything else afterward but to write useful code. Scanning assemblies also encourages composition by convention, making it easier to maintain in the long run. The downside of assembly scanning is that debugging can be hard when something is not registered because the registration process is less explicit.Now that we’ve created and registered the profiles with the IoC container, it is time to use AutoMapper. Let’s look at the three endpoints we created initially:
app.MapGet(“/products”, async (
IProductRepository productRepository,
IMapper mapper,
CancellationToken cancellationToken) =>
{
var products = await productRepository.AllAsync(cancellationToken);
return products.Select(p => mapper.Map<Product, ProductDetails>(p));
});
app.MapPost(“/products/{productId:int}/add-stocks”, async (
int productId,
AddStocksCommand command,
StockService stockService,
IMapper mapper,
CancellationToken cancellationToken) =>
{
try
{
var quantityInStock = await stockService.AddStockAsync(productId, command.Amount, cancellationToken);
var stockLevel = new StockLevel(quantityInStock);
return Results.Ok(stockLevel);
}
catch (ProductNotFoundException ex)
{
return Results.NotFound(mapper.Map<ProductNotFound>(ex));
}
});
app.MapPost(“/products/{productId:int}/remove-stocks”, async (
int productId,
RemoveStocksCommand command,
StockService stockService,
IMapper mapper,
CancellationToken cancellationToken) =>
{
try
{
var quantityInStock = await stockService.RemoveStockAsync(productId, command.Amount, cancellationToken);
var stockLevel = new StockLevel(quantityInStock);
return Results.Ok(stockLevel);
}
catch (NotEnoughStockException ex)
{
return Results.Conflict(mapper.Map<NotEnoughStock>(ex));
}
catch (ProductNotFoundException ex)
{
return Results.NotFound(mapper.Map<ProductNotFound>(ex));
}
});
The preceding code shows how similar it is to use AutoMapper to the other options. We inject an IMapper interface, then use it to map the entities. Instead of explicitly specifying both TSource and TDestination like in the previous example, when using AutoMapper, we must specify only the TDestination generic parameter, reducing the code’s complexity.
Suppose you are using AutoMapper on an IQueryable collection returned by EF Core. In that case, you should use the ProjectTo method, which limits the number of fields that EF will query to those you need. In our case, that changes nothing because we need the whole entity.
Here is an example that fetches all products from EF Core and projects them to ProductDto instances:
public IEnumerable<ProductDto> GetAllProducts()
{
return _mapper.ProjectTo<ProductDto>(_db.Products);
}
Performance-wise, this is the recommended way to use AutoMapper with EF Core.
One last yet significant detail is that we can assert whether our mapper configurations are valid when the application starts. This does not identify missing mappers but validates that the registered ones are configured correctly. The recommended way of doing this is in a unit test. To make this happen, I made the autogenerated Program class public by adding the following line at the end:
public partial class Program { }
Then I created a test project named Web.Tests that contain the following code:
namespace Web;
public class StartupTest
{
[Fact]
public async Task AutoMapper_configuration_is_valid()
{
// Arrange
await using var application = new AutoMapperAppWebApplication();
var mapper = application.Services.GetRequiredService<IMapper>();
mapper.ConfigurationProvider.AssertConfigurationIsValid();
}
}
internal class AutoMapperAppWebApplication : WebApplicationFactory<Program>{}
In the preceding code, we validate that all the AutoMapper maps are valid. To make the test fail, you can uncomment the following line of the WebProfile class:
CreateMap<NotEnoughStockException, Product>();
The AutoMapperAppWebApplication class is there to centralize the initialization of the test cases when there is more than one.In the test project, I created a second test case ensuring the products endpoint is reachable. For both tests to work together, we must change the database name to avoid seeding conflicts so each test runs on its own database. This has to do with how we seed the database in the Program.cs file, which is not something we usually do except for development or proofs of concept. Nonetheless, testing against multiple databases can come in handy to isolate tests.Here’s that second test case and updated AutoMapperAppWebApplication class to give you an idea:
public class StartupTest
{
[Fact]
public async Task The_products_endpoint_should_be_reachable()
{
await using var application = new AutoMapperAppWebApplication();
using var client = application.CreateClient();
using var response = await client.GetAsync(“/products”);
response.EnsureSuccessStatusCode();
}
// Omitted AutoMapper_configuration_is_valid method
}
internal class AutoMapperAppWebApplication : WebApplicationFactory<Program>
{
private readonly string _databaseName;
public AutoMapperAppWebApplication([CallerMemberName]string?
databaseName = default)
{
_databaseName = databaseName ??
nameof(AutoMapperAppWebApplication);
}
protected override IHost CreateHost(IHostBuilder builder)
{
builder.ConfigureServices(services =>
{
services.AddScoped(sp =>
{
return new DbContextOptionsBuilder<ProductContext>()
.UseInMemoryDatabase(_databaseName)
.UseApplicationServiceProvider(sp)
.Options;
});
});
return base.CreateHost(builder);
}
}
Running the tests ensures that the mapping in our application works and that one of the endpoints is reachable. We could add more tests, but those two cover about 50% of our code.
The CallerMemberNameAttribute used in the preceding code is part of the System.Runtime.CompilerServices namespace and allows its decorated member to access the name of the method that called it. In this case, the databaseName parameter receives the test method name.
And this closes the AutoMapper project. At this point, you should begin to be familiar with object mapping. I’d recommend you evaluate whether AutoMapper is the right tool for the job whenever a project needs object mapping. You can always load another tool or implement your own mapping logic if AutoMapper does not suit your needs. If too much mapping is done at too many levels, maybe another application architecture pattern would be better, or some rethinking is in order.AutoMapper is convention-based and does a lot on its own without any configuration from us. It is also configuration-based, caching the conversions to improve performance. We can also create type converters, value resolvers, value converters, and more. AutoMapper keeps us away from writing that boring mapping code.Yet, AutoMapper is old, feature complete, and is almost unavoidable due to the number of projects that uses it. However, it is not the fastest, which is why we are exploring Mapperly next.
Leave a Reply