Rasmus Olsson

Mapping Code in .NET: Manual, Complex, and Source Generators Compared

Tags: dotnet,
October 12, 2024

When working with APIs, databases, or different data models, mapping between types is often necessary. It might feel like extra work, but it helps keep things organized and prevents unintended side effects.

If the same entity is used everywhere—from the database to the API response a small changes on the entity can have big consequences. Imagine a Transaction entity with fields like Id, UserId, Amount, CreatedAt, InternalNotes, and IsFlaggedForReview. If this entity is returned directly from an API, fields like InternalNotes (used for fraud detection or internal audits) might unintentionally be exposed to the client. Which is not intended for the end user.

Here’s are a few other reasons for mapping code:

  • Prevents accidental data leaks – Ensures only the right data is exposed.
  • Keeps concerns separate – DTOs, entities, and API contracts each serve different purposes.
  • Adds a security layer – Explicitly defining what gets mapped avoids exposing internal structures.
  • Protects API stability – Database changes won’t automatically break API contracts.
  • Simplifies transformations – DTOs allow for computed fields, formatting, and validation without affecting database entities.
  • Improves performance – Returns only the necessary fields, reducing payload size.

So we surely need mapping. But how should we map and what are the different alternatives and tools?

In this post we will look at a few alternatives.

Mapping tools and alternatives

Based on my experience, I have classified the tools and alternatives into 3 different types:

  1. Manual Mapping
  2. Complex mappers
  3. Source generator-based mapping

Manual mapping

The most simplest solution is of course to just use you own code to do the mapping, here is an example:

// Internal object public class Transaction { public int Id { get; set; } public int UserId { get; set; } public decimal Amount { get; set; } public DateTime CreatedAt { get; set; } public string InternalNotes { get; set; } public bool IsFlaggedForReview { get; set; } public UserDetails User { get; set; } } // Internal object public class UserDetails { public int Id { get; set; } public string FirstName { get; set; } public string LastName { get; set; } } // Exposed object public record TransactionDto( int Id, decimal Amount, DateTime CreatedAt, string Status, string UserFullName); // Usage var transaction = new Transaction { Id = 1, UserId = 123, Amount = 150.237m, CreatedAt = DateTime.Now, InternalNotes = "Suspicious transaction", IsFlaggedForReview = true, User = new UserDetails { Id = 123, FirstName = "John", LastName = "Doe" } }; // Perform the mapping directly var flaggedForReview = transaction.IsFlaggedForReview ? "Flagged" : "Approved"; // Convert boolean to string var transactionCreatedAtUtc = transaction.CreatedAt.ToUniversalTime(); // Ensure UTC var transactionAmount = Math.Round(transaction.Amount, 2); // Round decimal value var fullName = $"{transaction.User.FirstName} {transaction.User.LastName}"; // Combine first and last name var dto = new TransactionDto( transaction.Id, transactionAmount, transactionCreatedAtUtc, flaggedForReview );

Complex mappers

The idea behind complex mappers is to automate object-to-object mapping, reducing the need to manually write conversion logic. Instead of explicitly mapping each field, you define mapping rules, and the mapper dynamically handles the transformation between objects.

One of the most widely used tools for this is AutoMapper, which allows defining mappings once and then converting objects automatically.

// Internal object public class Transaction { public int Id { get; set; } public int UserId { get; set; } public decimal Amount { get; set; } public DateTime CreatedAt { get; set; } public string InternalNotes { get; set; } public bool IsFlaggedForReview { get; set; } public UserDetails User { get; set; } } // Internal object public class UserDetails { public int Id { get; set; } public string FirstName { get; set; } public string LastName { get; set; } } // Expose object public record TransactionDto( int Id, decimal Amount, DateTime CreatedAt, string Status, string UserFullName); // Config public class TransactionProfile : Profile { public TransactionProfile() { CreateMap<Transaction, TransactionDto>() .ForMember(dest => dest.Status, opt => opt.MapFrom(src => src.IsFlaggedForReview ? "Flagged" : "Approved")) // bool to string .ForMember(dest => dest.UserFullName, opt => opt.MapFrom(src => $"{src.User.FirstName} {src.User.LastName}")) // first and last name .ForMember(dest => dest.CreatedAt, opt => opt.MapFrom(src => src.CreatedAt.ToUniversalTime())) // Ensure UTC .ForMember(dest => dest.Amount, opt => opt.MapFrom(src => Math.Round(src.Amount, 2))) // Round decimal value .ForSourceMember(src => src.InternalNotes, opt => opt.DoNotValidate()); // Ignore field } } //Usage var config = new MapperConfiguration(cfg => cfg.AddProfile<TransactionProfile>()); var mapper = config.CreateMapper(); var transaction = new Transaction { Id = 1, UserId = 123, Amount = 150.237m, CreatedAt = DateTime.Now, InternalNotes = "Suspicious transaction", IsFlaggedForReview = true, User = new UserDetails { Id = 123, FirstName = "John", LastName = "Doe" } }; // Perform the mapping var dto = mapper.Map<TransactionDto>(transaction);

Source generator-based mapping

Source generator-based mapping provides a way to automatically generate mapping code at compile time. Unlike complex mappers like AutoMapper, source generators generate explicit mapping code before runtime. One of the most popular libraries for this approach is Riok.Mapperly.

Here is a example:

// Internal object public class Transaction { public int Id { get; set; } public int UserId { get; set; } public decimal Amount { get; set; } public DateTime CreatedAt { get; set; } public string InternalNotes { get; set; } public bool IsFlaggedForReview { get; set; } public UserDetails User { get; set; } } // Internal object public class UserDetails { public int Id { get; set; } public string FirstName { get; set; } public string LastName { get; set; } } // Exposed object public record TransactionDto( int Id, decimal Amount, DateTime CreatedAt, string Status, string UserFullName); // Mapperly configuration [Mapper] public static partial class TransactionMapper { [MapProperty(nameof(Transaction.IsFlaggedForReview), nameof(TransactionDto.Status))] [MapProperty(nameof(Transaction.User), nameof(TransactionDto.UserFullName))] [MapperIgnoreSource(nameof(Transaction.UserId))] [MapperIgnoreSource(nameof(Transaction.InternalNotes))] public static partial TransactionDto ToDto(Transaction transaction); private static string ConvertStatus(bool isFlaggedForReview) => isFlaggedForReview ? "Flagged" : "Approved"; private static DateTime ConvertToUtc(DateTime createdAt) => createdAt.ToUniversalTime(); private static decimal RoundAmount(decimal amount) => Math.Round(amount, 2); private static string CombineUserFullName(UserDetails user) => $"{user.FirstName} {user.LastName}"; } // Usage var transaction = new Transaction { Id = 1, UserId = 123, Amount = 150.237m, CreatedAt = DateTime.Now, InternalNotes = "Suspicious transaction", IsFlaggedForReview = true, User = new UserDetails { Id = 123, FirstName = "John", LastName = "Doe" } }; // Perform the mapping var dto = TransactionMapper.ToDto(transaction);

And the source generation looks like this:

public static partial class TransactionMapper { [global::System.CodeDom.Compiler.GeneratedCode("Riok.Mapperly", "4.0.0.0")] public static partial global::TransactionDto ToDto(global::Transaction transaction) { var target = new global::TransactionDto( transaction.Id, RoundAmount(transaction.Amount), ConvertToUtc(transaction.CreatedAt), ConvertStatus(transaction.IsFlaggedForReview), CombineUserFullName(transaction.User) ); return target; } }

Reviewing the Different Mapping Approaches

When working with complex mappers like AutoMapper, one challenge is the lack of visibility into how mappings are applied. While AutoMapper provides configuration profiles to define mappings, understanding the transformations often requires familiarity with the library and diving into its configuration. Based on my experience, AutoMapper can become harder to manage as class structures grow more complex. In contrast, both source generation mappers and manual mapping approaches provide clear visibility into the final mapping logic.

The source generation example required quite a lot of code. When compared with manual mapping, we can see that we essentially repeat ourselves in the source generation approach.

Looking at, for example:

// mapperly [Mapper] public static partial class TransactionMapper { [MapProperty(nameof(Transaction.IsFlaggedForReview), nameof(TransactionDto.Status))] [MapProperty(nameof(Transaction.User), nameof(TransactionDto.UserFullName))] [MapperIgnoreSource(nameof(Transaction.UserId))] [MapperIgnoreSource(nameof(Transaction.InternalNotes))] public static partial TransactionDto ToDto(Transaction transaction); private static string ConvertStatus(bool isFlaggedForReview) => isFlaggedForReview ? "Flagged" : "Approved"; private static DateTime ConvertToUtc(DateTime createdAt) => createdAt.ToUniversalTime(); private static decimal RoundAmount(decimal amount) => Math.Round(amount, 2); private static string CombineUserFullName(UserDetails user) => $"{user.FirstName} {user.LastName}"; } // mapperly source generation public static partial class TransactionMapper { [global::System.CodeDom.Compiler.GeneratedCode("Riok.Mapperly", "4.0.0.0")] public static partial global::TransactionDto ToDto(global::Transaction transaction) { var target = new global::TransactionDto( transaction.Id, RoundAmount(transaction.Amount), ConvertToUtc(transaction.CreatedAt), ConvertStatus(transaction.IsFlaggedForReview), CombineUserFullName(transaction.User) ); return target; } } var dto = TransactionMapper.ToDto(transaction); // manual mapper var flaggedForReview = transaction.IsFlaggedForReview ? "Flagged" : "Approved"; var transactionCreatedAtUtc = transaction.CreatedAt.ToUniversalTime(); var transactionAmount = Math.Round(transaction.Amount, 2); var fullName = $"{transaction.User.FirstName} {transaction.User.LastName}"; var dto = new TransactionDto( transaction.Id, transactionAmount, transactionCreatedAtUtc, flaggedForReview );

One of the commonly cited advantages of source generation mappers is that they remove the need to manually specify 1-1 property mappings (e.g., Transaction.TransactionId -> TransactionDto.TransactionId). However, in practice, this benefit is often minimal, especially considering that modern IDEs provide tools like "initialize members," "multi-cursor"/"multi-select," and code completion, significantly speed up manual mapping. Additionally, source generation mappers still require explicit transformation logic for computed fields, similar to manual mapping.

A distinct advantage of source generation mappers, however, is their ability to provide compiler warnings for unmapped properties. This can help catch missing mappings at compile time, something that manual mapping does not offer out of the box.

However, source generation mappers like Mapperly can sometimes produce unexpected results. Consider the following example:

// Internal object public class Transaction { public DateTime? Created { get; set; } } // External object public class TransactionDto { public DateTime? CreatedDate { get; set; } } // Mapperly [Mapper] public static partial class TransactionMapper { public static partial TransactionDto ToDto(Transaction transaction); } // Mapperly source generation public static partial class TransactionMapper { [global::System.CodeDom.Compiler.GeneratedCode("Riok.Mapperly", "4.0.0.0")] public static partial global::TransactionDto ToDto(global::Transaction transaction) { var target = new global::TransactionDto(); target.CreatedDate = transaction.Created?.Date; return target; } }

As we can see above, Mapperly implicitly assumes a conversion based on the naming difference (Created vs CreatedDate). This results in CreatedDate being assigned only the date component of Created, losing the full DateTime value that might have been expected. This kind of implicit transformation can introduce subtle bugs if developers assume full DateTime values are mapped by default.

Conclusion

Code mappers in .NET solve an important problem, but they come with trade-offs. While tools like AutoMapper and Mapperly can be useful in some scenarios, manual mapping should not be discarded. It is clear, reduces complexity, and does exactly what you tell it to do.

Happy coding!

References:

please share