Skip to main content

10. Object Mapping

About this chapter

In this chapter we'll introduce Mapster, a high-performance object mapping library that will:

  • Eliminate repetitive manual mapping code
  • Maintain clean separation between domain models and DTOs
  • Improve code maintainability and readability
  • Reduce the likelihood of mapping errors

Learning outcomes:

  • Understanding the benefits of object mapping libraries
  • Installing and configuring Mapster in a .NET API project
  • Creating mapping configurations for your domain models and DTOs
  • Refactoring existing controllers to use Mapster
  • Applying mapping patterns consistently across your API

Architecture Checkpoint

In reference to our solution architecture we'll be making code changes to the highlighted components in this chapter:

  • Controllers (partially complete)
  • Mappings (complete)

Figure 10.1 Chapter 10 Solution Architecture


Companion Code
  • The code for this section can be found here on GitHub
  • The complete finished code can be found here on GitHub

Feature branch

Ensure that main is current, then create a feature branch called: chapter_10_object_mapping, and check it out:

git branch chapter_10_object_mapping
git checkout chapter_10_object_mapping
tip

If you can't remember the full workflow, refer back to Chapter 5

What is object mapping?

If we take a look at the code we have for UpdateCommand in the CommandsController we can see that we have manual mappings (highlighted):

[HttpPut("{id}")]
public async Task<ActionResult> UpdateCommand(int id, CommandUpdateDto commandUpdateDto)
{
var commandFromRepo = await _commandRepo.GetCommandByIdAsync(id);
if (commandFromRepo == null)
{
return NotFound();
}

// Manual mapping from DTO to entity
commandFromRepo.HowTo = commandUpdateDto.HowTo;
commandFromRepo.CommandLine = commandUpdateDto.CommandLine;

await _commandRepo.UpdateCommandAsync(commandFromRepo);
await _commandRepo.SaveChangesAsync();

return NoContent();
}

At the moment we only have 2 properties that we need to update, but imagine if there were more. This approach to mapping would quickly become tiresome and error prone. That's where automated object mapping comes in.

Object mapping allows you to set up configurations that specify how properties should be mapped between objects, in our case this applies equally to:

  • DTO -> Model (the above example)
  • Model -> DTO

In instances where property names are identical between objects (as is the case for us) then these mapping profiles are very basic, as the mapping framework automatically infers the mappings based on matching property names. Of course if this is not the case, or you need to add further mapping logic, then this can all be defined in the mapping config.

Mapping libraries

There are a few different mapping libraries in the .NET space, by far the most popular is AutoMapper, however I've decided not to use that in the book due to the changes in licensing conditions, instead opting for the equally good Mapster.

Fundamentally both these libraries do the same thing, so do your own research and select the library that works best for your own projects.

Package installs

To use Mapster we need to install 2 packages as follows:

dotnet add package Mapster
dotnet add package Mapster.DependencyInjection
tip

Check the .csproj file to ensure the packages were added correctly.

Mappings

In this section we'll create the mapping configuration we'll use application-wide, to do so:

  • In the root of the project create a folder called Mappings.
  • Create a file called MappingConfig.cs and place it into the Mappings folder

Add the following code to the MappingConfig.cs file:

using Mapster;
using CommandAPI.Models;
using CommandAPI.Dtos;

namespace CommandAPI.Mappings;

public class MappingConfig : IRegister
{
public void Register(TypeAdapterConfig config)
{
// Platform mappings
config.NewConfig<Platform, PlatformReadDto>();
config.NewConfig<PlatformCreateDto, Platform>();
config.NewConfig<PlatformUpdateDto, Platform>();

// Command mappings
config.NewConfig<Command, CommandReadDto>();
config.NewConfig<CommandCreateDto, Command>();
config.NewConfig<CommandUpdateDto, Command>();
}
}

This code:

  • Implements the IRegister interface from Mapster, allowing this configuration to be automatically discovered and registered
  • Defines the Register method where all mapping configurations are centralized
  • Creates bidirectional mapping configurations for our domain models and DTOs:
    • Model → ReadDto: For converting entities to DTOs when returning data (e.g., Platform to PlatformReadDto)
    • CreateDto → Model: For converting incoming data to entities when creating resources
    • UpdateDto → Model: For converting incoming data to entities when updating resources
  • Relies on convention-based mapping - since property names match between our models and DTOs, Mapster automatically infers how to map them without additional configuration

Register

We need to register the Mapster framework in our DI container, as usual this is done in Program.cs, so add the following highlighted lines:

using CommandAPI.Data;
using Mapster;
using Microsoft.EntityFrameworkCore;
using Npgsql;

//
// Existing code
//

builder.Services.AddScoped<IPlatformRepository, PgSqlPlatformRepository>();
builder.Services.AddScoped<ICommandRepository, PgSqlCommandRepository>();

builder.Services.AddMapster();

builder.Services.AddControllers();

//
// Existing code
//

Update controllers

With the mapping set up, we need to make use of them in the controllers, removing the manual mappings.

We'll go through each of the endpoints in both of our controllers, but first a couple of generic points to note:

  • Make sure you add a using Mapster; reference to both the Platforms and Commands controllers.
  • Unlike other services we've used, Mapster does not need to be injected into our controllers via dependency injection. Instead, it provides static extension methods (like .Adapt<T>()) that can be called directly on any object. This means you won't see Mapster in constructor parameters—you simply call the mapping methods when needed.
  • When it comes to detailing the Mapster code for each endpoint, I'm only going to show:
    • The existing manual mapping code (commented out)
    • The replacement Mapster code
tip

Remember you can find the completed code for this chapter here on GitHub.

Platforms controller

GetPlatforms

// Manual mapping to DTOs
//var platformDtos = platforms.Select(p => new PlatformReadDto(p.Id, p.PlatformName, p.CreatedAt));

//Using Mapster
var platformDtos = platforms.Select(p => p.Adapt<PlatformReadDto>());

This code:

  • Calls the .Adapt<PlatformReadDto>() extension method on each Platform object
  • Automatically maps all matching properties from Platform to PlatformReadDto using the configuration we defined earlier
  • Eliminates the need to manually specify each property (compare with the commented code above)
  • Returns the same result as the manual mapping, but with significantly less code

GetPlatformById

// Manual mapping to DTO
//var platformDto = new PlatformReadDto(platform.Id, platform.PlatformName, platform.CreatedAt);

//Using Mapster
var platformDto = platform.Adapt<PlatformReadDto>();

This code:

  • Maps a single Platform entity to a PlatformReadDto

GetCommandsForPlatform

// Manual mapping to DTO
//var commandDtos = commands.Select(c => new CommandReadDto(c.Id, c.HowTo, c.CommandLine, c.PlatformId, c.CreatedAt));

// Using Mapster
var commandDtos = commands.Select(c => c.Adapt<CommandReadDto>());

This code:

  • Maps a collection of Command entities to CommandReadDto objects

CreatePlatform

// Manual mapping from DTO to entity
//var platform = new Platform
//{
// PlatformName = platformCreateDto.PlatformName
//};

// Using Mapster
var platform = platformCreateDto.Adapt<Platform>();

await _platformRepo.CreatePlatformAsync(platform);
await _platformRepo.SaveChangesAsync();

// Manual mapping to DTO for response
//var platformReadDto = new PlatformReadDto(platform.Id, platform.PlatformName, platform.CreatedAt);

// Using Mapster
var platformReadDto = platform.Adapt<PlatformReadDto>();

This code:

  • Maps a PlatformCreateDto to a new Platform entity (for creation)
  • Maps the created Platform entity to a PlatformReadDto (for the response)

UpdatePlatform

// Manual mapping from DTO to entity
//platformFromRepo.PlatformName = platformUpdateDto.PlatformName;

// Using Mapster
platformUpdateDto.Adapt(platformFromRepo);

This code:

  • Maps properties from PlatformUpdateDto onto the existing platformFromRepo entity (in-place mapping)

DeletePlatform

As we are destroying a resource there is no need to use Mapster.

Commands controller

GetCommands

// Manual mapping to DTOs
//var commandDtos = commands.Select(c => new CommandReadDto(c.Id, c.HowTo, c.CommandLine, c.PlatformId, c.CreatedAt));

// Using Mapster
var commandDtos = commands.Select(c => c.Adapt<CommandReadDto>());

This code:

  • Maps a collection of Command entities to CommandReadDto objects

GetCommandById

// Manual mapping to DTO
//var commandDto = new CommandReadDto(command.Id, command.HowTo, command.CommandLine, command.PlatformId, command.CreatedAt);

// Using Mapster
var commandDto = command.Adapt<CommandReadDto>();

This code:

  • Maps a single Command entity to a CommandReadDto

CreateCommand

// Manual mapping from DTO to entity
/*var command = new Command
{
HowTo = commandCreateDto.HowTo,
CommandLine = commandCreateDto.CommandLine,
PlatformId = commandCreateDto.PlatformId
};
*/

// Using Mapster
var command = commandCreateDto.Adapt<Command>();

await _commandRepo.CreateCommandAsync(command);
await _commandRepo.SaveChangesAsync();

// Manual mapping to DTO for response
// var commandReadDto = new CommandReadDto(command.Id, command.HowTo, command.CommandLine, command.PlatformId, command.CreatedAt);

// Using Mapster
var commandReadDto = command.Adapt<CommandReadDto>();

This code:

  • Maps a CommandCreateDto to a new Command entity (for creation)
  • Maps the created Command entity to a CommandReadDto (for the response)

UpdateCommand

// Manual mapping from DTO to entity
//commandFromRepo.HowTo = commandUpdateDto.HowTo;
//commandFromRepo.CommandLine = commandUpdateDto.CommandLine;

// Using Mapster
commandUpdateDto.Adapt(commandFromRepo);

This code:

  • Maps properties from CommandUpdateDto onto the existing commandFromRepo entity (in-place mapping)

DeleteCommand

As we are destroying a resource there is no need to use Mapster.

Explicit mapping

As stated above, as we are using convention-based mapping (the property names are identical in both the source and destination objects), we don't even need to have the MappingConfig class as the automatic mapping inferences performed by Mapster are enough on their own. You could therefore do the following and Mapster would still work:

  • Delete the MappingConfig.cs file
  • Retain all other changes relating to Mapster

So why bother having the MappingConfig class in the first place?

In our current example we don't need one, that is true, but it is entirely possible that as we expand the code we may need to perform some explicit (or custom) mapping operations.

As an example (don't do this - it's just an illustration):

  • We add a VendorName string property to the Platform model
  • We add a CompanyName string property to the platform DTos.

Convention-based mapping would not be enough to infer the mapping between these 2 properties, so we'd need to set this up in the MappingConfig class as follows:

config.NewConfig<Platform, PlatformReadDto>()
.Map(dest => dest.CompanyName, src => src.VendorName);
config.NewConfig<PlatformCreateDto, Platform>()
.Map(dest => dest.VendorName, src => src.CompanyName);
config.NewConfig<PlatformUpdateDto, Platform>()
.Map(dest => dest.VendorName, src => src.CompanyName);

This code:

  • Explicitly maps the VendorName property from the Platform model to the CompanyName property in PlatformReadDto when reading data
  • Maps the CompanyName property from PlatformCreateDto to the VendorName property in the Platform model when creating new resources
  • Maps the CompanyName property from PlatformUpdateDto to the VendorName property in the Platform model when updating existing resources

Doesn't this bring us back to manual mapping? Not quite. While we do need to explicitly configure these property mappings in the MappingConfig class, this is fundamentally different from the old approach. The key distinction is that this configuration happens once in a centralized location, and throughout your controllers you still just call .Adapt<T>(). Compare this to the previous manual mapping method where every controller action had to manually assign each property. The mapping logic is defined in one place and reused everywhere, making it far more maintainable.

For now, we'll continue to use convention-based mapping only. Personally I'd suggest leaving the MappingConfig class in place.

Exercising changes

The changes we have made should make absolutely no difference to the external API contract - it is an internal change only. With this in mind run up the app and exercise the requests you have in both: platforms.http and commands.http.

There should be no change in behavior.

Version Control

With the code complete, it's time to commit our code. A summary of those steps can be found below, for a more detailed overview refer to Chapter 5

  • Save all files
  • git add .
  • git commit -m "add command model and associated artifacts"
  • git push (will fail - copy suggestion)
  • git push --set-upstream origin chapter_10_object_mapping
  • Move to GitHub and complete the PR process through to merging
  • Back at a command prompt: git checkout main
  • git pull

Conclusion

In this chapter we eliminated manual property mapping throughout our controllers by introducing Mapster, an object mapping library. Mapster supports both convention-based mapping (when property names match) and explicit mapping (when they don't), giving us flexibility as our application evolves. While our current implementation uses convention-based mapping exclusively—meaning we could technically omit the MappingConfig class—we've chosen to keep it in place to provide a clear location for future explicit mappings as the application grows.

The benefits of this approach are significant: our controller code is now cleaner and more maintainable, we've reduced the risk of mapping errors, and we've established a pattern that will scale as our models grow more complex. The .Adapt<T>() extension method provides a simple, intuitive API that requires no dependency injection and minimal setup.

Importantly, this was a purely internal refactoring—the API contract remains unchanged. This demonstrates a key principle of good API design: improving internal implementation without affecting consumers. As your application evolves, you'll find that having mapping logic centralized makes it much easier to handle future changes to your models or DTOs.

With object mapping in place, we now have a solid foundation for managing data transformations throughout our API, setting us up well for the more advanced features we'll be implementing in upcoming chapters.