DTO mapping — converting between domain entities and DTOs — is unavoidable in layered architectures. Every API endpoint reads domain entities from the database and maps them to response DTOs, and every write operation maps request DTOs to domain entities or commands. The mapping layer must be correct (no fields missed), fast (no N+1 queries), and maintainable (easy to update when DTOs change). Three approaches cover the spectrum: manual extension methods (explicit and type-safe), AutoMapper (less code, more magic), and EF Core projection (best for read performance).
Manual Mapping with Extension Methods
// ── Extension methods in PostExtensions.cs ────────────────────────────────
public static class PostExtensions
{
// Entity → Summary DTO (for list endpoints)
public static PostSummaryDto ToSummaryDto(this Post post)
=> new()
{
Id = post.Id,
Title = post.Title,
Slug = post.Slug,
AuthorName = post.Author?.DisplayName ?? "Unknown",
PublishedAt = post.PublishedAt ?? DateTime.UtcNow,
ViewCount = post.ViewCount,
CommentCount = post.Comments?.Count ?? 0,
Tags = post.Tags.Select(t => t.Name).ToList(),
};
// Entity → Detail DTO (for single-item endpoint)
public static PostDto ToDto(this Post post)
=> new()
{
Id = post.Id,
Title = post.Title,
Slug = post.Slug,
Body = post.Body,
Excerpt = post.Excerpt,
IsPublished = post.IsPublished,
PublishedAt = post.PublishedAt,
CreatedAt = post.CreatedAt,
UpdatedAt = post.UpdatedAt,
Author = post.Author!.ToAuthorDto(),
Tags = post.Tags.Select(t => t.Name).ToList(),
};
// Request DTO → Domain entity creation method
public static Post ToNewPost(this CreatePostRequest request, string authorId)
=> Post.Create(request.Title, request.Slug, request.Body, authorId);
}
// Usage in controller:
// var posts = await _service.GetAllAsync(ct);
// return Ok(posts.Select(p => p.ToSummaryDto()));
Select() with projection for high-performance list queries — rather than loading full entities and mapping them, project directly in the LINQ query to DTOs. _db.Posts.Where(p => p.IsPublished).Select(p => new PostSummaryDto { Id = p.Id, Title = p.Title, AuthorName = p.Author.DisplayName, ... }).ToListAsync(ct). This generates SQL that only fetches the columns needed for the DTO, avoids loading navigation properties unnecessarily, and eliminates the N+1 problem entirely. For read-heavy list endpoints, projection queries are dramatically more efficient than load-and-map.Body to Content) but forget to update the AutoMapper profile, the mapping silently produces a null/default value — no compiler error, no runtime exception, just wrong data. Always enable AssertConfigurationIsValid() in your tests to catch this at test time. AutoMapper also makes debugging harder — when a field is wrong, you have to trace through AutoMapper’s reflection-based internals to find the source.EF Core Projection (Best for Performance)
// ── Repository with direct projection — no entity loading ─────────────────
public async Task<PagedResult<PostSummaryDto>> GetPageAsync(
int page, int size, CancellationToken ct)
{
var query = _db.Posts
.AsNoTracking()
.Where(p => p.IsPublished)
.OrderByDescending(p => p.PublishedAt);
var total = await query.CountAsync(ct);
// Project to DTO directly in SQL — never loads full entity
var items = await query
.Skip((page - 1) * size)
.Take(size)
.Select(p => new PostSummaryDto // EF Core translates this to SQL
{
Id = p.Id,
Title = p.Title,
Slug = p.Slug,
AuthorName = p.Author!.DisplayName ?? "Unknown", // JOIN in SQL
PublishedAt = p.PublishedAt!.Value,
ViewCount = p.ViewCount,
CommentCount = p.Comments.Count(), // COUNT in SQL
Tags = p.Tags.Select(t => t.Name).ToList(),
})
.ToListAsync(ct);
return new PagedResult<PostSummaryDto>(items, page, size, total);
}
// Generated SQL (conceptually):
// SELECT p.Id, p.Title, p.Slug, u.DisplayName, p.PublishedAt,
// p.ViewCount, COUNT(c.Id), GROUP_CONCAT(t.Name)
// FROM Posts p
// LEFT JOIN Users u ON p.AuthorId = u.Id
// LEFT JOIN Comments c ON c.PostId = p.Id
// LEFT JOIN PostTags pt ON pt.PostId = p.Id
// LEFT JOIN Tags t ON pt.TagId = t.Id
// WHERE p.IsPublished = 1
// GROUP BY p.Id ...
Common Mistakes
Mistake 1 — Not using AsNoTracking() for read-only query projections
❌ Wrong — EF Core tracks all loaded entities even when you never modify them; wastes memory:
_db.Posts.Where(p => p.IsPublished).Select(p => new PostSummaryDto { ... })
✅ Correct — add .AsNoTracking() before Select for all read-only queries.
Mistake 2 — Loading entities then mapping (N+1 or unnecessary data loading)
❌ Wrong — loading full Post entities with all navigation properties for a list of 50, then mapping to summaries.
✅ Correct — project directly to DTOs in EF Core query; SQL fetches only what the DTO needs.