A compile-time source generator that automatically creates immutable DTO (Data Transfer Object) types from Entity Framework Core entities or any POCO classes, eliminating boilerplate code while maintaining type safety.
DKNet.EfCore.DtoGenerator is a Roslyn Incremental Source Generator that generates DTO classes at compile time. Instead of manually creating and maintaining DTO classes that mirror your entities, you simply apply the [GenerateDto] attribute to an empty partial record or class, and the generator creates all the properties and mapping methods automatically.
The generator intelligently synthesizes public init properties for every public instance readable property on the source entity, while excluding indexers and static properties. It also generates helpful mapping methods (FromEntity, ToEntity, and FromEntities) that leverage Mapster when available or fall back to property-by-property initialization.
- Compile-Time Code Generation: No runtime reflection or performance overhead
- Type-Safe DTOs: Compile-time errors prevent mismatches between entities and DTOs
- Automatic Property Mapping: Generates all properties from source entity automatically
- Mapster Integration: Leverages Mapster for efficient mapping when available
- Fallback Mapping: Provides property-by-property initialization when Mapster is not present
- Partial Class Support: Allows customization and extension of generated DTOs
- Property Exclusion: Exclude specific properties using the
Excludeparameter - Incremental Generation: Efficient generation that only regenerates when needed
- Zero Runtime Dependencies: Generated code has no runtime dependencies on the generator
DKNet.EfCore.DtoGenerator primarily serves the Application Layer and Presentation Layer of the Onion Architecture by providing clean, immutable DTOs that decouple external representations from internal domain models:
┌─────────────────────────────────────────────────────────────────┐
│ 🌐 Presentation Layer │
│ (Controllers, API Endpoints) │
│ │
│ Uses: Generated DTOs for request/response models │
│ 📄 CustomerDto, OrderDto, ProductDto │
│ ✅ Validation happens on DTOs, not domain entities │
│ 🔒 Domain entities never exposed directly to clients │
└─────────────────────────┬───────────────────────────────────────┘
│
┌─────────────────────────┴───────────────────────────────────────┐
│ 🎯 Application Layer │
│ (Use Cases, Application Services) │
│ │
│ 🎯 DKNet.EfCore.DtoGenerator - Generates DTOs at compile time │
│ 📋 Maps between domain entities and DTOs │
│ 🔄 Uses Mapster for efficient transformations │
│ ✅ Maintains separation between domain and presentation │
└─────────────────────────┬───────────────────────────────────────┘
│
┌─────────────────────────┴───────────────────────────────────────┐
│ 💼 Domain Layer │
│ (Entities, Aggregates, Domain Services) │
│ │
│ Domain entities remain pure and focused on business logic │
│ No knowledge of DTOs or external representations │
└─────────────────────────┬───────────────────────────────────────┘
│
┌─────────────────────────┴───────────────────────────────────────┐
│ 🗄️ Infrastructure Layer │
│ (Data Access, Persistence) │
│ │
│ Domain entities persist without DTO concerns │
└─────────────────────────────────────────────────────────────────┘
DTOs serve as an anti-corruption layer, preventing external API concerns from leaking into the domain model:
// Domain Entity - Pure business logic
public class Customer : AggregateRoot
{
public string Name { get; private set; }
public Email Email { get; private set; }
public CustomerStatus Status { get; private set; }
public void ActivateAccount()
{
// Business logic
}
}
// Generated DTO - External representation
[GenerateDto(typeof(Customer))]
public partial record CustomerDto;
// Auto-generated properties: Name, Email, Status (as string/primitive types)DTOs help maintain clear boundaries between bounded contexts by providing explicit translation points:
// Order Context
[GenerateDto(typeof(Order))]
public partial record OrderDto;
// Customer Context
[GenerateDto(typeof(Customer))]
public partial record CustomerSummaryDto;
// Integration between contexts uses DTOs, not domain entitiesDTOs prevent clients from directly modifying aggregate internals:
// Domain aggregate with encapsulated behavior
public class Order : AggregateRoot
{
private List<OrderItem> _items = new();
public IReadOnlyList<OrderItem> Items => _items.AsReadOnly();
public void AddItem(Product product, int quantity)
{
// Business rules enforced here
}
}
// DTO for reading - no behavior, just data
[GenerateDto(typeof(Order))]
public partial record OrderDto;Generated DTOs enable complete decoupling between presentation and domain layers:
// Application Service - translates between layers
public class CustomerService
{
private readonly ICustomerRepository _repository;
public async Task<CustomerDto> GetCustomerAsync(Guid id)
{
var customer = await _repository.GetByIdAsync(id);
return CustomerDto.FromEntity(customer); // Generated mapping
}
public async Task<Guid> CreateCustomerAsync(CreateCustomerDto dto)
{
var customer = dto.ToEntity(); // Generated mapping
await _repository.AddAsync(customer);
return customer.Id;
}
}DTOs with generated mapping methods are easily testable:
[Fact]
public void CustomerDto_ShouldMapFromEntity()
{
// Arrange
var customer = new Customer("John Doe", "john@example.com");
// Act
var dto = CustomerDto.FromEntity(customer);
// Assert
dto.Name.ShouldBe("John Doe");
dto.Email.ShouldBe("john@example.com");
}Different DTO versions can be generated from the same entity:
// V1 API
[GenerateDto(typeof(Customer))]
public partial record CustomerDtoV1;
// V2 API - exclude sensitive fields
[GenerateDto(typeof(Customer), Exclude = new[] { "InternalNotes", "CreditScore" })]
public partial record CustomerDtoV2;<ItemGroup>
<PackageReference Include="DKNet.EfCore.DtoGenerator" Version="1.0.0"
PrivateAssets="all" OutputItemType="Analyzer" />
<!-- Optional but recommended for efficient mapping -->
<PackageReference Include="Mapster" Version="7.4.0" />
</ItemGroup>Add these properties to your .csproj file:
<PropertyGroup>
<EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles>
<CompilerGeneratedFilesOutputPath>$(BaseIntermediateOutputPath)Generated</CompilerGeneratedFilesOutputPath>
<!-- Force analyzer to reload on every build to avoid caching issues -->
<EnforceExtendedAnalyzerRules>true</EnforceExtendedAnalyzerRules>
</PropertyGroup>Configure properties to exclude globally across all DTOs:
<!-- Define global exclusions (comma or semicolon separated) -->
<PropertyGroup>
<DtoGenerator_GlobalExclusions>CreatedBy,UpdatedBy,CreatedAt,UpdatedAt</DtoGenerator_GlobalExclusions>
</PropertyGroup>
<!-- Make the property visible to the source generator -->
<ItemGroup>
<CompilerVisibleProperty Include="DtoGenerator_GlobalExclusions" />
</ItemGroup>This is particularly useful for:
- Audit properties (CreatedBy, UpdatedBy, CreatedAt, UpdatedAt)
- Internal tracking fields
- Properties that should never be exposed in DTOs
- Reducing boilerplate in DTO attribute declarations
For debugging and verification, copy generated DTOs to your project:
<!-- Custom target to copy generated DTOs to project/GeneratedDtos folder -->
<Target Name="CopyGeneratedDtosToOutputFolder" AfterTargets="CoreCompile"
Condition="Exists('$(CompilerGeneratedFilesOutputPath)')">
<ItemGroup>
<GeneratedDtoFiles Include="$(CompilerGeneratedFilesOutputPath)\**\*Dto.g.cs"/>
</ItemGroup>
<MakeDir Directories="$(ProjectDir)GeneratedDtos" Condition="'@(GeneratedDtoFiles)' != ''"/>
<Copy SourceFiles="@(GeneratedDtoFiles)"
DestinationFiles="$(ProjectDir)GeneratedDtos\%(Filename)%(Extension)"
SkipUnchangedFiles="false"
OverwriteReadOnlyFiles="true"
Condition="'@(GeneratedDtoFiles)' != ''"/>
<Message Text="Copied %(Filename)%(Extension) to $(ProjectDir)GeneratedDtos"
Importance="high" Condition="'@(GeneratedDtoFiles)' != ''"/>
</Target>
<!-- Exclude generated DTOs from compilation, but keep them visible in Solution Explorer -->
<ItemGroup>
<Compile Remove="GeneratedDtos\**\*.cs"/>
<None Include="GeneratedDtos\**\*.cs"/>
</ItemGroup>// Entity
public class Product
{
public Guid Id { get; set; }
public string Name { get; set; } = string.Empty;
public string Description { get; set; } = string.Empty;
public decimal Price { get; set; }
public int StockQuantity { get; set; }
public DateTime CreatedAt { get; set; }
}
// DTO Declaration
[GenerateDto(typeof(Product))]
public partial record ProductDto;
// Usage
var product = await repository.GetByIdAsync(productId);
var dto = ProductDto.FromEntity(product);
return Results.Ok(dto);// Exclude internal/sensitive properties
[GenerateDto(typeof(Product), Exclude = new[] { "StockQuantity", "CreatedAt" })]
public partial record ProductSummaryDto;
// Generated DTO will only include: Id, Name, Description, PriceConfigure global exclusions via MSBuild properties to exclude common audit or internal properties across all DTOs:
<!-- In your .csproj file -->
<PropertyGroup>
<DtoGenerator_GlobalExclusions>CreatedBy,UpdatedBy,CreatedAt,UpdatedAt</DtoGenerator_GlobalExclusions>
</PropertyGroup>
<!-- Make the property visible to the source generator -->
<ItemGroup>
<CompilerVisibleProperty Include="DtoGenerator_GlobalExclusions" />
</ItemGroup>Global exclusions are applied to all DTOs by default:
// This DTO will automatically exclude CreatedBy, UpdatedBy, CreatedAt, UpdatedAt
[GenerateDto(typeof(Product))]
public partial record ProductDto;
// Local exclusions are combined with global exclusions
[GenerateDto(typeof(Product), Exclude = ["InternalNotes"])]
public partial record ProductSummaryDto; // Excludes global properties + InternalNotes
// Include parameter overrides global exclusions
[GenerateDto(typeof(Product), Include = ["Id", "Name", "CreatedAt"])]
public partial record ProductNameDto; // Only includes these 3 properties, ignoring global exclusionsBenefits of Global Exclusions:
- Centralized configuration for common exclusions
- Reduces boilerplate in DTO declarations
- Consistent exclusion of audit/internal properties
- Easy to maintain across large projects
[GenerateDto(typeof(Product))]
public partial record ProductDto
{
// Add computed property
public string DisplayPrice => $"${Price:N2}";
// Add custom property not in entity
public bool IsAvailable => StockQuantity > 0;
}// Map multiple entities to DTOs
var products = await repository.GetAllAsync();
var dtos = ProductDto.FromEntities(products);
return Results.Ok(dtos);
// Async with EF Core and Mapster
var dtos = await dbContext.Products
.ProjectToType<ProductDto>() // Mapster extension
.ToListAsync();public class Order
{
public Guid Id { get; set; }
public string OrderNumber { get; set; } = string.Empty;
public Customer Customer { get; set; } = null!;
public List<OrderItem> Items { get; set; } = new();
public OrderStatus Status { get; set; }
}
// Generate DTOs for related entities
[GenerateDto(typeof(Customer))]
public partial record CustomerDto;
[GenerateDto(typeof(OrderItem))]
public partial record OrderItemDto;
[GenerateDto(typeof(Order))]
public partial record OrderDto;
// Configure Mapster to map nested objects
TypeAdapterConfig<Order, OrderDto>
.NewConfig()
.Map(dest => dest.Customer, src => CustomerDto.FromEntity(src.Customer))
.Map(dest => dest.Items, src => OrderItemDto.FromEntities(src.Items));// Read DTO
[GenerateDto(typeof(Customer))]
public partial record CustomerDto;
// Create DTO - exclude Id and timestamps
[GenerateDto(typeof(Customer), Exclude = new[] { "Id", "CreatedAt", "UpdatedAt" })]
public partial record CreateCustomerDto;
// Update DTO - exclude Id and CreatedAt
[GenerateDto(typeof(Customer), Exclude = new[] { "Id", "CreatedAt" })]
public partial record UpdateCustomerDto;
// API Endpoint
app.MapPost("/customers", async (CreateCustomerDto dto, ICustomerRepository repo) =>
{
var customer = dto.ToEntity();
await repo.AddAsync(customer);
return Results.Created($"/customers/{customer.Id}", CustomerDto.FromEntity(customer));
});// Configure mappings at startup
public static class MappingConfiguration
{
public static void Configure()
{
TypeAdapterConfig.GlobalSettings.Scan(typeof(Program).Assembly);
// Custom mapping rules
TypeAdapterConfig<Customer, CustomerDto>
.NewConfig()
.Map(dest => dest.FullName, src => $"{src.FirstName} {src.LastName}")
.Ignore(dest => dest.InternalId);
}
}// Efficient database queries with projection
public async Task<List<ProductDto>> GetProductsAsync()
{
return await _dbContext.Products
.Where(p => p.IsActive)
.ProjectToType<ProductDto>() // Mapster projects directly from DB
.ToListAsync();
}// Register custom adapter for value objects
TypeAdapterConfig<Email, string>
.NewConfig()
.MapWith(email => email.Value);
TypeAdapterConfig<string, Email>
.NewConfig()
.MapWith(str => new Email(str));namespace MyApp.V1.Dtos
{
[GenerateDto(typeof(Product))]
public partial record ProductDto;
}
namespace MyApp.V2.Dtos
{
[GenerateDto(typeof(Product), Exclude = new[] { "InternalCode" })]
public partial record ProductDto
{
// V2 adds new computed field
public string Category { get; init; } = string.Empty;
}
}// Read model - all properties
[GenerateDto(typeof(Order))]
public partial record OrderDto;
// Command model - only writable properties
[GenerateDto(typeof(Order), Exclude = new[] { "Id", "OrderNumber", "CreatedAt", "Status" })]
public partial record CreateOrderCommand;
// Update model - fewer properties
[GenerateDto(typeof(Order), Exclude = new[] { "Id", "OrderNumber", "CreatedAt" })]
public partial record UpdateOrderCommand;public class Order
{
public Guid Id { get; set; }
public Address ShippingAddress { get; set; } = null!;
}
[GenerateDto(typeof(Order))]
public partial record OrderDto
{
// Override to flatten
public new string ShippingAddress { get; init; } = string.Empty;
}
// Configure flattening in Mapster
TypeAdapterConfig<Order, OrderDto>
.NewConfig()
.Map(dest => dest.ShippingAddress,
src => $"{src.ShippingAddress.Street}, {src.ShippingAddress.City}");DtoGenerator works at compile time, not runtime:
- ✅ No runtime reflection overhead
- ✅ No runtime code generation
- ✅ All mappings are statically compiled
- ✅ Full IDE IntelliSense support
With Mapster (Recommended):
- Uses compiled expressions for fast mapping
- Supports query projection for efficient database queries
- Caches mapping expressions
Without Mapster (Fallback):
- Simple property-by-property assignment
- No reflection at runtime
- Suitable for simple scenarios
// Efficient collection mapping with Mapster
var dtos = await dbContext.Products
.ProjectToType<ProductDto>() // Maps in database, not in memory
.ToListAsync();
// vs. inefficient approach
var products = await dbContext.Products.ToListAsync(); // Load all entities
var dtos = products.Select(p => ProductDto.FromEntity(p)); // Map in memoryEnsure these properties are set:
<EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles>
<CompilerGeneratedFilesOutputPath>$(BaseIntermediateOutputPath)Generated</CompilerGeneratedFilesOutputPath>Check obj/Generated folder for generated files.
- Clean and rebuild the solution
- Ensure
EnforceExtendedAnalyzerRulesis set totrue - Check for analyzer warnings in the Error List
If you get duplicate property errors:
- Check if properties are declared in both entity and DTO partial
- Use the
Excludeparameter to skip duplicates - Override properties explicitly if needed
Ensure Mapster package reference is added:
<PackageReference Include="Mapster" Version="7.4.0" />Generator checks for Mapster at compile time.
If global exclusions aren't being applied:
-
Verify the MSBuild property is correctly set in your
.csproj:<PropertyGroup> <DtoGenerator_GlobalExclusions>Property1,Property2</DtoGenerator_GlobalExclusions> </PropertyGroup>
-
Ensure
CompilerVisiblePropertyis configured:<ItemGroup> <CompilerVisibleProperty Include="DtoGenerator_GlobalExclusions" /> </ItemGroup>
-
Clean and rebuild to regenerate all DTOs
-
Check generated files in
obj/Generatedor your configured output folder
The generator reports several diagnostic codes:
-
DKDTOGEN001: DTO generation warning - entity type issues
- Entity type not found or not accessible
- Generic entity types (limited support)
- Circular references in entity properties
-
DKDTOGEN002: No properties found for entity
- Entity type wasn't resolved correctly
- Entity has no public readable properties
-
DKDTOGEN003: Properties filtered from DTO (informational)
- Shows how many properties were included/excluded
-
DKDTOGEN004: Include and Exclude are mutually exclusive
- Cannot use both Include and Exclude on the same DTO
- Choose one approach or the other
-
DKDTOGEN005: Include parameter ignores global exclusions (informational)
- When using Include, global exclusions are not applied
- Only specified properties will be included
Check build output for specific diagnostic messages.
Navigation and collection properties are included as shallow copies:
- Complex reference types (e.g., navigation properties like
Customer,Order) are generated with the= null!;initializer to satisfy C# nullable reference type requirements - Collections are initialized with
= [];for non-nullable collection types - For deep copying:
- Configure Mapster to handle nested objects
- Override properties in partial DTO
- Create separate DTOs for related entities
- Exclude navigation properties using the
Excludeparameter if they're not needed
// Entity with navigation property
public class Order
{
public Guid Id { get; set; }
public Customer Customer { get; set; } = null!; // Navigation property
public List<OrderItem> Items { get; set; } = new();
}
// Generated DTO includes navigation property with null! initializer
[GenerateDto(typeof(Order))]
public partial record OrderDto;
// Generated: public Customer Customer { get; init; } = null!;
// Generated: public List<OrderItem> Items { get; init; } = [];
// Alternative: Exclude navigation properties if not needed
[GenerateDto(typeof(Order), Exclude = new[] { "Customer", "Items" })]
public partial record OrderSummaryDto;- Non-nullable strings receive the
requiredmodifier - Non-nullable collections are initialized with
= [] - Non-nullable complex reference types (navigation properties, custom classes) receive
= null!;initializer to satisfy compiler null-state analysis
Limited support for generic entity types. DTO shells must be non-generic.
Entity inheritance is not automatically handled. Create separate DTOs for each entity type or use Mapster configuration.
// Preferred
[GenerateDto(typeof(Product))]
public partial record ProductDto;
// Instead of class
[GenerateDto(typeof(Product))]
public partial class ProductDto; // Works but records are more idiomatic for DTOs[GenerateDto(typeof(Customer))]
public partial record CustomerDto; // Full read model
[GenerateDto(typeof(Customer), Exclude = new[] { "Id", "CreatedAt" })]
public partial record CreateCustomerDto; // Create command
[GenerateDto(typeof(Customer), Exclude = new[] { "Id", "Email" })]
public partial record CustomerSummaryDto; // List view/Features
/Customers
/Entities
Customer.cs
/Dtos
CustomerDto.cs
CreateCustomerDto.cs
UpdateCustomerDto.cs
/Orders
/Entities
Order.cs
/Dtos
OrderDto.cs
// Startup.cs or Program.cs
var config = TypeAdapterConfig.GlobalSettings;
config.Scan(typeof(Program).Assembly);
config.RequireExplicitMapping = false;
config.RequireDestinationMemberSource = false;// ✅ Good - DTOs at API boundary
public async Task<CustomerDto> GetCustomerAsync(Guid id)
{
var customer = await _repository.GetByIdAsync(id); // Domain entity internally
return CustomerDto.FromEntity(customer); // Convert to DTO for API
}
// ❌ Bad - DTOs in domain layer
public async Task ProcessOrderAsync(OrderDto dto) // DTOs shouldn't be in domain servicesDtoGenerator works seamlessly with other DKNet components:
public class CustomerService
{
private readonly IReadRepository<Customer> _repository;
public async Task<PagedList<CustomerDto>> GetCustomersAsync(int page, int pageSize)
{
return await _repository.Gets()
.ProjectToType<CustomerDto>() // DtoGenerator + Mapster
.ToPagedListAsync(page, pageSize); // DKNet.EfCore.Repos
}
}public record GetCustomerQuery : IWitResponse<CustomerDto>
{
public required Guid CustomerId { get; init; }
}
public class GetCustomerHandler : IHandler<GetCustomerQuery, CustomerDto>
{
private readonly ICustomerRepository _repository;
public async Task<CustomerDto?> OnHandle(GetCustomerQuery request, CancellationToken ct)
{
var customer = await _repository.FindAsync(request.CustomerId, ct);
return customer != null ? CustomerDto.FromEntity(customer) : null;
}
}- DKNet.EfCore.Repos - Repository pattern implementations
- DKNet.EfCore.Repos.Abstractions - Repository abstractions
- Architecture Guide - Understanding DDD and Onion Architecture
- Examples & Recipes - Practical implementation patterns
💡 Pro Tip: Use DtoGenerator for all API data transfer needs to maintain a clean separation between your domain model and external representations. Combined with Mapster, it provides a powerful, type-safe, and performant solution for object mapping in DDD applications.