5. Object Mapping

  1. Object Mapping

Look for alternatives to AutoMapper

About this chapter

Eliminate repetitive manual mapping code using AutoMapper to automatically transform entities to DTOs and vice versa with minimal configuration.

  • Manual mapping problems: Why manual object mapping is error-prone and inefficient
  • AutoMapper benefits: DRY principle, maintainability, and convention-based mapping
  • Installation and configuration: Setting up AutoMapper in ASP.NET Core
  • Mapping profiles: Creating and organizing profile classes for different entities
  • Advanced mappings: Custom logic, nested objects, collections, and transformations
  • Project structure: Organizing profiles in a maintainable way

Learning outcomes:

  • Understand problems solved by AutoMapper
  • Install and configure AutoMapper in .NET 10
  • Create mapping profiles for entities and DTOs
  • Use ForMember to handle custom mapping logic
  • Map complex nested objects and collections
  • Organize mapping profiles following best practices

18.1 Why Use AutoMapper

Without AutoMapper:

// Manual mapping - tedious and error-prone
var platformDto = new PlatformReadDto
{
    Id = platform.Id,
    PlatformName = platform.PlatformName,
    CommandCount = platform.Commands.Count,
    CreatedBy = platform.CreatedBy
};
// Multiply this by every endpoint...

With AutoMapper:

var platformDto = _mapper.Map<PlatformReadDto>(platform);

Benefits:

  • DRY Principle: Define mappings once
  • Maintainability: Change mapping logic in one place
  • Convention-Based: Auto-maps properties with same names
  • Testability: Test mapping configurations independently
  • Complex Mappings: Handle nested objects, collections, custom logic

18.2 Installing and Configuring AutoMapper

dotnet add package AutoMapper.Extensions.Microsoft.DependencyInjection
// Program.csbuilder.Services.AddAutoMapper(AppDomain.CurrentDomain.GetAssemblies());// This scans all assemblies for classes inheriting from Profile// and registers them automatically

18.3 Creating Mapping Profiles

using AutoMapper;
using CommandAPI.Models;
using CommandAPI.Dtos;

namespace CommandAPI.Profiles;

public class PlatformsProfile : Profile
{
    public PlatformsProfile()
    {
        // Simple mapping (properties match by name)
        CreateMap<Platform, PlatformReadDto>()
            .ForMember(dest => dest.CommandCount,
                opt => opt.MapFrom(src => src.Commands.Count));
        
        // Reverse mapping for updates
        CreateMap<PlatformMutateDto, Platform>();
        
        // Bidirectional
        CreateMap<Platform, PlatformMutateDto>().ReverseMap();
    }
}

public class CommandsProfile : Profile
{
    public CommandsProfile()
    {
        // Model -> Read DTO
        CreateMap<Command, CommandReadDto>()
            .ForMember(dest => dest.PlatformName,
                opt => opt.MapFrom(src => src.Platform.PlatformName))
            .ForMember(dest => dest.CreatedDate,
                opt => opt.MapFrom(src => src.CreatedAt.ToString("yyyy-MM-dd")));
        
        // Mutate DTO -> Model
        CreateMap<CommandMutateDto, Command>()
            .ForMember(dest => dest.Id, opt => opt.Ignore())  // Never map ID on create
            .ForMember(dest => dest.Platform, opt => opt.Ignore())  // Will be loaded separately
            .ForMember(dest => dest.CreatedAt, opt => opt.Ignore());  // Set by system
        
        // For PATCH operations
        CreateMap<Command, CommandMutateDto>();
    }
}

public class PaginationProfile : Profile
{
    public PaginationProfile()
    {
        // Generic pagination mapping
        CreateMap(typeof(PaginatedList<>), typeof(PaginatedList<>));
    }
}

18.4 CORRECTION: Organizing Profiles Properly

Project Structure:
└── Profiles/
    ├── PlatformsProfile.cs      ✅ Separate file
    ├── CommandsProfile.cs       ✅ Separate file
    ├── RegistrationsProfile.cs  ✅ Separate file
    ├── BulkJobProfile.cs        ✅ Separate file
    └── PaginationProfile.cs     ✅ Separate file

// ❌ Don't put all mappings in one profile
// ❌ Don't mix profile logic with controllers or models

GenericProfile.cs (from current code):

// Current code has this - it's okay but could be more specific
public class GenericProfile : Profile
{
    public GenericProfile()
    {
        CreateMap(typeof(PaginatedList<>), typeof(PaginatedList<>));
    }
}

// Better: PaginationProfile.cs
public class PaginationProfile : Profile
{
    public PaginationProfile()
    {
        // Generic type mapping
        CreateMap(typeof(PaginatedList<>), typeof(PaginatedList<>));
        
        // Specific mappings if needed
        CreateMap<PaginatedList<Platform>, PaginatedList<PlatformReadDto>>();
        CreateMap<PaginatedList<Command>, PaginatedList<CommandReadDto>>();
    }
}

18.5 Convention-Based Mapping

// AutoMapper automatically maps properties with:
// - Same name (case-insensitive)
// - Source property to destination property
// - Flattening: Source.Property.SubProperty -> Destination.PropertySubProperty

public class Command
{
    public int Id { get; set; }
    public string HowTo { get; set; }
    public string CommandLine { get; set; }
    public Platform Platform { get; set; }
}

public class CommandReadDto
{
    public int Id { get; set; }          // ✅ Auto-mapped
    public string HowTo { get; set; }     // ✅ Auto-mapped
    public string CommandLine { get; set; } // ✅ Auto-mapped
    public string PlatformName { get; set; } // ❌ Requires custom mapping
}

// Custom mapping needed:
CreateMap<Command, CommandReadDto>()
    .ForMember(dest => dest.PlatformName,
        opt => opt.MapFrom(src => src.Platform.PlatformName));

18.6 Custom Mappings When Needed

public class CommandsProfile : Profile
{
    public CommandsProfile()
    {
        CreateMap<Command, CommandReadDto>()
            // Map from nested property
            .ForMember(dest => dest.PlatformName,
                opt => opt.MapFrom(src => src.Platform.PlatformName))
            
            // Conditional mapping
            .ForMember(dest => dest.IsRecent,
                opt => opt.MapFrom(src => src.CreatedAt > DateTime.UtcNow.AddDays(-7)))
            
            // Custom resolver
            .ForMember(dest => dest.DisplayText,
                opt => opt.MapFrom<CommandDisplayTextResolver>())
            
            // Ignore property
            .ForMember(dest => dest.InternalField,
                opt => opt.Ignore())
            
            // Null substitution
            .ForMember(dest => dest.Description,
                opt => opt.NullSubstitute("No description provided"))
            
            // Value transformation
            .ForMember(dest => dest.CommandLine,
                opt => opt.MapFrom(src => src.CommandLine.Trim()))
            
            // After map action
            .AfterMap((src, dest) =>
            {
                // Custom logic after mapping
                dest.Popularity = CalculatePopularity(src);
            });
    }
}

// Custom resolver
public class CommandDisplayTextResolver : IValueResolver<Command, CommandReadDto, string>
{
    public string Resolve(Command source, CommandReadDto destination,
        string destMember, ResolutionContext context)
    {
        return $"{source.Platform.PlatformName}: {source.HowTo}";
    }
}

18.7 REVISIT: Ensuring All DTOs Are Properly Mapped

// Create comprehensive mapping tests
using AutoMapper;
using Xunit;

namespace CommandAPI.Tests.Profiles;

public class MappingProfileTests
{
    private readonly IMapper _mapper;
    
    public MappingProfileTests()
    {
        var configuration = new MapperConfiguration(cfg =>
        {
            cfg.AddMaps(typeof(Program).Assembly);
        });
        
        _mapper = configuration.CreateMapper();
    }
    
    [Fact]
    public void AutoMapper_Configuration_IsValid()
    {
        // This will throw if any mappings are invalid
        _mapper.ConfigurationProvider.AssertConfigurationIsValid();
    }
    
    [Fact]
    public void Platform_To_PlatformReadDto_Maps_Correctly()
    {
        // Arrange
        var platform = new Platform
        {
            Id = 1,
            PlatformName = "Docker",
            Commands = new List<Command>
            {
                new Command { /* ... */ },
                new Command { /* ... */ }
            }
        };
        
        // Act
        var dto = _mapper.Map<PlatformReadDto>(platform);
        
        // Assert
        Assert.Equal(platform.Id, dto.Id);
        Assert.Equal(platform.PlatformName, dto.PlatformName);
        Assert.Equal(2, dto.CommandCount);
    }
    
    [Fact]
    public void Command_To_CommandReadDto_Includes_PlatformName()
    {
        // Arrange
        var command = new Command
        {
            Id = 1,
            HowTo = "Run container",
            CommandLine = "docker run",
            Platform = new Platform { PlatformName = "Docker" }
        };
        
        // Act
        var dto = _mapper.Map<CommandReadDto>(command);
        
        // Assert
        Assert.Equal("Docker", dto.PlatformName);
    }
}

18.8 Using AutoMapper in Controllers

[Route("api/[controller]")]
[ApiController]
public class PlatformsController : ControllerBase
{
    private readonly ICommandRepository _repository;
    private readonly IMapper _mapper;
    private readonly ILogger<PlatformsController> _logger;

    public PlatformsController(
        ICommandRepository repository,
        IMapper mapper,
        ILogger<PlatformsController> logger)
    {
        _repository = repository;
        _mapper = mapper;
        _logger = logger;
    }

    [HttpGet]
    public async Task<ActionResult<PaginatedList<PlatformReadDto>>> GetPlatforms(
        int pageIndex = 1, int pageSize = 10)
    {
        var platforms = await _repository.GetPlatformsAsync(pageIndex, pageSize);
        
        // Map entire paginated list
        var platformDtos = _mapper.Map<PaginatedList<PlatformReadDto>>(platforms);
        
        return Ok(platformDtos);
    }

    [HttpGet("{id}")]
    public async Task<ActionResult<PlatformReadDto>> GetPlatformById(int id)
    {
        var platform = await _repository.GetPlatformByIdAsync(id);
        
        if (platform == null)
            return NotFound();
        
        // Map single entity
        return Ok(_mapper.Map<PlatformReadDto>(platform));
    }

    [HttpPost]
    public async Task<ActionResult<PlatformReadDto>> CreatePlatform(
        PlatformMutateDto platformDto)
    {
        // Map DTO to entity
        var platform = _mapper.Map<Platform>(platformDto);
        platform.CreatedBy = User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
        
        await _repository.CreatePlatformAsync(platform);
        await _repository.SaveChangesAsync();
        
        // Map entity back to read DTO
        var readDto = _mapper.Map<PlatformReadDto>(platform);
        
        return CreatedAtRoute(
            nameof(GetPlatformById),
            new { id = readDto.Id },
            readDto);
    }

    [HttpPut("{id}")]
    public async Task<ActionResult> UpdatePlatform(
        int id, PlatformMutateDto platformDto)
    {
        var platform = await _repository.GetPlatformByIdAsync(id);
        
        if (platform == null)
            return NotFound();
        
        // Map DTO properties to existing entity (preserves Id, CreatedAt, etc.)
        _mapper.Map(platformDto, platform);
        
        await _repository.UpdatePlatformAsync(platform);
        await _repository.SaveChangesAsync();
        
        return NoContent();
    }
}

18.9 Performance Considerations

// ✅ Good: Map collections efficiently
var dtos = _mapper.Map<List<PlatformReadDto>>(platforms);

// ❌ Bad: Mapping in a loop
var dtos = new List<PlatformReadDto>();
foreach (var platform in platforms)
{
    dtos.Add(_mapper.Map<PlatformReadDto>(platform));
}

// ✅ Good: Use ProjectTo for database queries (reduces data transfer)
var platforms = await _context.Platforms
    .ProjectTo<PlatformReadDto>(_mapper.ConfigurationProvider)
    .ToListAsync();

// This translates mapping to SQL SELECT - only fetches needed columns

// ❌ Bad: Fetch everything then map
var platforms = await _context.Platforms.ToListAsync();
var dtos = _mapper.Map<List<PlatformReadDto>>(platforms);