Zum Inhalt

DTOs und Mapping

DTOs lassen sich von Hand schreiben, und viele sind es auch: Requests, Dashboards und andere Formen, die keine Entity abbilden. Für DTOs, die eine Entity abbilden, erzeugt das optionale Paket Coworkee.CodeGen sie aus der Entity, zusammen mit dem Mapping in beide Richtungen. Es bringt den Source-Generator Nextended.CodeGen mit; gemappt wird entweder mit einfachen Extension-Methoden oder mit einem Mapperly-Mapper, pro Entity wählbar.

Einschalten

Das Paket gehört in das Projekt mit den Entities, meist das Modulprojekt:

MyApp.Catalog/MyApp.Catalog.csproj
<PackageReference Include="Coworkee.CodeGen" />
<!-- nur für Entities, die mit Mapperly mappen -->
<PackageReference Include="Riok.Mapperly" PrivateAssets="all" ExcludeAssets="runtime" />

Daneben liegt eine codegen.json. Das Paket findet sie über den Namen; ohne sie wird nichts erzeugt.

MyApp.Catalog/codegen.json
{
  "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" }
  }
}
Einstellung Wozu
OutputPath Die DTOs landen im Contracts-Projekt, so teilen API, Blazor-Client und SDKs sie. Das Contracts-Projekt braucht dafür nichts.
MappingOutputPath Die Mappings bleiben im Modulprojekt, dem einzigen, das Entities und DTOs kennt.
Namespace Namespace der Mappings. Mit dem Root-Namespace des Moduls steht brand.ToDto() in jedem Handler ohne using bereit.
DeepProperties Übernimmt die Id aus Entity ins DTO.
PropertiesToIgnore Spalten, die kein DTO tragen soll. Ein Attribut kann eine eigene Liste setzen.
CreateFileHeaders: false Hält den Zeitstempel aus den Dateien heraus; ein Build ändert sie nur, wenn sich die Entity geändert hat.
DefaultMappingSettings Methodennamen; ToDtoMethodName ist standardmäßig ToDto.

Die erzeugten Dateien sind normale Quelldateien im Repository und werden im Pull Request geprüft wie jede andere Änderung. Neu geschrieben werden sie nur, wenn der Build eine geänderte Entity sieht; eine Änderung ist nach dem nächsten Build sichtbar. Wer eine Datei löscht, bekommt sie beim nächsten Build neu.

Entities markieren

MyApp.Catalog/Domain/Brand.cs
[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; }
}
MyApp.Contracts/Catalog/Generated/BrandDto.g.cs (erzeugt)
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-Member bleiben required, nullbare bleiben nullbar, und die Klasse ist partial, das Contracts-Projekt kann sie also ergänzen. [IgnoreOnGeneration] an einer Property oder PropertiesToIgnore am Attribut lässt Member weg, [GenerationPropertySetting(PropertyName = ...)] benennt sie um. Alle Optionen stehen in der Nextended.CodeGen-Referenz.

Mapping mit Extension-Methoden

Das ist der Standard. Brand bekommt Extension-Methoden in einer partiellen Klasse MappingExtensions:

BrandDto dto = brand.ToDto();
Brand entity = dto.ToEntity();
dto.AssignTo(existingBrand);

Der Code besteht aus einfachen Zuweisungen und steht lesbar in MyApp.Catalog/Generated/MappingExtensions.g.Brand.cs. Braucht ein Feld mehr als eine Zuweisung, ergänzt GenerateBeforeAndAfterAssignPartialsInMapping = true partielle Hooks vor und nach den Zuweisungen.

Mapping mit Mapperly

MyApp.Catalog/Domain/Product.cs
[AutoGenerateDto(Namespace = "MyApp.Contracts.Catalog", MappingStrategy = DtoMappingStrategy.Mapperly)]
public sealed class Product : AuditedEntity, IMultiTenant { … }

Product bekommt stattdessen einen partiellen Mapperly-Mapper. Die Aufrufe sehen gleich aus (product.ToDto()); Mapperly schreibt die Implementierung beim Kompilieren und meldet Member, die es nicht abbilden kann.

MyApp.Catalog/Generated/ProductMapper.g.cs (erzeugt)
[Mapper(RequiredMappingStrategy = RequiredMappingStrategy.Target)]
[UseStaticMapper(typeof(MyApp.Catalog.MappingExtensions))]
public static partial class ProductMapper
{
    public static partial ProductDto ToDto(this Product src);
    [MapperIgnoreTarget("TenantId")] /* … die übrigen ignorierten Spalten */
    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);
}

Über UseStaticMapper nutzt die verschachtelte Brand das Mapping von Brand, beide Strategien lassen sich also beliebig mischen.

Eine erzeugte Methode überschreiben

Der Mapper ist partial. Eine Methode mit demselben Namen im eigenen Teil der Klasse ersetzt die erzeugte, der erzeugte Teil lässt sie weg. Dasselbe gilt für das [Mapper]-Attribut, etwa um Einstellungen von Mapperly zu ändern.

MyApp.Catalog/ProductMapper.cs
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);
}

Hier geht der Preis des Produkts auf Cent gerundet hinaus; alles andere mappt weiterhin Mapperly. Methoden werden über den Namen zugeordnet, ein eigenes AssignTo übernimmt also beide Richtungen.

Warum die Dateien auf der Platte liegen

Source-Generatoren sehen die Ausgabe anderer Generatoren nicht. Mapperly kann nur einen Mapper implementieren, der als echte Datei existiert; deshalb braucht die Mapperly-Strategie OutputPath und MappingOutputPath. Fehlen sie, enthält der erzeugte Mapper einen Kompilierfehler, der genau das sagt.

Welche Variante

Extension-Methoden Mapperly
Zusätzliches Paket keins Riok.Mapperly
Eigene Logik partielle Hooks vor und nach den Zuweisungen Mapperly-Attribute an der eigenen Deklaration, jede Methode ersetzbar
Prüfungen keine, nicht abbildbare Member werden übersprungen Mapperly meldet DTO-Member ohne Quelle
Geeignet für einfache DTOs DTOs mit Umbenennungen, Konvertierungen oder verschachtelten Objekten

Enums sowie generische und abstrakte Entities mappen immer mit Extension-Methoden.

Ohne das Paket

Ohne Coworkee.CodeGen ändert sich nichts: DTOs und Mappings von Hand schreiben oder den Mapper der Wahl verwenden. Wird das Paket später entfernt, bleiben die erzeugten Dateien erhalten und sind ab dann normaler Quellcode.