Skip to content

Code Generation

Cargonerds leans heavily on build-time code generation so that the hand-written code stays small and the boilerplate (DTOs, mappers, type-level enum handles, HTTP controllers and client proxies) is derived mechanically from a single source of truth — the domain model and the application services.

There are four distinct generators in play, in two categories:

# Generator Kind Triggered by Produces
1 Nextended.CodeGen Roslyn source generator (NuGet) [AutoGenerateDto] on entities + CodeGen.config.json DTO classes + interfaces
2 StaticEnumGenerator Roslyn source generator (in-repo) [StaticEnum] on enums A type-per-enum-member "static enum" handle
3 Riok.Mapperly Roslyn source generator (NuGet) [Mapper] partial class + partial map methods Entity ⇄ DTO mapping implementations
4 ABP auto-API + dynamic proxies ABP runtime + CLI tooling App-service classes / ConventionalControllers REST controllers, C#/TS client proxies

Generators 1–3 run inside dotnet build — there is no separate generation step and (for the DTO generator) the output is committed to the repo. Generator 4 is partly runtime (controllers are materialised in the API host at startup) and partly CLI (the client proxies are refreshed on demand).

The big picture

The Hub module defines its entities once. The DTO generator turns each [AutoGenerateDto] entity into a *Dto + I*Dto pair, Mapperly turns those into compiled mappers, ABP turns the application services that use those DTOs into REST endpoints, and the ABP CLI turns those endpoints into typed C#/TypeScript clients for the Blazor admin and the Next.js frontend.

flowchart LR
    E["Domain entity<br/>[AutoGenerateDto]"] -->|Nextended.CodeGen| D["AddressDto.g.cs<br/>(class + interface)"]
    E -->|Riok.Mapperly| M["DtoMapper<br/>entity.MapTo&lt;TDto&gt;()"]
    EN["Enum<br/>[StaticEnum]"] -->|StaticEnumGenerator| S["StaticServiceType.g.cs"]
    D --> AS["Application service<br/>(uses DTOs)"]
    M --> AS
    AS -->|ABP ConventionalControllers| API["REST controllers<br/>(API host)"]
    API -->|abp generate-proxy| CL["C# / TS client proxies"]

1. DTO generation (Nextended.CodeGen)

The Hub module annotates domain entities with [AutoGenerateDto]; the Nextended.CodeGen Roslyn source generator emits the corresponding DTO classes and interfaces at build time. This keeps the *Dto shapes in lock-step with the entities without an AutoMapper-style runtime reflection cost, and without anyone hand-maintaining ~190 DTOs.

For background on what DTOs are and why ABP separates them from entities, see the ABP Data Transfer Objects guide and the local DTOs page.

How it is wired

The generator and its config are referenced in modules/hub/src/Hub.Domain/Hub.Domain.csproj:

<ItemGroup>
  <PackageReference Include="Riok.Mapperly" />
  <!-- ... -->
  <PackageReference Include="Nextended.CodeGen" />
</ItemGroup>

<ItemGroup>
  <AdditionalFiles Include="CodeGen.config.json" />
</ItemGroup>

<ItemGroup>
  <ProjectReference Include="..\Hub.Domain.Shared\Hub.Domain.Shared.csproj" />
  <ProjectReference Include="..\Hub.SourceGenerators\Hub.SourceGenerators.csproj"
                    OutputItemType="Analyzer" ReferenceOutputAssembly="false" />
</ItemGroup>

AdditionalFiles, not an embedded resource

CodeGen.config.json is exposed to the analyzer through MSBuild's <AdditionalFiles> item — that is how a source generator is allowed to read a project file. The same project also references the in-repo Hub.SourceGenerators analyzer (generator #2) with OutputItemType="Analyzer" ReferenceOutputAssembly="false", the standard way to consume a source generator project without taking a normal assembly reference on it.

Annotating an entity

Entities opt in with [AutoGenerateDto(...)] (the attribute comes from Nextended.Core.Attributes). A simple example (modules/hub/src/Hub.Domain/Base/ContainerShipmentBase.cs):

using Nextended.Core.Attributes;

namespace Hub.Base;

[AutoGenerateDto(GenerateMapping = false)]
public class ContainerShipmentBase
{
    public Guid ContainersId { get; set; }
    public virtual Container Container { get; set; }

    public Guid ShipmentsId { get; set; }

    [GenerationPropertySetting(MapWithClassMapper = true)]
    public virtual ShipmentBase ShipmentBase { get; set; }

    public bool? IsMagayaContainerTrackingEnabled { get; set; }
}

The far more important annotation is on the base entity, because it generates DTOs for the entire derived tree in one shot (modules/hub/src/Hub.Domain/Base/HubBaseGuidEntity.cs, namespace Hub.Base):

[AutoGenerateDto(
    AutoGenerateDerived = true,
    BaseType = "Volo.Abp.Application.Dtos.IEntityDto<Guid>",
    PropertiesToIgnore = [
        nameof(AuditedAggregateRoot.ExtraProperties),
        nameof(AuditedAggregateRoot.ConcurrencyStamp),
        nameof(AuditedAggregateRoot.LastModifierId),
        nameof(AuditedAggregateRoot.CreatorId),
    ],
    KeepAttributesOnGeneratedClass = false,
    DefaultPropertyInterfaceAccess = InterfaceProperty.GetAndSet,
    KeepPropertyAttributesOnGeneratedClass = false,
    GenerateMapping = false,
    KeepPropertyAttributesOnGeneratedInterface = false
)]
[ProvideEdm(ProvideInherits = true)]
public class HubBaseGuidEntity : AuditedAggregateRoot<Guid>, IGuidEntity
{
    internal void SetIdInternal(Guid id) => Id = id;
}

Key choices on the base entity:

  • AutoGenerateDerived = true — every non-abstract subclass of HubBaseGuidEntity gets its own DTO; you annotate the base once, not 190 entities.
  • BaseType = "...IEntityDto<Guid>" — the generated root DTO implements ABP's IEntityDto<Guid>, so the DTO hierarchy mirrors the entity hierarchy and carries an Id.
  • PropertiesToIgnore — ABP audit/infrastructure columns (ExtraProperties, ConcurrencyStamp, LastModifierId, CreatorId) are deliberately kept off the wire.
  • GenerateMapping = false — the generator does not emit mappers; mapping is Mapperly's job (see §3).
  • [ProvideEdm(ProvideInherits = true)] — a separate Hub attribute (Hub.Attributes) that feeds the OData EDM model with the inherited type hierarchy. It is unrelated to DTO generation but lives on the same base type.

Configuration — CodeGen.config.json

The generator's behaviour is controlled by modules/hub/src/Hub.Domain/CodeGen.config.json (JSON with comments — the generator tolerates // lines). The full Hub config:

{
  "DtoGeneration": {
    // The whole DtoGeneration section is optional and can also be overridden on the attributes.
    "DeepProperties": true,
    "GenerateMappings": false,
    "OneFilePerClass": true,
    "CreateRegions": false,
    "CreateComments": false,
    "CreateFileHeaders": false,
    "GeneratePartial": true,
    "GenerateMapping": false,
    "Namespace": "Hub.Application.Contracts.Shipments",
    "Suffix": "Dto",
    "OutputPath": "../Hub.Application.Contracts/Generated/",
    "MappingOutputPath": "../Hub.Application/Extensions/Generated/"
  }
}

The settings that actually shape the output:

Setting Value Effect
Suffix Dto AddressAddressDto / IAddressDto
Namespace Hub.Application.Contracts.Shipments All generated DTOs land in this one namespace
OutputPath ../Hub.Application.Contracts/Generated/ DTOs are written into the Contracts project
MappingOutputPath ../Hub.Application/Extensions/Generated/ Where mappers would go (unused — see below)
OneFilePerClass true One *.g.cs file per DTO (e.g. AddressDto.g.cs)
DeepProperties true Navigation properties become nested DTOs, recursively
GeneratePartial true DTOs are partial, so they can be extended by hand
GenerateMapping / GenerateMappings false No mappers emitted — Mapperly owns mapping

OutputPath writes into a sibling project, and the files are committed

Unlike a typical source generator (which keeps output in the in-memory compiler tree), this generator writes real *.g.cs files to Hub.Application.Contracts/Generated/. Those files are checked into source control (~190 of them, e.g. AddressDto.g.cs, CalculatedShipmentDto.g.cs, OrganizationDto.g.cs). They are regenerated on dotnet build, so never hand-edit a *.g.cs file — your change will be overwritten on the next build. If you need to add members, use the partial class (GeneratePartial: true) in a separate file. Because MappingOutputPath points at Hub.Application/Extensions/Generated/ but mapping is disabled, that folder stays empty.

What a generated DTO looks like

For each entity the generator emits both an interface and a class, each partial, with deep navigation properties projected to their own DTOs. Excerpt from the generated modules/hub/src/Hub.Application.Contracts/Generated/AddressDto.g.cs:

namespace Hub.Application.Contracts.Shipments {
    public partial interface IAddressDto : ICustomerOwnedGuidEntityDto
    {
        string Name { get; set; }
        string? AddressLine1 { get; set; }
        ICountryDto? Country { get; set; }
        IOrganizationDto? Organization { get; set; }
        // ...
    }

    public partial class AddressDto : CustomerOwnedGuidEntityDto, IAddressDto
    {
        public string Name { get; set; }
        public string? AddressLine1 { get; set; }
        public CountryDto? Country { get; set; }
        public OrganizationDto? Organization { get; set; }
        // explicit interface impls bridge IAddressDto.Country <-> CountryDto, etc.
    }
}

Note how DeepProperties: true turns Country/Organization navigations into CountryDto / OrganizationDto, and how the DTO inheritance (AddressDto : CustomerOwnedGuidEntityDto) mirrors the entity inheritance, anchored by AutoGenerateDerived on HubBaseGuidEntity.

Disabling generation in hosts

Some host projects reference the analyzer transitively (through their project graph) but must not themselves run the generator. They opt out with a minimal config:

  • src/Cargonerds.AuthServer/CodeGen.config.json
  • src/Cargonerds.Web.Public/CodeGen.config.json
{
  "DtoGeneration": {
    "DisableGeneration": true
  }
}

Why those two

These hosts pull in the analyzer transitively but own no [AutoGenerateDto] entities of their own; "DisableGeneration": true short-circuits the generator so the build does not waste time (or emit stray files) for them.


2. Static enum generation (Hub.SourceGenerators)

This is the repo's own Roslyn generator, living in modules/hub/src/Hub.SourceGenerators/. It is a small, focused IIncrementalGenerator that turns an enum into a type-level handle: a sealed class per enum member, so a single enum value can be referenced as a generic type argument, not just a runtime value.

The generator

modules/hub/src/Hub.SourceGenerators/StaticEnumGenerator.cs:

[Generator(LanguageNames.CSharp)]
public sealed class StaticEnumGenerator : IIncrementalGenerator
{
    private const string AttributeFullName = "Hub.Domain.Shared.Attributes.StaticEnumAttribute";

    public void Initialize(IncrementalGeneratorInitializationContext context)
    {
        var enumsWithAttribute = context
            .SyntaxProvider.ForAttributeWithMetadataName(
                AttributeFullName,
                (_, _) => true,
                (ctx, _) => (INamedTypeSymbol)ctx.TargetSymbol)
            .Where(static m => m is not null)!;

        context.RegisterSourceOutput(enumsWithAttribute,
            static (spc, enumSymbol) => Execute(spc, enumSymbol));
    }
    // Execute(...) emits: context.AddSource($"Static{enumSymbol.Name}.g.cs", ...)
}

It keys on ForAttributeWithMetadataName(...) — the modern, cached incremental-generator entry point — looking for the trigger attribute modules/hub/src/Hub.Domain.Shared/Attributes/StaticEnumAttribute.cs:

[AttributeUsage(AttributeTargets.Enum)]
public sealed class StaticEnumAttribute : Attribute { }

For any enum tagged [StaticEnum], it emits Static{EnumName}.g.cs containing an abstract Static{Enum} class with a sealed nested type per member, an implicit conversion back to the enum, a Type → enum lookup dictionary, and a ToEnum<T>() helper.

Example trigger

The only enum currently tagged is ServiceType (modules/hub/src/Hub.Domain/Enums/ServiceType.cs):

[AutoGenerateDto(GenerateMapping = false)]
[StaticEnum]
public enum ServiceType
{
    CW1 = 1,
    Magaya = 2,
    HapagLloyd = 4,
    Rohlig = 5,
    Vizion = 6,
    Pricing = 7,
    UnitsNet = 8,
    Google = 9,
}

Note it carries both attributes: [StaticEnum] drives generator #2, and [AutoGenerateDto] drives generator #1 (so ServiceTypeDto exists too — used e.g. in MappingExtensions).

From this the generator produces (conceptually) a StaticServiceType abstract class with a sealed nested CW1, Magaya, … type each exposing Value, plus StaticServiceType.ToEnum<T>(). The effect is that a specific enum value can be passed as a generic type parameter constrained to StaticServiceType.

The generator project

modules/hub/src/Hub.SourceGenerators/Hub.SourceGenerators.csproj:

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>netstandard2.0</TargetFramework>
    <LangVersion>preview</LangVersion>
    <Nullable>enable</Nullable>
    <IsPackable>false</IsPackable>
    <IncludeBuildOutput>false</IncludeBuildOutput>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Microsoft.CodeAnalysis.CSharp" PrivateAssets="all" />
    <PackageReference Include="Microsoft.CodeAnalysis.Analyzers" PrivateAssets="all" />
  </ItemGroup>
</Project>

Why netstandard2.0 for a .NET 10 solution?

Roslyn source generators (and analyzers) must target netstandard2.0 — that is the contract the C# compiler loads them against, regardless of the application's target framework. IsPackable=false + IncludeBuildOutput=false keep this from shipping as a normal assembly, and PrivateAssets="all" on the Roslyn references stops them flowing to consumers. The consuming Hub.Domain.csproj references it as an analyzer (OutputItemType="Analyzer" ReferenceOutputAssembly="false").

Generated Static*.g.cs output is in-memory only

Unlike the Nextended DTO files, the StaticEnumGenerator output is not written to disk in this build configuration (there is no EmitCompilerGeneratedFiles setting). The generated types exist in the compiler's in-memory generated tree and are fully usable from code, but you will not find Static*.g.cs files in the source tree. To inspect them, navigate to the generated symbol in an IDE or temporarily enable EmitCompilerGeneratedFiles.


3. Mapping (Riok.Mapperly)

Both the DTO generator and the entity [AutoGenerateDto] attributes set GenerateMapping = false, because mapping is delegated to Riok.Mapperly — a compile-time mapper generator. This is the mechanism ABP documents under Object-to-object mapping & Mapperly. Mapperly was chosen over AutoMapper to avoid runtime reflection in the hot DTO-projection paths.

The mapper is a partial class decorated with [Mapper]; Mapperly fills in the bodies of the partial map methods at build time. From modules/hub/src/Hub.Application/Services/DtoMapper.cs:

using Riok.Mapperly.Abstractions;

namespace Hub.Services;

[Mapper(UseReferenceHandling = true)]
public static partial class DtoMapper
{
    private static partial CalculatedShipmentDto ToDto(
        this CalculatedShipment entity,
        [ReferenceHandler] IReferenceHandler referenceHandler);

    public static CalculatedShipmentDto ToDto(
        this CalculatedShipment entity,
        SuperTypeResolutionReferenceHandler? referenceHandler = null)
        => ToDto(entity, (IReferenceHandler)(referenceHandler ?? new SuperTypeResolutionReferenceHandler()));
    // ...
}

Call sites use the generated extension via the ABP MapTo<T>() convention, e.g. in modules/hub/src/Hub.Application/Search/ShipmentSearchProvider.cs:

protected override CalculatedShipmentDto ToDto(CalculatedShipment entity)
    => entity.MapTo<CalculatedShipmentDto>();

UseReferenceHandling = true

The shipment graph is deeply self-referential, so the mapper is configured with reference handling (and a custom SuperTypeResolutionReferenceHandler) to avoid infinite recursion when projecting the entity graph onto the deep DTO graph that DeepProperties: true produced.


4. ABP auto-API and client proxies

The HTTP surface is itself generated. ABP's auto API controllers turn application-service classes into REST endpoints, and the ABP CLI turns those endpoints into typed client proxies.

Controllers

Controllers are not hand-written; they are created from the application-service assemblies in the API host (src/Cargonerds.HttpApi.Host/CargonerdsHttpApiHostModule.cs):

options.ConventionalControllers.Create(typeof(CargonerdsApplicationModule).Assembly);
options.ConventionalControllers.Create(typeof(HubApplicationModule).Assembly);
options.ConventionalControllers.Create(typeof(PricingApplicationModule).Assembly);

See ABP Auto API Controllers for the routing conventions, and the local REST API page. The method-name → HTTP-verb conventions for these endpoints (Get → GET, Create/Add/Insert → POST, etc.) are also summarised in Application services.

Client proxies

The generated REST endpoints are consumed by the Blazor admin client and the Next.js frontend via dynamic client proxies, refreshed with the ABP CLI against the live API host (see ABP Dynamic C# API client proxies):

  • C# client proxies (Cargonerds.HttpApi.Client):
abp generate-proxy -t csharp -u https://localhost:44354
  • TypeScript proxies for the Next.js frontend:
abp generate-proxy -t ts -u https://localhost:44354

Run these against the live api host URL — see the run-mode ports in Running Locally. More on consuming the generated clients is in API Clients.


EF Core migrations

Database schema code is also generated, but from the EF Core model rather than from attributes — it is covered separately in Development Workflow and Migrations.


The do-not-edit rule

Never hand-edit generated files

  • *.g.cs files under Hub.Application.Contracts/Generated/ are produced by Nextended.CodeGen on every dotnet build. Edits are silently overwritten. Extend the partial DTO from a separate, hand-written file instead.
  • Static*.g.cs (from StaticEnumGenerator) and the Mapperly map-method bodies are emitted by the compiler — there is nothing on disk to edit.
  • Generated controllers and client proxies come from ABP/CLI tooling; change the application service (the source of truth) and regenerate, never the proxy.

The single source of truth is always the domain entity / enum / application service. Change that, rebuild, and let the generators catch up.

See also