DTOs and mapping¶
DTOs can be written by hand, and many are: requests, dashboards and other shapes that do not mirror an entity. For DTOs that do mirror one, the optional package Coworkee.CodeGen generates them from the entity, together with the mapping in both directions. It brings the Nextended.CodeGen source generator; the mapping is either plain extension methods or a Mapperly mapper, chosen per entity.
Turn it on¶
Reference the package in the project that holds the entities, usually the module project:
<PackageReference Include="Coworkee.CodeGen" />
<!-- only for entities that map with Mapperly -->
<PackageReference Include="Riok.Mapperly" PrivateAssets="all" ExcludeAssets="runtime" />
and put a codegen.json next to the project file. The package picks it up by name; without it nothing is generated.
{
"DtoGeneration": {
"Namespace": "MyApp.Catalog",
"OutputPath": "../MyApp.Contracts/Catalog/Generated/",
"MappingOutputPath": "./Generated/",
"DeepProperties": true,
"PropertiesToIgnore": [ "TenantId", "CreatedAt", "CreatedBy", "ModifiedAt", "ModifiedBy" ],
"CreateFileHeaders": false,
"CreateRegions": false,
"DefaultMappingSettings": { "ToSourceMethodName": "ToEntity" }
}
}
| Setting | Why |
|---|---|
OutputPath |
The DTOs are written into the contracts project, so the API, the Blazor client and the SDKs share them. The contracts project needs nothing for that. |
MappingOutputPath |
The mappings stay in the module project, the only one that knows entities and DTOs. |
Namespace |
Namespace of the mappings. The module's root namespace makes brand.ToDto() available in every handler without a using. |
DeepProperties |
Takes Id from Entity into the DTO. |
PropertiesToIgnore |
Columns no DTO should carry. An attribute can set its own list. |
CreateFileHeaders: false |
Keeps the time stamp out of the files, so a build only changes them when the entity changed. |
DefaultMappingSettings |
Method names; ToDtoMethodName defaults to ToDto. |
The generated files are ordinary source files in the repository: review them in the pull request like any other change. They are only rewritten when the build sees a changed entity, and a change shows up after the next build. Delete a file to have it generated again.
Mark the entities¶
[AutoGenerateDto(Namespace = "MyApp.Contracts.Catalog")]
public sealed class Brand : AuditedEntity, IMultiTenant
{
public required string Name { get; set; }
public string? Description { get; set; }
public decimal Tax { get; set; }
public Guid TenantId { get; set; }
}
public partial class BrandDto : IBrandDto
{
public required string Name { get; set; }
public string? Description { get; set; }
public decimal Tax { get; set; }
public System.Guid Id { get; set; }
}
required members stay required, nullable members stay nullable, and the class is partial, so the contracts project can add to it. [IgnoreOnGeneration] on a property or PropertiesToIgnore on the attribute leaves members out; [GenerationPropertySetting(PropertyName = ...)] renames them. The Nextended.CodeGen reference lists every option.
Mapping with extension methods¶
This is the default. Brand gets extension methods in a partial MappingExtensions class:
The code is plain assignments you can read in MyApp.Catalog/Generated/MappingExtensions.g.Brand.cs. For a field that needs more than an assignment, GenerateBeforeAndAfterAssignPartialsInMapping = true adds partial hooks before and after the assignments.
Mapping with Mapperly¶
[AutoGenerateDto(Namespace = "MyApp.Contracts.Catalog", MappingStrategy = DtoMappingStrategy.Mapperly)]
public sealed class Product : AuditedEntity, IMultiTenant { … }
Product gets a partial Mapperly mapper instead. The call sites look the same (product.ToDto()); Mapperly writes the implementation at compile time and reports members it cannot map.
[Mapper(RequiredMappingStrategy = RequiredMappingStrategy.Target)]
[UseStaticMapper(typeof(MyApp.Catalog.MappingExtensions))]
public static partial class ProductMapper
{
public static partial ProductDto ToDto(this Product src);
[MapperIgnoreTarget("TenantId")] /* … the other ignored columns */
public static partial Product ToEntity(this ProductDto src);
public static partial void AssignTo(this Product src, [MappingTarget] ProductDto dest);
[MapperIgnoreTarget("TenantId")] /* … */
public static partial void AssignTo(this ProductDto src, [MappingTarget] Product dest);
}
UseStaticMapper lets the nested Brand reuse Brand's own mapping, so both strategies mix freely.
Overriding a generated method¶
The mapper is partial. Declare a method with the same name in your own part, and the generated part leaves it out. The same goes for the [Mapper] attribute, for example to change Mapperly's settings.
public static partial class ProductMapper
{
[MapProperty(nameof(Product.Rate), nameof(ProductDto.Rate), Use = nameof(RoundRate))]
public static partial ProductDto ToDto(this Product product);
[UserMapping(Default = false)]
private static decimal RoundRate(decimal rate) => Math.Round(rate, 2);
}
Here the product's rate goes out rounded to cents; everything else is still mapped by Mapperly. Methods are matched by name, so declaring AssignTo takes over both directions.
Why the files are on disk
Source generators cannot see each other's output. Mapperly can only implement a mapper that exists as a real file, which is why the Mapperly strategy needs OutputPath and MappingOutputPath. Without them the generated mapper contains a compile error that says so.
Which one to take¶
| Extension methods | Mapperly | |
|---|---|---|
| Extra package | none | Riok.Mapperly |
| Custom logic | partial hooks before and after the assignments | Mapperly attributes on your own declaration, any method can be replaced |
| Checks | none, unmapped members are skipped | Mapperly reports DTO members without a source |
| Good for | simple DTOs | DTOs with renames, conversions or nested objects |
Enums, generic and abstract entities always use extension methods.
Leaving it out¶
Without Coworkee.CodeGen, nothing changes: write DTOs and mappings by hand, or use the mapper you prefer. Removing the package later keeps the generated files; they are plain source from then on.