Skip to content

Data Transfer Objects (DTOs)

DTOs are the contract between application services and their callers (the Blazor frontends, the React SPA, dynamic C# proxies, and any external API consumer). In an ABP solution a DTO is the only type that should cross the application-service boundary — domain entities never leave the application layer. See the framework background on Data transfer objects.

Cargonerds keeps its DTOs in the *.Application.Contracts projects and combines four distinct styles:

Style Where How it is produced Example
Source-generated entity DTOs Hub.Application.Contracts/Generated/*.g.cs Nextended.CodeGen analyzer from [AutoGenerateDto] OrganizationDto, TagDto, ShipmentDto
Hand-written composite / shaped DTOs Hub.Application.Contracts/Dtos/ Plain C# (often partial over a generated type) CombinedShipmentDto, ExportFilterDto
ABP request / result envelopes Hub.Application.Contracts/Dtos/ Extend PagedAndSortedResultRequestDto / PagedResultDto<T> ShipmentPageRequestDto, PagedResultDtoWithFilters<T>
Hand-written input DTOs Hub.Application.Contracts/Dtos/ Plain C# with DataAnnotations CreateUpdateTagDto, CreateUpdateBookingDto

Scale

There are 192 generated *.g.cs DTO files under Hub.Application.Contracts/Generated/, driven from only 27 entity classes that carry [AutoGenerateDto] (the rest are pulled in automatically by AutoGenerateDerived — see below). By contrast the whole solution has only one AutoMapper CreateMap call left (IdentityUser → IdentityUserDto); mapping is overwhelmingly done with Mapperly.


Source-generated DTOs ([AutoGenerateDto])

Most Hub entities are annotated with [AutoGenerateDto] (from Nextended.Core.Attributes). The third-party Nextended.CodeGen Roslyn analyzer emits a matching partial interface I…Dto and partial class …Dto for each annotated type (and, because of AutoGenerateDerived, for the whole inheritance subtree) into modules/hub/src/Hub.Application.Contracts/Generated/.

How the generator is wired

The generator is consumed purely through the project file — there is no in-repo source generator for DTOs (the only in-repo Roslyn generator, Hub.SourceGenerators/StaticEnumGenerator.cs, is unrelated and emits Static{Name}.g.cs for [StaticEnum] types).

<!-- modules/hub/src/Hub.Domain/Hub.Domain.csproj (excerpt) -->
<ItemGroup>
  <PackageReference Include="Riok.Mapperly" />
  <PackageReference Include="Nextended.CodeGen" />
</ItemGroup>

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

CodeGen.config.json drives the output. The relevant keys:

// modules/hub/src/Hub.Domain/CodeGen.config.json (excerpt)
{
  "DtoGeneration": {
    "OutputPath":        "../Hub.Application.Contracts/Generated/",
    "MappingOutputPath": "../Hub.Application/Extensions/Generated/",
    "Namespace":         "Hub.Application.Contracts.Shipments",
    "Suffix":            "Dto",
    "OneFilePerClass":   true,
    "DeepProperties":    true,
    "GeneratePartial":   true,
    "GenerateMapping":   false,
    "GenerateMappings":  false
  }
}

The opt-in lives on the base entity, which switches on derived generation for the entire tree:

// modules/hub/src/Hub.Domain/Base/HubBaseGuidEntity.cs
[AutoGenerateDto(
    AutoGenerateDerived = true,                              // emit DTOs for every subclass too
    BaseType = "Volo.Abp.Application.Dtos.IEntityDto<Guid>", // generated DTOs implement ABP's IEntityDto
    PropertiesToIgnore = [
        nameof(AuditedAggregateRoot.ExtraProperties),
        nameof(AuditedAggregateRoot.ConcurrencyStamp),
        nameof(AuditedAggregateRoot.LastModifierId),
        nameof(AuditedAggregateRoot.CreatorId),
    ],
    DefaultPropertyInterfaceAccess = InterfaceProperty.GetAndSet,
    GenerateMapping = false                                  // Mapperly does the mapping, not the generator
)]
[ProvideEdm(ProvideInherits = true)]                         // also feeds the OData EDM model
public class HubBaseGuidEntity : AuditedAggregateRoot<Guid>, IGuidEntity { /* ... */ }

GenerateMapping = false is deliberate

Nextended.CodeGen can emit mapping code, but the project disables it both globally (in CodeGen.config.json) and on the attribute. Entity ↔ DTO conversion is done with Riok.Mapperly instead (see Mapping below and the Service Patterns page). The MappingOutputPath in the config is therefore unused in practice.

The generated inheritance chain

Each generated file emits an interface + class pair, and the inheritance mirrors the domain entity hierarchy. The base of the chain:

// Hub.Application.Contracts/Generated/HubBaseGuidEntityDto.g.cs
public partial interface IHubBaseGuidEntityDto {
    DateTime? LastModificationTime { get; set; }
    DateTime  CreationTime { get; set; }
    Guid      Id { get; set; }
}
public partial class HubBaseGuidEntityDto : Volo.Abp.Application.Dtos.IEntityDto<Guid>, IHubBaseGuidEntityDto {
    public DateTime? LastModificationTime { get; set; }
    public DateTime  CreationTime { get; set; }
    public Guid      Id { get; set; }
}
// Hub.Application.Contracts/Generated/CustomerOwnedGuidEntityDto.g.cs
public partial class CustomerOwnedGuidEntityDto : HubBaseGuidEntityDto, ICustomerOwnedGuidEntityDto {
    public Guid? CargonerdsCustomerId { get; set; }
}

A concrete entity DTO then extends that base and exposes typed navigations (collections, sub-DTOs, enum DTOs):

// Hub.Application.Contracts/Generated/OrganizationDto.g.cs (trimmed)
public partial interface IOrganizationDto : ICustomerOwnedGuidEntityDto {
    string Name { get; set; }
    Guid?  AssociationId { get; set; }
    bool   IsForwarder { get; set; }
    bool   IsBroker { get; set; }
    bool   IsActive { get; set; }
    ICollection<ExternalOrganizationIdentifierDto> ExternalIdentifiers { get; set; }
    ICollection<ContactDto> Contacts { get; set; }
    IAssociationDto? Association { get; set; }
    // ...many boolean role flags + ProcessDataState / DataSource enum DTOs
}

public partial class OrganizationDto : CustomerOwnedGuidEntityDto, IOrganizationDto {
    public string Name { get; set; }
    public AssociationDto? Association { get; set; }
    // explicit interface impl bridges the interface's IAssociationDto to the concrete AssociationDto
    IAssociationDto IOrganizationDto.Association { get => Association; set => Association = (AssociationDto)value; }
    // ...
}

Polymorphic entities produce abstract DTO bases — e.g. ExternalIdentifierDto is generated as public abstract partial class ExternalIdentifierDto, matching the abstract domain base, with one concrete DTO per external-system subtype (ExternalOrganizationIdentifierDto, etc.):

// Hub.Application.Contracts/Generated/ExternalIdentifierDto.g.cs
public abstract partial class ExternalIdentifierDto : CustomerOwnedGuidEntityDto, IExternalIdentifierDto {
    public string Identifier { get; set; }
    public ServiceTypeDto ServiceType { get; set; }
    public bool IsActive { get; set; }
}
classDiagram
    class `IEntityDto~Guid~`
    class HubBaseGuidEntityDto {
        +Guid Id
        +DateTime CreationTime
        +DateTime? LastModificationTime
        +string? GetObjectKey()
    }
    class CustomerOwnedGuidEntityDto {
        +Guid? CargonerdsCustomerId
    }
    class OrganizationDto {
        +string Name
        +bool IsForwarder
    }
    class ExternalIdentifierDto {
        <<abstract>>
        +string Identifier
        +ServiceTypeDto ServiceType
    }
    class ExternalOrganizationIdentifierDto
    `IEntityDto~Guid~` <|.. HubBaseGuidEntityDto
    HubBaseGuidEntityDto <|-- CustomerOwnedGuidEntityDto
    CustomerOwnedGuidEntityDto <|-- OrganizationDto
    CustomerOwnedGuidEntityDto <|-- ExternalIdentifierDto
    ExternalIdentifierDto <|-- ExternalOrganizationIdentifierDto

Generated DTO namespace

Generated Hub DTOs live in the Hub.Application.Contracts.Shipments namespace (both interfaces and classes), set by Namespace in CodeGen.config.json. Many hand-written companions and composites instead live in the shorter Hub.Dtos namespace.

Extending generated DTOs (partial companions)

Because every generated type is partial, hand-written partials add behaviour without touching the .g.cs file. This is the supported extension point — the generated files are checked-in artifacts and must not be edited by hand.

// Hub.Application.Contracts/Dtos/HubBaseGuidEntityDto.cs
namespace Hub.Application.Contracts.Shipments;

public partial class HubBaseGuidEntityDto
{
    public virtual string? GetObjectKey() => Id.ToString();
}
// Hub.Application.Contracts/Dtos/DocumentDto.cs
public partial class DocumentDto : IDocumentBaseDto { }

More involved companions live under Hub.Application.Contracts/Dtos/Partials/ (e.g. for CalculatedShipmentDto, CodeEntityDto).

Do not edit *.g.cs; they can drift

The 192 generated DTOs are committed to the repo but regenerated by the analyzer on build. Editing one by hand will be overwritten, and the generated set can silently drift from the domain entities if a property is renamed without a rebuild. Put all hand-written DTO behaviour in a partial companion under Dtos/ (or Dtos/Partials/).


Hand-written composite & shaped DTOs

Some DTOs aggregate several generated DTOs into one read model. CombinedShipmentDto is the public face of the shipment aggregate and inherits the generated ShipmentBaseDto. Note the computed, read-only properties that flatten the polymorphic external-identifier collections:

// modules/hub/src/Hub.Application.Contracts/Dtos/CombinedShipmentDto.cs
namespace Hub.Dtos;

public class CombinedShipmentDto : ShipmentBaseDto
{
    public JobTypeDto? JobType { get; set; }
    public List<CustomsDeclarationDto> CustomsDeclarations { get; set; } = [];
    public ICollection<ConsolDto> Consols { get; set; } = [];
    public ICollection<OrderDto> Orders { get; set; } = [];
    public ICollection<TrackingContainerDto> TrackingContainers { get; set; } = [];
    public Guid? BookedInOrganizationUnitId { get; set; }
    public Guid? BookedByUserId { get; set; }

    // computed read models over the typed identifier sub-collections
    public ICollection<ExternalIdentifierDto> ExternalIdentifiers =>
        [.. ExternalShipmentIdentifierDtos, .. ExternalConsolIdentifierDtos, .. ExternalCustomsDeclarationIdentifierDtos];

    public bool? HasCw1Number => ExternalIdentifiers.Any(s => s.ServiceType == ServiceTypeDto.CW1);

    public ICollection<ExternalShipmentIdentifierDto> ExternalShipmentIdentifierDtos { get; set; } = [];
    public ICollection<ExternalCustomsDeclarationIdentifierDto> ExternalCustomsDeclarationIdentifierDtos { get; set; } = [];
    public ICollection<ExternalConsolIdentifierDto> ExternalConsolIdentifierDtos { get; set; } = [];
}

CombinedShipmentDto is the DTO that ShipmentAppService returns — its ToDto override delegates to the Mapperly DtoMapper. See Service Patterns for how the read pipeline builds it.


Input DTOs & validation

Create/update (input) DTOs are validated before the application-service method body runs. Cargonerds uses two validation mechanisms.

DataAnnotations (the simplest path)

Several hand-written Hub input DTOs use ordinary DataAnnotations — the simplest ABP-idiomatic approach. CreateUpdateTagDto is a minimal example:

// modules/hub/src/Hub.Application.Contracts/Dtos/CreateUpdateTagDto.cs
public class CreateUpdateTagDto
{
    [Required]
    public string Name { get; set; }

    public string Color { get; set; }

    public Guid? OrganizationUnitId { get; set; }
}

CreateUpdateBookingDto in the same folder is a larger one, with [Required] spread across many shipment, address, and cargo fields.

FluentValidation (the Hub / Pricing path)

For the richer Hub input DTOs the project pulls in AbpFluentValidationModule, so any AbstractValidator<TDto> in the contracts assembly is discovered and applied automatically to the matching incoming DTO — no manual call site is needed.

// modules/hub/src/Hub.Application.Contracts/HubApplicationContractsModule.cs
[DependsOn(
    typeof(HubDomainSharedModule),
    typeof(AbpDddApplicationContractsModule),
    typeof(AbpFluentValidationModule),   // <-- auto-wires the validators below
    typeof(AbpAuthorizationModule)
)]
public class HubApplicationContractsModule : AbpModule { }

Validators live under *.Application.Contracts/Validators/:

  • HubCreateUpdateBookingValidator, CommodityValidator, CodeEntityValidator, CreateUpdateTagValidator, CreateUpdateUserValidator.
  • HostOrganizationUnitCreateDtoValidator, OrganizationUnitUpdateDtoValidator, OrganizationUnitValidator (src/Cargonerds.Application/Validators/).
  • PricingCreateQuotationValidator (Pricing.Application.Contracts/Validators/).

CreateUpdateBookingValidator shows the depth these reach — conditional cross-field rules, per-element child rules, and content/size/extension checks on uploaded documents:

// modules/hub/src/Hub.Application.Contracts/Validators/CreateUpdateBookingValidator.cs (trimmed)
public class CreateUpdateBookingValidator : AbstractValidator<CreateUpdateBookingDto>
{
    public CreateUpdateBookingValidator()
    {
        RuleFor(x => x.TransportMode).NotNull();
        RuleFor(x => x.TransportMode!.Code).NotEmpty();

        // exactly one of PackLines / Containers must be supplied
        RuleFor(x => x.PackLines).NotNull().When(x => x.Containers == null);
        RuleFor(x => x.Containers).NotNull().When(x => x.PackLines == null);

        RuleForEach(x => x.Documents).ChildRules(document =>
        {
            document.RuleFor(d => d.Content).NotNull().NotEmpty();
            document.RuleFor(d => d.Content)
                    .Must(content => content!.Length <= 10 * 1024 * 1024)
                    .When(d => d.Content != null)
                    .WithMessage("Each document must be 10 MB or less");
            // ...allowed-extension check against a whitelist
        });
    }
}

CreateUpdateTagValidator is a partial class that uses [GeneratedRegex] source-generated regexes to validate that a tag colour is a valid hex / rgb() / rgba() string.

Where validation belongs

Keep validators in *.Application.Contracts next to the DTOs they guard. Because AbpFluentValidationModule is registered in the contracts module, adding a new AbstractValidator<T> there is all that is required — it is picked up by DI convention and runs on every call that binds that DTO.


Request DTOs (paging, filtering, faceting)

Hub list endpoints layer extra options on top of ABP's PagedAndSortedResultRequestDto. There is a small ladder of request DTOs:

// FilterablePagedAndSortedResultRequestDto.cs — adds a free-text filter
public class FilterablePagedAndSortedResultRequestDto : PagedAndSortedResultRequestDto
{
    public string? Filter { get; set; }
}

// PagedAndSortedResultRequestWithFacetOptionsDto.cs — adds facet build options
public class PagedAndSortedResultRequestWithFacetOptionsDto : PagedAndSortedResultRequestDto
{
    public FacetBuilderOptions? FacetBuilderOptions { get; set; }
}

// ShipmentPageRequestDto.cs — shipment-specific list filters
public class ShipmentPageRequestDto : PagedAndSortedResultRequestWithFacetOptionsDto
{
    public string? QuickFilter { get; set; }
    public bool SubscribedOnly { get; set; }
    public Guid[]? TagIds { get; set; }
}

ShipmentPageRequestDto is the input to IShipmentAppService. The actual OData $filter/$orderby applied to the query is parsed from the HTTP request and combined by the read pipeline — see OData filtering.


Result DTOs

For faceted lists the Hub returns PagedResultDtoWithFilters<T>, which extends ABP's PagedResultDto<T> (so Items + TotalCount are preserved) and adds the facet / OData metadata the UI needs to render filter chips:

// modules/hub/src/Hub.Application.Contracts/Dtos/PagedResultDtoWithFilters.cs
public class PagedResultDtoWithFilters<T> : PagedResultDto<T>, IFacetResponse
{
    public List<FilterGroupDto> Filters { get; set; } = new();    // all applicable filter groups (UI rendering)
    public List<AppliedFilterDto> Applied { get; set; } = new();  // applied filters as removable chips
    public string? ODataFilter { get; set; }                      // combined OData $filter that was applied
    public QueryMetaDto Query { get; set; } = new();              // $orderby / $top / $skip echo-back
}

Typed configuration objects (nested DTOs, string enums)

A recurring convention is to model rich, nested configuration as typed sub-DTO objects rather than opaque JSON strings, so the contract is self-describing for both the C# proxy and the React SPA. The canonical in-repo example is the Excel-export filter, where the schedule and column layout are first class objects on the request DTO:

// modules/hub/src/Hub.Application.Contracts/Dtos/Excel/ExportFilterDto.cs (trimmed)
public class ExportFilterDto : IFilterContext
{
    public string? OData { get; init; }
    public ExportType? ExportType { get; init; }
    public List<ExportColumnConfigDto> Columns { get; set; } = new();   // typed column layout
    public ExportScheduleDto? Schedule { get; init; }                   // typed nested schedule
    public List<string> Recipients { get; init; } = new();
    public Guid[]? OrganizationUnitIds { get; set; }
}

public class ExportScheduleDto
{
    public bool IsRecurring { get; init; }

    // enum serialized as a PascalCase string ("Daily"/"Weekly"/"Monthly") instead of an int,
    // so the JSON contract matches the SPA's string unions
    [JsonConverter(typeof(JsonStringEnumConverter))]
    public ExcelExportScheduleOptions? Frequency { get; init; }

    public string? TimeOfDay { get; init; } = "09:00";
    public DayOfWeek? DayOfWeek { get; init; }
    public string? TimeZoneId { get; init; }
    public int? Every { get; init; }
}

Enums on the wire — opt into strings explicitly

ABP's auto-API serializes enums as integers by default. Where the JSON contract must use the enum name (to line up with the React frontend's string unions), the property is decorated with [JsonConverter(typeof(JsonStringEnumConverter))]. This is applied per-property on a handful of DTOs (ExportScheduleDto.Frequency, several Pricing legacy DTOs under Pricing.Application.Contracts/Dtos/Legacy/); it is not the global default. With typed config objects the backend (de)serializes the whole graph centrally — callers send objects, not hand-built JSON strings.


Mapping: Mapperly, not AutoMapper

Hub entity → DTO mapping is performed by Riok.Mapperly source-generated mappers in DtoMapper (a static partial class), exposed as ToDto() / ToNet() extension methods. The public overloads inject a custom IReferenceHandler (SuperTypeResolutionReferenceHandler) so cyclic graphs and shared "super type" code entities are resolved correctly:

// modules/hub/src/Hub.Application/Services/DtoMapper.cs (excerpt)
[Mapper(UseReferenceHandling = true)]
public static partial class DtoMapper
{
    // Mapperly generates this private partial core:
    private static partial CalculatedShipmentDto ToDto(
        this CalculatedShipment entity,
        [ReferenceHandler] IReferenceHandler referenceHandler);

    // hand-written public wrapper supplies the default super-type reference handler:
    public static CalculatedShipmentDto ToDto(
        this CalculatedShipment entity,
        SuperTypeResolutionReferenceHandler? referenceHandler = null)
        => ToDto(entity, (IReferenceHandler)(referenceHandler ?? new SuperTypeResolutionReferenceHandler()));
}

ABP's ObjectMapper (AutoMapper), by contrast, survives for just one mapping: the ABP IdentityUser → IdentityUserDto profile in HubApplicationAutoMapperProfile. Every domain ↔ DTO conversion in Hub/Pricing goes through Mapperly. The mapping mechanics, the [MapDerivedType] polymorphism lists, and the ApplyUpdate in-place pattern are covered in detail on Service Patterns.

Don't reach for ObjectMapper.Map<> on a domain type

Because mapping is Mapperly, most conversions are extension methods (entity.ToDto(), dto.ToNet(), entity.MapTo<TDto>()), not ObjectMapper.Map<...>. Assuming an AutoMapper profile exists for an arbitrary entity will fail at runtime — only IdentityUser still has one.


Auto-API & dynamic clients

DTOs flow straight into ABP's auto API controllers and the dynamic C# client proxies. Service interfaces are conventional ABP contracts (IShipmentAppService : ICrudAppService<CombinedShipmentDto, Guid, ShipmentPageRequestDto>), and the remote service names come from HubRemoteServiceConsts (RemoteServiceName = "Hub") and PricingRemoteServiceConsts. A few auto-API conventions worth remembering when designing DTOs and method signatures:

  • Method-name prefix → HTTP verb: Get* → GET, Create/Add/Insert/Post* → POST, Update/Put* → PUT, Delete/Remove* → DELETE, anything else → POST.
  • A leading Guid parameter on a custom method becomes a path parameter; two simple Guid parameters both become query parameters.
  • Enums serialize as integers unless a property opts into JsonStringEnumConverter (see above).

The Swagger document is at https://localhost:44354/swagger/v1/swagger.json. See REST API and API Clients for the consumer side.


Gotchas

Key DTO pitfalls

  • 192 checked-in *.g.cs DTOs, regenerated on build. Never hand-edit them; add a partial companion under Dtos/. They can drift from the entities if a property changes without a rebuild.
  • GenerateMapping = false everywhere. The DTO generator emits no mapping code; Mapperly is the mapping engine. The MappingOutputPath in CodeGen.config.json is effectively unused.
  • Enums default to integers on the wire. Opt into string serialization per-property with [JsonConverter(typeof(JsonStringEnumConverter))] when a frontend expects names.
  • FluentValidation is auto-wired but only in the contracts assembly. A validator placed elsewhere won't be discovered. Some input DTOs (e.g. CreateUpdateTagDto) use DataAnnotations instead of FluentValidation — the two styles coexist.
  • PagedResultDtoWithFilters<T> is not a plain PagedResultDto<T>. Faceted endpoints return the richer envelope (Filters / Applied / ODataFilter / Query); clients that only read Items/TotalCount will silently ignore the facet metadata.

See also