5. Object Mapping
- 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);