Codepth.dev
Playbooks/Cross-Cutting
← All playbooks
Cross-Cutting

Mapping & Transforming Data (Entity to DTO)

You accidentally serialized a password hash to the API

5 wayseasy·~11 min read

Use Case: Mapping & Transforming Data (Entity ↔ DTO)

The everyday problem: Your database entities are not what you want to expose over your API. The User entity has a PasswordHash, internal flags, and navigation properties you must never serialize to clients. Your API needs a clean UserDto with just the public fields. So every layer boundary — entity to DTO going out, request model to entity coming in — needs mapping. Done by hand for every property it is tedious and error-prone; done with a heavyweight tool it can hide bugs and cost performance. The challenge is converting between data shapes (entities, DTOs, view models, request/response models) cleanly, correctly, and without the mapping layer becoming either a maintenance burden or a performance sink.

Five approaches, from explicit manual mapping to compile-time source generation. The debate between them — convenience versus explicitness versus performance — is one of the more opinionated topics in .NET, so each is presented with its honest trade-offs.


Way 1: Manual Mapping (explicit, no dependencies)

Best used for: Small projects, performance-critical paths, or teams that value explicitness and want zero magic — every mapping is visible, debuggable code with no library behaviour to learn.

The concept: You write the mapping by hand — typically as an extension method or a static method that takes the source and returns the target, assigning each property explicitly. Nothing is hidden; the mapping is ordinary code you can read, debug, and step through.

// Manual mapping as extension methods — explicit and debuggable
public static class UserMappings
{
    public static UserDto ToDto(this User user) => new()
    {
        Id          = user.Id,
        DisplayName = $"{user.FirstName} {user.LastName}",
        Email       = user.Email,
        JoinedDate  = user.CreatedAt,
        IsPremium   = user.SubscriptionTier > 0
        // PasswordHash, internal flags simply not mapped — impossible to leak by accident
    };

    public static User ToEntity(this CreateUserRequest req) => new()
    {
        FirstName = req.FirstName,
        LastName  = req.LastName,
        Email     = req.Email.ToLowerInvariant(),
        CreatedAt = DateTime.UtcNow
    };
}

// Usage — clean call sites
var dto = user.ToDto();
var dtos = users.Select(u => u.ToDto()).ToList();

Why it works: The mapping is just code — no library to configure, no runtime reflection, no surprises. You can see exactly what maps to what, set breakpoints, and apply arbitrary transformation logic inline. Nothing maps unless you write it, so you cannot accidentally expose a sensitive field by forgetting to exclude it.

Benchmark note: Manual mapping is the fastest possible (it is direct property assignment — nothing to beat) and the most explicit, with zero dependencies. Its cost is verbosity and maintenance: for a large domain with many types and properties, you write and maintain a lot of mapping code, and when you add a property you must remember to update every mapping (a forgotten line silently omits a field). For small-to-medium projects, performance-sensitive code, or teams burned by mapping-library magic, the explicitness is often worth the verbosity. For large projects with many similar mappings, the boilerplate becomes a real burden — which is what the other approaches address.


Way 2: AutoMapper (convention-based, reflection)

Best used for: Large projects with many mappings that mostly follow naming conventions, where the boilerplate of manual mapping is a genuine burden and you accept some runtime cost and indirection for the convenience.

The concept: AutoMapper maps automatically by matching property names by convention, with explicit configuration only for the cases that differ. You define mapping profiles once; AutoMapper handles the property copying at runtime via reflection (with internal optimisation). It dramatically reduces mapping code for conventional shapes.

// Define mappings once in a profile
public class UserProfile : Profile
{
    public UserProfile()
    {
        CreateMap<User, UserDto>()
            // Only configure what DOESN'T match by convention
            .ForMember(d => d.DisplayName, o => o.MapFrom(s => $"{s.FirstName} {s.LastName}"))
            .ForMember(d => d.IsPremium,   o => o.MapFrom(s => s.SubscriptionTier > 0));
        // Id, Email, etc. map automatically by name

        CreateMap<CreateUserRequest, User>()
            .ForMember(d => d.Email, o => o.MapFrom(s => s.Email.ToLowerInvariant()));
    }
}

// Register
builder.Services.AddAutoMapper(typeof(UserProfile).Assembly);

// Use via injected IMapper
public class UserController(IMapper mapper) : ControllerBase
{
    public UserDto Get(User user) => mapper.Map<UserDto>(user);
}

// ProjectTo — pushes the mapping into the SQL query (selects only needed columns)
var dtos = await mapper.ProjectTo<UserDto>(_db.Users).ToListAsync(ct);

Why it works: For mappings that follow naming conventions, AutoMapper eliminates the boilerplate — you configure only the exceptions. ProjectTo is a genuine strength: it translates the mapping into the EF Core query so only the needed columns are selected from the database (avoiding the over-fetching of loading full entities then mapping).

Benchmark note: AutoMapper trades performance and explicitness for convenience. It is slower than manual mapping (reflection-based, though optimised) — usually irrelevant for typical API workloads but measurable on hot paths. The deeper criticisms, which have made it controversial: mappings are implicit, so a bug (a mis-mapped or silently-unmapped property) hides in configuration rather than visible code and can be hard to trace; refactoring a property name can silently break a convention-based mapping with no compile error; and it can mask expensive operations. Note also that AutoMapper's licensing moved to commercial for some usage — verify current terms. Use it where conventional mappings are numerous and the convenience genuinely pays; many teams have moved away from it toward source generators (Way 4) for explicitness plus performance. Always validate configuration at startup (AssertConfigurationIsValid) to catch mapping gaps early.


Way 3: Projection in the Query (map in the SELECT)

Best used for: Read paths where you are loading from the database and want the mapping to happen in the SQL query — selecting only the columns the DTO needs, never materialising full entities. Often the best-performing approach for reads.

The concept: Instead of loading entities and then mapping them, you project directly into the DTO inside the LINQ query with Select. EF Core translates this into SQL that selects exactly the DTO's fields — the mapping and the data fetch are one operation, and no entity is ever fully loaded.

// Project straight into the DTO in the query — EF generates minimal SQL
var dtos = await _db.Users
    .Where(u => u.IsActive)
    .Select(u => new UserDto
    {
        Id          = u.Id,
        DisplayName = u.FirstName + " " + u.LastName, // computed in SQL
        Email       = u.Email,
        OrderCount  = u.Orders.Count(),               // aggregated in SQL — no N+1
        IsPremium   = u.SubscriptionTier > 0
    })
    .ToListAsync(ct);
// Generated SQL selects ONLY these columns + the order count — nothing else.
// PasswordHash and other entity columns are never even fetched.

Why it works: The mapping is pushed down into the database query, so only the data the DTO needs crosses the wire — no over-fetching of unused entity columns, no separate mapping step, no risk of leaking unmapped fields (they are never loaded). Aggregations and computed values execute in SQL. This is the same technique that solves N+1 (it avoids loading navigation properties as entities).

Benchmark note: Projection is frequently the fastest and leanest read path — it minimises both data transferred (only needed columns) and memory (no entity materialisation or change tracking), and the mapping is "free" because it is part of the query the database runs anyway. The limitations: it only applies to read paths that originate from a queryable source (you cannot project an already-loaded object this way — that is just in-memory mapping), and the projection expression must be translatable to SQL (you cannot call arbitrary C# methods inside it — EF will fail to translate them). For API read endpoints returning DTOs from the database, projection is usually the right default, and it composes with any of the other approaches for the write side.


Way 4: Source-Generated Mapping (Mapperly — compile-time)

Best used for: Wanting AutoMapper's low-boilerplate convenience and manual mapping's performance and explicitness simultaneously — the modern approach that resolves the convenience-vs-performance tension.

The concept: Mapperly is a source generator: you declare a mapping method signature and mark it [Mapper], and at compile time it generates the explicit property-assignment code you would have written by hand. You get concise declarations, but the actual mapping is plain generated code — no runtime reflection, full performance, and you can inspect exactly what it produces.

// Declare the mapper — the implementation is GENERATED at compile time
[Mapper]
public partial class UserMapper
{
    // Mapperly generates the body: direct property assignments, no reflection
    public partial UserDto ToDto(User user);

    // Configure only the non-conventional bits via attributes
    [MapProperty(nameof(User.SubscriptionTier), nameof(UserDto.IsPremium))]
    public partial UserDto ToDtoCustom(User user);

    // Custom logic for computed members
    private string MapDisplayName(User u) => $"{u.FirstName} {u.LastName}";
}

// Usage — looks like AutoMapper, performs like manual mapping
var mapper = new UserMapper();
var dto = mapper.ToDto(user);

// The generated code (visible in your obj/ folder) is literally:
//   return new UserDto { Id = user.Id, Email = user.Email, ... };
// — exactly what you'd hand-write, but you didn't have to.

Why it works: Source generation happens at compile time, so the mapping is concrete generated code with no runtime reflection — as fast as manual mapping. Because it generates real code you can read, the mapping is explicit and debuggable (you can open the generated file), and missing or mismatched properties produce compile-time warnings or errors rather than silent runtime gaps — the explicitness AutoMapper lacks, with the brevity manual mapping lacks.

Benchmark note: Mapperly (and similar source generators) genuinely resolves the long-standing trade-off: near-manual performance, AutoMapper-like conciseness, compile-time safety, and zero runtime reflection or startup cost. Mapping errors surface at build time (unmapped property → a diagnostic), which catches the exact class of bug that hides in reflection-based mappers. This is why source-generated mapping has become many teams' preferred choice for new projects. The minor costs: it is a newer approach (smaller ecosystem than AutoMapper, though mature and widely adopted), and complex custom mappings still need configuration via attributes or partial methods. For new .NET projects wanting low-boilerplate mapping without the runtime-reflection downsides, this is the strong default recommendation.


Way 5: Records + Explicit Construction (lightweight, immutable)

Best used for: DTOs modelled as immutable records, where mapping is a constructor call or a with expression — clean, explicit, and leveraging the language rather than a mapping mechanism.

The concept: Define DTOs as records (immutable, value-equality, concise) and map by constructing them directly — either inline, via a static factory, or in a projection. This is manual mapping (Way 1) modernised with records, emphasising immutability and minimal ceremony, and it pairs naturally with query projection (Way 3).

// Immutable record DTOs — concise declarations
public record UserDto(int Id, string DisplayName, string Email, bool IsPremium);
public record OrderDto(int Id, decimal Total, DateTime PlacedAt, string Status);

// Map via a static factory on the DTO (or an extension method)
public record UserDto(int Id, string DisplayName, string Email, bool IsPremium)
{
    public static UserDto From(User u) =>
        new(u.Id, $"{u.FirstName} {u.LastName}", u.Email, u.SubscriptionTier > 0);
}

// Usage — explicit, immutable, no library
var dto  = UserDto.From(user);
var dtos = users.Select(UserDto.From).ToList();

// Pairs perfectly with query projection (Way 3):
var fromDb = await _db.Users
    .Select(u => new UserDto(u.Id, u.FirstName + " " + u.LastName, u.Email, u.SubscriptionTier > 0))
    .ToListAsync(ct);

// Transform an existing DTO immutably with 'with'
var redacted = dto with { Email = "***" };

Why it works: Records give you concise, immutable DTOs with value equality and built-in with-expression transformation, and constructing them is explicit, fast, and dependency-free. Immutability is a real benefit for DTOs — they are data-transfer snapshots that should not be mutated after creation, and records enforce that. The mapping is visible code (like manual mapping) but with far less ceremony thanks to positional records.

Benchmark note: This is essentially manual mapping (so: fast, explicit, zero dependencies) modernised — records reduce the DTO boilerplate that made manual mapping verbose, and immutability prevents a class of accidental-mutation bugs. It pairs ideally with query projection (Way 3) for reads. The same trade-off as manual mapping applies for large domains: you still write each mapping, so many types mean many factory methods — though records make each one a one-liner. For teams that prefer immutable DTOs and language-level clarity over a mapping framework, this is a clean, modern choice, and it combines well with source generation (Way 4) when the count of mappings grows.


Choosing the right approach

  • Small project / hot path / want zero magic → Manual mapping (Way 1)
  • Many conventional mappings, accept runtime cost → AutoMapper (Way 2) — check licensing
  • Reading from the database into DTOs → Query projection (Way 3) — best for reads
  • Want convenience + performance + compile-time safety → Source-generated / Mapperly (Way 4) — modern default
  • Immutable record DTOs, language-level clarity → Records + construction (Way 5)

The decision spine: first separate reads from writes. For reads from the database, projection (Way 3) is usually best — it maps in the query, fetches only needed columns, and cannot leak unmapped fields. For everything else, the choice is about the convenience-explicitness-performance triangle: manual mapping (Way 1) and records (Way 5) are explicit and fast but verbose at scale; AutoMapper (Way 2) is concise but reflection-based, implicit, and now licensing-sensitive; source-generated mapping (Way 4) increasingly wins because it is concise and fast and compile-time-safe, eliminating the traditional trade-off. The security throughline across all of them: DTOs exist partly to control what leaves your system, so prefer approaches where forgetting to map a field omits it (safe) rather than ones where a convention might include something sensitive (unsafe) — explicit construction and projection fail safe; convention-based mapping requires vigilance to not expose internal fields.

Note: this is an informational engineering overview. Performance differences are usually negligible for typical workloads and matter mainly on hot paths — measure before optimising. Verify current library licensing terms before adopting.