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<TDto>()"]
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 ofHubBaseGuidEntitygets its own DTO; you annotate the base once, not 190 entities.BaseType = "...IEntityDto<Guid>"— the generated root DTO implements ABP'sIEntityDto<Guid>, so the DTO hierarchy mirrors the entity hierarchy and carries anId.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 |
Address → AddressDto / 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.jsonsrc/Cargonerds.Web.Public/CodeGen.config.json
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:
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):
- TypeScript proxies for the Next.js frontend:
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.csfiles underHub.Application.Contracts/Generated/are produced by Nextended.CodeGen on everydotnet build. Edits are silently overwritten. Extend thepartialDTO from a separate, hand-written file instead.Static*.g.cs(fromStaticEnumGenerator) 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¶
- DTOs — what the generated DTOs are used for.
- Entity Framework Core — the
HubDbContext, TPH and the entity model the DTOs are generated from. - Hub module — where almost all generated code originates.
- Application services and REST API — the auto-API surface built on top of the generated DTOs.
- ABP patterns and Layered architecture — how generation fits the module layering.
- Development workflow — when generation runs in the normal build/dev loop.