> ## Content Index
> Fetch the complete content index at: https://www.sfeir.dev/llms.txt
> Use this file to discover other available public pages before exploring further.

# Simplifier la transformation d'objets avec AutoMapper
- URL: https://www.sfeir.dev/back/transformer-ses-donnees/
- Published: 2023-10-04T10:39:51.000Z
- Updated: 2023-10-13T15:15:53.000Z
- Description: Présentation et cas pratique d'utilisation de la bibliothèque .NET de référence pour le mapping d'objet.
- Author: Gaëlle Chalot
- Tags: Back, .NET

La conception du modèle de données d'une application requiert souvent une séparation des objets en deux types distincts : les **DAO** (Data Access Object), qui incarnent les données brutes, et les **DTO** (Data Transfer Object), également appelés ViewModel dans le cadre du modèle **MVC** (Modèle-Vue-Contrôleur). Ces derniers définissent la structure des données à échanger entre les différentes couches de l'application. La couche de service, chargée de la logique métier, est responsable de la transformation de ces objets DAO en DTO. Grâce à la bibliothèque **AutoMapper**, cette tâche potentiellement laborieuse peut être entièrement automatisée.

## Mise en place du projet

Supposons que l'objectif soit de créer une API pour la gestion de blogs. Trois entités principales seraient nécessaires : les **blogs**, les **auteurs** et les **articles** (ou "posts"). Le projet serait conçu en utilisant le framework **ASP.NET Core** et l**'ORM** (Object-Relational Mapping) **Entity Framework Core** pour l'accès à la base de données.

### Réalisation du modèle de données

Nous commencerons par définir nos DAO.

```cs
public abstract class Entity
{
    public Guid Id { get; init; }
}

public class Blog : Entity
{
    public string Title { get; set; } = string.Empty;

    public string Description { get; set; } = string.Empty;

    public Author? Author { get; set; }

    public ICollection<Post> Posts { get; set; } = new List<Post>();
}

public class Author : Entity
{
    public string Name { get; set; } = string.Empty;

    public string Description { get; set; } = string.Empty;

    public ICollection<Blog> Blogs { get; set; } = new List<Blog>();
}

public class Post : Entity
{
    public string Title { get; set; } = string.Empty;

    public string Content { get; set; } = string.Empty;

    public DateTimeOffset CreatedAt { get; set; }

    public Blog? Blog { get; set; }
}
```

Classes que l'on utilisera dans le contexte Entity Framework

Notre API proposera une route permettant de récupérer une liste d'informations sur tous les blogs : leur nom, leur description, le nom de leur auteur, ainsi que la date de leur dernière activité, qui correspondra à la date de publication du dernier article.

Nous formulons ensuite ces informations en tant que DTO.

```cs
public record BlogOverviewDto
{
    public required Guid Id { get; init; }

    public required string Title { get; init; }

    public required string Description { get; init; }

    public required string AuthorName { get; init; }

    public required DateTimeOffset LastActivity { get; init; }
}

```

DTO contenant des informations de base sur le blog

### Le Service de Blog (BlogService)

Pour effectuer la transition de DAO à DTO, une classe de service sert d'intermédiaire entre le contrôleur et le contexte d'Entity Framework (la base de données). Elle englobe toute la logique métier de l'API et est injectée dans le contrôleur ASP.

Mettons en place une implémentation simple d'un `BlogService` :

```cs
public interface IBlogService
{
    IEnumerable<BlogOverviewDto> GetAll();
}

internal class BlogService : IBlogService
{
    private readonly AppDbContext context;

    public BlogService(AppDbContext context)
    {
        this.context = context;
    }

    public IEnumerable<BlogOverviewDto> GetAll()
    {
        List<Blog> blogs = context.Blogs
            .Include(b => b.Author)
            .Include(b => b.Posts.OrderByDescending(p => p.CreatedAt).Take(1))
            .ToList();

        return blogs.Select(b => new BlogOverviewDto
        {
            Id = b.Id,
            Title = b.Title,
            Description = b.Description,
            AuthorName = b.Author.Name,
            LastActivity = b.Posts.Single().CreatedAt
        });
    }
}

```

Pour simplifier l'exemple, on supposera qu'un blog a toujours au moins un post.

On récupère la liste de nos blogs, en ordonnant les posts par ordre de création et on crée ensuite notre DTO.

Nous pourrions certes créer une classe « BlogMapper » dotée d'une méthode `**ToBlogOverviewDto**` (et des tests unitaires associés), mais cela serait à la fois chronophage et source potentielle d'erreurs. En effet, toute évolution des objets DAO ou DTO nécessiterait une mise à jour manuelle du processus de mappage.

Nous préférerons donc utiliser le package [AutoMapper](https://automapper.org/?ref=sfeir.dev), qui nous permet de supprimer cette redondance et de simplifier grandement la conversion entre DAO et DTO.

## Présentation d'AutoMapper

AutoMapper est une bibliothèque conçue pour simplifier le mappage d'objet à objet, tout en minimisant la configuration requise. Si une propriété porte le même nom dans l'objet source et dans l'objet de destination, le mappage se fait automatiquement entre ces deux propriétés.

Par exemple, la propriété `Blog.Title` sera assignée à la propriété `BlogOverviewDto.Title` automatiquement. Le "Flattening" de propriété est également supporté : `Blog.Author.Name` sera donc mappé vers `BlogOverviewDto.AuthorName`.

Pour configurer la bibliothèque, il suffit de créer une classe héritant de `Profile` dans laquelle mettre notre configuration :

```cs
public class MappingProfile : Profile
{
    public MappingProfile()
    {
        CreateMap<Blog, BlogOverviewDto>(MemberList.Destination)
            .ForMember(
            	blogDto => blogDto.LastActivity, 
                option => option.MapFrom(blog => blog.Posts
                                                     .Single()
                                                     .CreatedAt));
    }
}

```

Classe de configuration des mappings

`CreateMap` est utilisé pour configurer quels objets peuvent être mappés. Ici, on a juste besoin de pouvoir transformer un objet de type `Blog` en objet de type `BlogOverviewDto`.

Toutes les propriétés du DTO sont mappées implicitement, à l'exception de : `LastActivity`, qui ne correspond à aucune propriété côté DAO. Il faut donc préciser à AutoMapper comment retrouver la valeur pour cette propriété dans l'objet source.  
La méthode `ForMember` permet d'indiquer comment récupérer cette propriété en manuel. Il est possible de définir une opération à réaliser sur l'objet source via `option.MapFrom`, de retourner une valeur statique, d'utiliser une classe de conversion via `option.ConvertUsing`, ou bien d'ignorer la propriété avec `option.Ignore`.

💡

Il est également possible de [customiser](https://docs.automapper.org/en/v12.0.1/Configuration.html?highlight=conventions&ref=sfeir.dev#naming-conventions) les conventions utilisées pour le matching implicite, par Map ou par Profile : Convention de nommage des propriétés, remplacement de mots, filtres... 

AutoMapper s'intègre parfaitement à [l'injection de dépendance d'ASP.NET Core](https://docs.automapper.org/en/latest/Dependency-injection.html?ref=sfeir.dev), via la méthode `AddAutoMapper`, ce qui nous permettra d'injecter l'interface `IMapper` dans le `BlogService`.

```cs
services.AddAutoMapper(configuration => 	
	configuration.AddProfile<MappingProfile>());

```

On peut également facilement vérifier que notre mapping est correct et que l'on a oublié aucune propriété, au démarrage de l'application ou dans un test unitaire par exemple, grâce à la méthode `AssertConfigurationIsValid`, [fournie par la bibliothèque](https://docs.automapper.org/en/latest/Getting-started.html?ref=sfeir.dev#how-do-i-test-my-mappings) :

```cs
public void MappingProfile_ShouldHaveValidConfiguration()
{
	var configuration = new MapperConfiguration(cfg => 
		cfg.AddProfile<MappingProfile>());

	configuration.AssertConfigurationIsValid();
}

```

Si, par exemple, la propriété `LastActivity` n'était pas correctement configurée, `AssertConfigurationIsValid` lèverait une exception, conduisant à l'échec du test.

```
AutoMapper.AutoMapperConfigurationException : 
Unmapped members were found. Review the types and members below.

...

Blog -> BlogOverviewDto (Destination member list)

Unmapped properties:
LastActivity
```

Erreur levée par AssertConfigurationIsValid

Le paramètre passé à `CreateMap` permet de spécifier quelles propriétés seront validées :

- `MemberList.Destination` : Valide que toutes les propriétés de l'objet de destination sont mappées, c'est le comportement par défaut.
- `MemberList.Source` : Valide que toutes les propriétés de l'objet source sont mappées.
- `MemberList.None` : Pas de validation.

Si l'on voulait également transformer un `BlogOverviewDto` en `Blog`, on pourrait ajouter un `ReverseMap` à notre configuration.   
Attention cependant : `ReverseMap` ne valide pas le mapping ! Pour valider la configuration dans les deux sens, il serait nécessaire d'ajouter également `ValidateMemberList` et préciser si l'on veut valider les membres source ou destination.

```cs
CreateMap<Blog, BlogOverviewDto>(MemberList.Destination)
    .ForMember(
        blogDto => blogDto.LastActivity, 
        option => option.MapFrom(blog => blog.Posts
                                             .Single()
                                             .CreatedAt))
    .ReverseMap()
    .ValidateMemberList(MemberList.Source);
```

Configuration avec ReverseMap

⚠️

Cette configuration ne passera pas la validation : On n'a pas précisé comment la propriété LastActivity du DTO devait être mappée dans l'objet Blog.

### Transformer nos objets

Mettons maintenant à jour le `BlogService` pour utiliser notre mapping :

```cs
internal class BlogService : IBlogService
{
    private readonly AppDbContext context;
    private readonly IMapper mapper;

    public BlogService(AppDbContext context, IMapper mapper)
    {
        this.context = context;
        this.mapper = mapper;
    }

    public IEnumerable<BlogOverviewDto> GetAll()
    {
        List<Blog> blogs = context.Blogs
           .Include(b => b.Author)
           .Include(b => b.Posts.OrderByDescending(p => p.CreatedAt).Take(1))
           .ToList();

        return blogs.Select(mapper.Map<BlogOverviewDto>);
    }
}
```

On a un code beaucoup plus lisible, et surtout la logique de mapping est sortie du service.

## Intégration avec les ORM

Jetons un œil à la requête effectuée à notre base de données :

```sql
SELECT 
    [b].[id], [b].[AuthorId], [b].[Description], [b].[Title], 
    [a].[id], [a].[Description], [a].[Name],
    [t0].[id], [t0].[BlogId], [t0].[Content], [t0].[CreatedAt], [t0].[Title]
FROM 
    [Blogs] AS [b]
INNER JOIN 
    [Authors] AS [a] ON [b].[AuthorId] = [a].[id]
LEFT JOIN (
    SELECT 
        [t].[id], [t].[BlogId], [t].[Content], [t].[CreatedAt], [t].[Title]
    FROM (
        SELECT 
            [p].[id], [p].[BlogId], [p].[Content], [p].[CreatedAt], [p].[Title], 
            ROW_NUMBER() OVER(PARTITION BY [p].[BlogId] ORDER BY [p].[CreatedAt] DESC) AS [row]
        FROM 
            [Posts] AS [p]
    ) AS [t]
    WHERE 
        [t].[row] <= 1
) AS [t0] ON [b].[id] = [t0].[BlogId]
ORDER BY 
    [b].[id], [a].[id], [t0].[BlogId], [t0].[CreatedAt] DESC
```

On récupère beaucoup plus de données que nécessaire, qui ne seront pas utilisées puisque pas présentes dans notre DTO.

Pour ne récupérer que ce dont on a besoin, on peut effectuer un `Select` pour choisir quelles propriétés demander dans la requête :

```cs
public IEnumerable<BlogOverviewDto> GetAll()
{
    return context.Blogs
        .Select(b => new BlogOverviewDto
        {
            Id = b.Id,
            Title = b.Title,
            Description = b.Description,
            AuthorName = b.Author.Name,
            LastActivity = b.Posts
                .OrderByDescending(p => p.CreatedAt)
                .First()
                .CreatedAt
        });
}

```

Ce qui nous donne la requête SQL suivante :

```sql
SELECT 
    [b].[id] AS [Id], [b].[Title], [b].[Description], 
    [a].[Name] AS [AuthorName], (
        SELECT TOP(1) [p].[CreatedAt]
        FROM [Posts] AS [p]
        WHERE [b].[id] = [p].[BlogId]
        ORDER BY [p].[CreatedAt] DESC) AS [LastActivity]
FROM 
    [Blogs] AS [b]
INNER JOIN 
    [Authors] AS [a] ON [b].[AuthorId] = [a].[id]
```

C'est bien mieux, par contre, on se retrouve à nouveau à faire du mapping manuel, ce qui est précisément ce que l'on voulait éviter en utilisant AutoMapper.  
Heureusement pour nous, celui-ci va une fois de plus nous simplifier la vie, grâce à la méthode `ProjectTo` cette fois.

### Projection en base de données

AutoMapper peut très facilement s'intégrer avec Entity Framework (ou n'importe quel autre ORM d'ailleurs), grâce à des [extensions de l'interface](https://docs.automapper.org/en/stable/Queryable-Extensions.html?ref=sfeir.dev) `IQueryable`, notamment la méthode `ProjectTo`.

Contrairement à la méthode `Map` qui effectue la transformation d'objet dans la mémoire, `ProjectTo` va construire une instruction `SELECT` à partir d'un `IQueryable`, le mapping va donc se faire directement côté base de données.

Mettons à jour la configuration pour y intégrer la même logique que dans notre `Select` précédent :

```cs
CreateMap<Blog, BlogOverviewDto>(MemberList.Destination)
    .ForMember(
        blogDto => blogDto.LastActivity,
        option => option.MapFrom(blog => blog.Posts
                                              .OrderByDescending(p => p.CreatedAt)
                                              .First()
                                              .CreatedAt));
```

Il suffit ensuite d'appeler `ProjectTo`, en lui passant en paramètre la configuration du mapper :

```cs
public IEnumerable<BlogOverviewDto> GetAll()
{
	return context.Blogs.ProjectTo<BlogOverviewDto>(mapper.ConfigurationProvider);
}

```

On peut difficilement faire plus simple.  
Si on vérifie notre requête SQL, on obtient exactement la même chose qu'avec notre `Select` manuel.

On a donc à la fois une requête SQL simplifiée, et la validation de toute notre logique de mapping via AutoMapper.

⚠️

Bien sûr, comme la transformation de l'objet se fait en base de données, cela [limite la configuration](https://docs.automapper.org/en/latest/Queryable-Extensions.html?ref=sfeir.dev#supported-mapping-options) que l'on peut mettre en place : Par exemple, impossible d'utiliser des classes de conversion customisées ou du mapping conditionnel.

### Mesures de performances

Pour essayer de mesurer la différence entre les différentes solutions, on peut réaliser un benchmark à l'aide de la bibliothèque [BenchmarkDotNet](https://benchmarkdotnet.org/?ref=sfeir.dev) :

| Méthode de mapping                  | Temps d'execution | Mémoire allouée |
| ----------------------------------- | ----------------- | --------------- |
| En mémoire, manuel                  | 118.07 ms         | 8001.74 KB      |
| En mémoire, avec AutoMapper         | 133.85 ms         | 8257.86 KB      |
| En base de données, manuel          | 290.3 ms          | 758.67 KB       |
| En base de données, avec AutoMapper | 285.2 ms          | 757.82 KB       |

*Test réalisé sur une base de données avec 1000 blogs, chacun avec 100 posts.*

On peut déjà voir que l'impact de l'utilisation d'AutoMapper est négligeable.  
Le mapping côté base de données est légèrement plus lent dans notre cas, dû à des optimisations faites par défaut par Entity Framework dans sa requête initiale (via le partitionnement et la jointure de la table `Blogs`) qu'on ne retrouve pas dans la requête simplifiée.

Pour essayer de comparer à requête SQL équivalente, modifions la requête utilisée par Entity Framework lors du mapping en mémoire :

```sql
SELECT 
    [b].[id], [b].[AuthorId], [b].[Description], [b].[Title], 
    [a].[id], [a].[Description], [a].[Name], 
    [p].[id], [p].[BlogId], [p].[Content], [p].[CreatedAt], [p].[Title]
FROM 
    [Blogs] AS [b]
INNER JOIN 
    [Authors] AS [a] ON [b].[AuthorId] = [a].[id]
LEFT JOIN [Posts] AS [p] ON [p].[id] = (
    SELECT TOP 1 [p2].[id]
    FROM [Posts] AS [p2]
    WHERE [p2].[BlogId] = [b].[id]
    ORDER BY [p2].[CreatedAt] DESC
)
```

Ce qui nous donne :

| Méthode de mapping | Temps d'execution | Mémoire allouée |
| ------------------ | ----------------- | --------------- |
| En mémoire         | 280.1 ms          | 10080.33 KB     |
| En base de données | 265.5 ms          | 757.45 KB       |

Non seulement le mapping en base de données est légèrement plus rapide, mais surtout, dans tous les cas, nous pouvons consommer jusqu'à 10 fois moins de mémoire, ce qui peut être intéressant si on a à mapper des collections de taille importante.   
Si la configuration d'un mapping est compatible avec une execution en base de données, utiliser la projection peut donc être un bon moyen de facilement optimiser ses requêtes.

## Le mot de la fin

AutoMapper est extrêmement pratique pour éviter la mise en place de logiques de mapping répétitives. Néanmoins, comme l'énonce son créateur Jimmy Bogard dans son post [AutoMapper's Design Philosophy](https://www.jimmybogard.com/automappers-design-philosophy/?ref=sfeir.dev) :

> AutoMapper works because it enforces a convention. It assumes that your destination types are a subset of the source type. It assumes that everything on your destination type is meant to be mapped. It assumes that the destination member names follow the exact name of the source type. It assumes that you want to flatten complex models into simple ones.

AutoMapper est parfaitement adapté à un use case en particulier : pour transformer des objets complexes vers une représentation plus simple.   
Comme on l'a vu avec la propriété `LastActivity`, dès que les objets à mapper ne correspondent pas exactement, on se retrouve à mettre en place de la configuration manuelle plus ou moins complexe. Et plus on a besoin d'adapter manuellement sa configuration, plus on perd le bénéfice d'avoir un mapping automatique.

Il est donc important de déterminer si la mise en place d'AutoMapper est pertinente selon les besoins de l'application.

> If you find yourself hating a tool, it's important to ask - for what problems was this tool designed to solve? And if those problems are different than yours, perhaps that tool isn't a good fit.