Skip to content

Projections Guide

This guide covers [GenerateProjection] — generating Expression<Func<TEntity, TDto>> for SQL-translatable EF Core queries.

When you add [GenerateProjection] to a DTO that has [MapFrom<T>], the source generator emits a static Projection property. EF Core translates this expression tree directly to a SQL SELECT clause, fetching only the columns your DTO needs.

[MapFrom<Property>]
[GenerateProjection]
public partial class PropertySummaryDto
{
public Guid Id { get; init; }
public string Name { get; init; } = "";
public string City { get; init; } = "";
public int StarRating { get; init; }
public bool IsActive { get; init; }
}

The generator creates:

public static Expression<Func<Property, PropertySummaryDto>> Projection { get; } =
entity => new PropertySummaryDto
{
Id = entity.Id,
Name = entity.Name,
City = entity.City,
StarRating = entity.StarRating,
IsActive = entity.IsActive,
};

EF Core translates this to:

SELECT p."Id", p."Name", p."City", p."StarRating", p."IsActive"
FROM "Properties" p
WHERE ...
FeatureFromEntity()SelectorProjection
SQL translationNoNoYes
Custom convertersYesYesNo
Format stringsYesYesNo
CustomizeMappingYesYesNo
BeforeMappingYesYesNo
Nested DTOsYes (recursive)Yes (recursive)Yes (inlined)
Collections of DTOsYesYesYes (inlined)
Circular referencesYes (tracked)Yes (tracked)Not supported
Navigation loadingManual IncludeManual IncludeAutomatic (SQL JOIN)

Rule of thumb: Use Projection whenever you are querying from EF Core. Fall back to FromEntity() only when you need converters, format strings, hooks, or are working with in-memory objects.

var dtos = await db.Properties
.Where(p => p.IsActive)
.Select(PropertySummaryDto.Projection)
.ToListAsync();

The Pragmatic.Mapping.EFCore package provides convenience methods with built-in OpenTelemetry tracing:

using Pragmatic.Mapping.EFCore.Extensions;
// List
var dtos = await db.Properties
.Where(p => p.IsActive)
.ToListDtoAsync(PropertySummaryDto.Projection);
// Single item
var dto = await db.Properties
.Where(p => p.Id == id)
.FirstOrDefaultDtoAsync(PropertySummaryDto.Projection);
// Paginated
var page = await db.Properties
.Where(p => p.IsActive)
.OrderBy(p => p.Name)
.ToPagedDtoAsync(PropertySummaryDto.Projection, pageNumber: 1, pageSize: 20);
// page.Items -- IReadOnlyList<PropertySummaryDto>
// page.TotalCount -- Total matching rows
// page.TotalPages -- Computed from TotalCount / PageSize
// page.HasNextPage -- Boolean
// page.HasPreviousPage
// Offset/Limit (cursor-based)
var slice = await db.Properties
.OrderBy(p => p.Name)
.ToSliceDtoAsync(PropertySummaryDto.Projection, offset: 40, limit: 20);
var dto = await db.Properties
.Where(p => p.Code == "PROP-001")
.SingleOrDefaultDtoAsync(PropertySummaryDto.Projection);
var dtos = await db.Properties
.Where(p => p.City == "Vienna")
.ToArrayDtoAsync(PropertySummaryDto.Projection);

When a DTO has a nested DTO property, the generator inlines the nested projection instead of calling FromEntity(). This keeps the entire expression SQL-translatable.

[MapFrom<Order>]
[GenerateProjection]
public partial class OrderDto
{
public Guid Id { get; init; }
public decimal Total { get; init; }
public AddressDto? ShippingAddress { get; init; }
public List<OrderLineDto> Lines { get; init; } = [];
}
[MapFrom<Address>]
public partial class AddressDto
{
public string Street { get; init; } = "";
public string City { get; init; } = "";
}
[MapFrom<OrderLine>]
public partial class OrderLineDto
{
public string ProductName { get; init; } = "";
public int Quantity { get; init; }
}

The generated projection inlines everything:

public static Expression<Func<Order, OrderDto>> Projection { get; } =
entity => new OrderDto
{
Id = entity.Id,
Total = entity.Total,
ShippingAddress = entity.ShippingAddress == null
? null
: new AddressDto
{
Street = entity.ShippingAddress!.Street,
City = entity.ShippingAddress!.City,
},
Lines = entity.Lines.Select(x => new OrderLineDto
{
ProductName = x.ProductName,
Quantity = x.Quantity,
}).ToList(),
};

EF Core translates this to SQL JOINs automatically — no Include() calls needed.

Deep nesting (3+ levels) is supported with recursive inlining. The generator uses unique variable names (__e0, __e1, etc.) to avoid lambda parameter conflicts.

[MapProperty] with a navigation path works in projections. The generator converts null-conditional access (?.) to null-forgiving access (!.) because Expression Trees do not support ?. — EF Core handles null semantics in SQL.

[MapFrom<RoomType>]
[GenerateProjection]
public partial class RoomTypeSummaryDto
{
public string Name { get; init; } = "";
[MapProperty("Property.Name")]
public string PropertyName { get; init; } = "";
}

Generated projection:

entity => new RoomTypeSummaryDto
{
Name = entity.Name,
PropertyName = entity.Property!.Name, // !. instead of ?.
}

This translates to a SQL JOIN on the Property table.

When a nullable source maps to a non-nullable target, the generator uses null-coalescing (??) with EF Core-compatible defaults:

SourceTargetGenerated Expression
string?stringentity.Name ?? ""
int?intentity.Count ?? 0
decimal?decimalentity.Amount ?? 0m
bool?boolentity.Flag ?? false
Guid?Guidentity.Id ?? Guid.Empty
DateTime?DateTimeentity.Date ?? default(DateTime)
enum?enumentity.Status ?? default(MyEnum)

These expressions translate cleanly to SQL COALESCE.

The generator analyzes mapping paths and tracks which navigations the DTO needs. This metadata is available as a static property:

// Generated on ReservationSummaryDto
public static IReadOnlyList<string> RequiredNavigations { get; } = ["Guest", "Property"];

This tells you exactly which Include() calls are needed when using FromEntity() instead of Projection:

// When not using Projection, you need these Includes
var reservation = await db.Reservations
.Include(r => r.Guest)
.Include(r => r.Property)
.FirstOrDefaultAsync(r => r.PersistenceId == id);
var dto = ReservationSummaryDto.FromEntity(reservation);

With Projection, these navigations are resolved as SQL JOINs automatically.

Features that cannot be translated to SQL are excluded from projections with compile-time warnings:

FeatureIn FromEntityIn ProjectionDiagnostic
[MapConverter<T>]YesExcludedPRAG0320
[MapProperty(Format = "...")]YesExcludedPRAG0321
CustomizeMapping()YesIgnoredPRAG0319
BeforeMapping()YesIgnored
Circular referencesTrackedNot supported
Complex dictionary valuesYesNot supportedPRAG0322

When a property is excluded from projection, it receives default in the SQL result. Structure your DTOs accordingly — consider having a projection-safe DTO for queries and a richer DTO for single-entity display.

All three approaches produce the same SQL:

// 1. Direct Select (standard EF Core)
var dtos = await db.Properties.Select(PropertySummaryDto.Projection).ToListAsync();
// 2. SelectDto extension (Pragmatic.Mapping.EFCore, same thing + readability)
var dtos = await db.Properties.SelectDto(PropertySummaryDto.Projection).ToListAsync();
// 3. ToListDtoAsync (Pragmatic.Mapping.EFCore, convenience + tracing)
var dtos = await db.Properties.ToListDtoAsync(PropertySummaryDto.Projection);

The ToListDtoAsync and related methods add OpenTelemetry activity tracing (Pragmatic.Mapping activity source) with tags for source type, DTO type, and result count.