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/:
- Hub —
CreateUpdateBookingValidator,CommodityValidator,CodeEntityValidator,CreateUpdateTagValidator,CreateUpdateUserValidator. - Host —
OrganizationUnitCreateDtoValidator,OrganizationUnitUpdateDtoValidator,OrganizationUnitValidator(src/Cargonerds.Application/Validators/). - Pricing —
CreateQuotationValidator(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
Guidparameter on a custom method becomes a path parameter; two simpleGuidparameters 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.csDTOs, regenerated on build. Never hand-edit them; add apartialcompanion underDtos/. They can drift from the entities if a property changes without a rebuild. GenerateMapping = falseeverywhere. The DTO generator emits no mapping code; Mapperly is the mapping engine. TheMappingOutputPathinCodeGen.config.jsonis 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 plainPagedResultDto<T>. Faceted endpoints return the richer envelope (Filters/Applied/ODataFilter/Query); clients that only readItems/TotalCountwill silently ignore the facet metadata.
See also¶
- Application Services — where DTOs enter and leave the app layer
- Service Patterns — Mapperly mappers,
HubEntityBaseService,ToDtodelegation - Authorization — permission checks on the services that consume these DTOs
- Entities — the domain types these DTOs are projected from
- OData filtering — how
ShipmentPageRequestDto/ facets become a query - REST API and API Clients — auto-API and dynamic proxies
- ABP Patterns — the framework conventions used throughout