Guide: Mapping entity graphs to DTOs

July 15, 2026 · View on GitHub

Purpose

query.mapTo(SomeDto.class) maps an entity query result to a nested DTO graph — DTO fields can themselves be DTOs (ToOne) or List<Dto>/Set<Dto> (ToMany), not just flat scalar columns. Ebean generates the mapper (reflection-free), automatically derives the query's select()/fetch() spec from the target DTO's declared shape, and forces setUnmodifiable(true) so any property the mapper needs but wasn't fetched fails fast with LazyInitialisationException instead of silently lazy loading.

This is distinct from the existing flat asDto(Dto.class) — see Quick comparison below.

Optional<CustomerDto> dto = new QCustomer()
  .id.eq(customerId)
  .mapTo(CustomerDto.class)
  .findOneOrEmpty();
List<CustomerDto> dtos = DB.find(Customer.class)
  .where().eq("status", Status.ACTIVE)
  .mapTo(CustomerDto.class)   // no .select()/.fetch() needed - derived from CustomerDto's shape
  .findList();

Quick comparison: mapTo() vs asDto() vs plain entity query

mapTo(Dto.class)asDto(Dto.class)Plain entity query
ShapeNested DTO graph (ToOne/ToMany)Flat, single-row DTOEntity graph
Fetch specAuto-derived from the DTO's declared shapeWhatever select()/SQL you writeWhatever select()/fetch() you write
Mismatch caughtAt compile time (unregistered pair fails fast at first use; codegen fails fast on structural problems)At runtime (reflection-based constructor/setter matching)N/A (real entity properties)
Identity/de-dupYes - repeated source instances map to the same DTO instance (DtoMapContext)N/A (one row in, one DTO out)Yes (entity/persistence-context identity)
Backing pipelineExecutes the entity ORM query, setUnmodifiable(true), maps the resulting graphExecutes SQL directly against a flat ResultSetExecutes the entity ORM query
Best forAPI/read-model responses that mirror a nested entity shapeFlat summary rows, reports, native/vendor SQLData you intend to mutate and save back

See also writing-ebean-query-beans.md (Step 8/9) for asDto() and the general query-shape decision guide.


Basic usage

1. Declare a plain DTO

DTOs are plain classes with no framework attachment — no annotations required for the common case (properties matched to the source entity by name):

public class CustomerDto {
  private final Long id;
  private final String name;
  private final AddressDto billingAddress;   // nested ToOne
  private final List<ContactDto> contacts;   // nested ToMany

  public CustomerDto(Long id, String name, AddressDto billingAddress, List<ContactDto> contacts) {
    this.id = id;
    this.name = name;
    this.billingAddress = billingAddress;
    this.contacts = contacts;
  }

  public Long getId() { return id; }
  public String getName() { return name; }
  public AddressDto getBillingAddress() { return billingAddress; }
  public List<ContactDto> getContacts() { return contacts; }
}

A constructor whose parameters match (by name) a source entity/DTO property is used for mapping — same shape convention as the existing DtoQuery. Getters are used to read the source's properties — a bare/fluent accessor like active() is resolved automatically too, not just getActive()/isActive() (useful both for Ebean's own record entity beans and for ordinary classes that just expose bare-name accessors).

2. Register the (source, target) pair

Declare each entity → DTO pair with @DtoMapping on a package-info.java (a neutral holder — see Why package-info.java?):

@DtoMapping(source = Customer.class, target = CustomerDto.class)
@DtoMapping(source = Address.class, target = AddressDto.class)
@DtoMapping(source = Contact.class, target = ContactDto.class)
package org.example.dto;

import io.ebean.annotation.DtoMapping;

This triggers querybean-generator (the existing annotation processor) to generate a CustomerDtoMapper implements DtoMapper<Customer, CustomerDto> for each pair — no new Maven/Gradle setup beyond what query beans already require.

3. Query with mapTo(...)

List<CustomerDto> dtos = DB.find(Customer.class)
  .where().eq("status", Status.ACTIVE)
  .mapTo(CustomerDto.class)
  .findList();

CustomerDto one = new QCustomer().id.eq(id).mapTo(CustomerDto.class).findOne();

Optional<CustomerDto> maybe = new QCustomer().id.eq(id).mapTo(CustomerDto.class).findOneOrEmpty();

mapTo(...) works the same from a query bean (QCustomer) or a plain DB.find(...)/ ExpressionList query.

Paging - findPagedList()

findPagedList() mirrors Query#findPagedList() — the underlying entity query is paged as normal and each page's result is mapped to the target DTO list:

PagedList<CustomerDto> paged = DB.find(Customer.class)
  .where().eq("status", Status.ACTIVE)
  .orderBy().asc("name")
  .setFirstRow(0)
  .setMaxRows(50)
  .mapTo(CustomerDto.class)
  .findPagedList();

int totalRowCount = paged.getTotalCount();      // page metadata - unaffected by DTO mapping
List<CustomerDto> page1 = paged.getList();       // mapped DTOs for this page

Page metadata (getTotalCount(), getTotalPageCount(), hasNext(), hasPrev(), loadCount(), ...) reflects the underlying entity query directly; only getList() is mapped (once, cached) to the DTO type.

An unregistered pair fails fast

If (Customer.class, SomeDto.class) was never declared via @DtoMapping, the first mapTo(SomeDto.class) call throws immediately:

PersistenceException: No DtoMapper registered mapping Customer -> SomeDto
  - check @DtoMapping(source = Customer.class, target = SomeDto.class) is declared
    on a package-info.java processed by querybean-generator

Auto-derived fetch spec

You never write .select()/.fetch() for a mapTo(...) query — the generated mapper exposes a fetchGroup() built directly from the DTO's declared shape, and mapTo(...) applies it automatically:

public CustomerDtoMapper() {
  this(new AddressDtoMapper(), new ContactDtoMapper());
}

public CustomerDtoMapper(DtoMapper<Address, AddressDto> billingAddressMapper,
                          DtoMapper<Contact, ContactDto> contactsMapper) {
  this.fetchGroup = FetchGroup.of(Customer.class)
    .select("id,name")
    .fetch("billingAddress", billingAddressMapper.fetchGroup())
    .fetch("contacts", contactsMapper.fetchGroup())
    .build();
}

Each nested DTO gets its own generated mapper (mirroring MapStruct's per-type mapper generation), wired together via constructor injection — mappers are stateless and substitutable, not static singletons. Mapper instances are constructed once, in dependency order, and reused — see DtoMapperManager below.


Nested collections and identity-aware de-duplication

When the same source entity instance is reachable via more than one path in the graph (e.g. two Contacts sharing the same Customer, or the same Address referenced from two paths), the mapper reuses the same target DTO instance rather than creating duplicate-but-equal copies — mirroring the identity semantics the entity graph already has:

List<CustomerDto> dtos = DB.find(Customer.class).mapTo(CustomerDto.class).findList();

CustomerDto customer = dtos.get(0);
// both contacts share the exact same customer.billingAddress AddressDto instance
assertThat(customer.getContacts().get(0).getCustomer())
  .isSameAs(customer.getContacts().get(1).getCustomer());

This is done via a DtoMapContext threaded through every nested map(...) call within one top-level mapList(...)/findList() invocation. The generated code only pays for this when it can actually matter — a DTO that's never nested under another DTO skips DtoMapContext entirely (there's nothing else in scope to de-duplicate against):

// AddressDto is nested under CustomerDto (reachable via multiple contacts) - dedup needed
// dedup using DtoMapContext, same Address instance can be reached via more than one path in the graph
return context.computeIfAbsent(AddressDto.class, source, s -> new AddressDto(...));

// ContactSummaryDto is only ever mapped as a top-level query result - no dedup possible
// skip DtoMapContext, only ever a top-level mapping
return new ContactSummaryDto(source.getId(), source.getFullName());

// CustomerDto has nested mappers (billingAddress, contacts) but is never itself nested
// DtoMapContext for nested mappers only
return new CustomerDto(source.getId(), source.getName(), ...);

The generated comment tells you at a glance which of the three cases applies — useful when debugging why a DtoMapContext is (or isn't) in the generated code for a particular mapper.


Using generated mappers directly (outside query.mapTo())

Every generated XxxDtoMapper is a plain public class — you don't need ServiceLoader, a registry, or a Database just to construct or call one directly (though DtoMapperManager, below, is available if you want a shared, DI-friendly lookup). It always has a public no-arg constructor (delegating to defaults for any nested mappers/ @DtoConvert converters) plus an explicit constructor taking those dependencies directly, and implements DtoMapper<SOURCE, TARGET>'s map(...)/mapList(...):

CustomerDtoMapper mapper = new CustomerDtoMapper();
CustomerDto dto = mapper.map(customer);       // any Customer you already have on hand
List<CustomerDto> dtos = mapper.mapList(customers);

This works on any entity graph, not just one that just came out of a mapTo(...) query — e.g. entities you loaded with a plain .fetch(...) query, entities you just .save()d, or entities built by hand in a test. The only requirement is that whatever the mapper reads (via plain getters) is actually populated — there's no lazy-loading fallback.

Testing the mapping in isolation

Because mappers are plain, constructor-injected classes, you can unit test the mapping logic itself — independent of query.mapTo(), the DTO-pair registry, and (for @DtoConvert instance-dispatch converters) DtoConverterManager — by passing a test double straight into the explicit constructor:

SecretCipher upperCasingTestCipher = String::toUpperCase;
ContactConversionDto dto = new ContactConversionDtoMapper(upperCasingTestCipher).map(contact);

assertThat(dto.getSecretCode()).isEqualTo("SHH");

No DtoConverterManager.put(...) registration needed for this kind of test — the real production wiring (DtoConverterManager.get(SecretCipher.class)) only happens in the generated no-arg constructor, which the explicit-constructor call above bypasses entirely. See TestCustomerDtoGraphMapping (mapper called directly against a manually queried graph) and TestMapperManualUsage (mapper called directly against hand-built/just-saved entities, plus the converter test-double case above) in tests/test-dto-mapping.

DtoMapperManager — resolving a generated mapper for dependency injection

new CustomerDtoMapper() is enough for a single mapper, but if your application wants a single shared instance of every generated mapper (mirroring how query.mapTo() resolves them internally) - e.g. to wire one up for constructor injection into a service, replacing a hand-written mapper class - use io.ebean.DtoMapperManager:

DtoMapperManager manager = new DtoMapperManager();   // ServiceLoader discovery only - no Database needed
CustomerDtoMapper mapper = manager.get(CustomerDtoMapper.class);

DtoMapperManager has no dependency on Database at all - its constructor only does ServiceLoader.load(DtoMapperRegister.class) - so it can be constructed independently, before (or entirely without) a Database, e.g. as a bean in an avaje-inject (or any DI framework's) dependency graph:

@Factory
class DtoMapperFactory {

  @Bean
  DtoMapperManager dtoMapperManager() {
    return new DtoMapperManager();
  }

  @Bean
  CustomerDtoMapper customerDtoMapper(DtoMapperManager manager) {
    return manager.get(CustomerDtoMapper.class);
  }
}

If you also want query.mapTo(...) to use that exact same manager instance (so there's only ever one instance of each generated mapper, whichever path resolves it), register it via DatabaseBuilder.putServiceObject before building the Database - this is the same putServiceObject/getServiceObject mechanism already used for things like AutoMigrationRunner:

DtoMapperManager sharedManager = new DtoMapperManager();

Database db = Database.builder()
  .putServiceObject(DtoMapperManager.class, sharedManager)
  .build();

// query.mapTo(...) against `db` now resolves mappers via `sharedManager`

If nothing is registered via putServiceObject, the Database builds its own default DtoMapperManager instance instead - registering one is entirely optional. A standalone DtoMapperManager() construction bypasses the DatabaseConfigProvider hook (that hook is specifically about Database startup ordering), so if any of your mappers need a @DtoConvert instance-dispatch converter, register it via DtoConverterManager.put(...) yourself first, exactly as you would before building a Database. See TestDtoMapperManager and TestDtoMapperManagerSharing in tests/test-dto-mapping.

Recipe: adding extra caller-supplied fields after mapping

Sometimes a target DTO needs a field that isn't sourced from the entity graph at all - e.g. populated from a separate query or business rule, only when a caller-supplied flag is set. Rather than the generator supporting partial/builder-based mapping directly, if your DTO is a record with a "seed from instance" builder (e.g. via avaje-recordbuilder's @RecordBuilder, which generates Target.builder(existingInstance)), just map the graph-sourced fields as usual and layer the extra field on afterwards:

Driver base = mapper.map(cDriver);
Driver full = DriverBuilder.builder(base).fleets(fleets).build();

No generator changes needed - the mapped instance is simply the seed for the builder.


Large targets: builder-based construction and named variants

Two features aimed at large, builder-shaped target DTOs (typically OpenAPI-generated records with a generated builder), where a positional constructor call is unwieldy and a single query needs to populate the target in more than one shape.

Builder-based construction (builder = AUTO | ALWAYS | NEVER)

If the target has a static no-arg Target.builder() factory returning a type with a fluent (returns-itself) setter per property plus a build() method - the shape avaje-recordbuilder's @RecordBuilder generates - the generated mapper can construct the target via Target.builder().prop(x)....build() instead of new Target(a, b, c, ...):

public record User(Long id, String name, String email, /* ... 21 more fields */) {

  public static UserBuilder builder() {
    return UserBuilder.builder();
  }
}
@DtoMapping(source = CUser.class, target = User.class)
package org.example.dto;

By default (builder = AUTO), the generator auto-detects a matching builder and uses it only once the target has more than 5 properties, falling back to a positional constructor for smaller DTOs. Override explicitly either direction:

@DtoMapping(source = CUser.class, target = User.class, builder = DtoMapping.Builder.ALWAYS)

builder = ALWAYS is a codegen-time error if no matching builder shape is found; builder = NEVER always uses a positional constructor even if a builder is detected. This applies regardless of whether the target is hand-authored or foreign/generated - @DtoMapping is already declared externally via package-info.java, so no annotation on the target itself is needed either way.

Named variants excluding nested paths (name=, exclude=)

The same (source, target) pair can be registered more than once - one base mapping (leaving name() empty) plus any number of named variants, each excluding one or more nested ToOne/ToMany properties:

@DtoMapping(source = CUser.class, target = User.class)
@DtoMapping(source = CUser.class, target = User.class, name = "noFleets", exclude = "fleets")
package org.example.dto;

Both variants are generated into the same mapper class (one class per target, not one per variant) - the generated noFleets() accessor returns a single shared/cached DtoMapper<CUser, User> view (not reconstructed per call), omitting fleets from both its mapped output (null for a ToOne, List.of() for a ToMany) and its own fetchGroup(). Each excluded property is still evaluated inline at its own declared field position internally (guarded by a boolean flag) - a variant's exclusions never change the evaluation order of the DTO's other properties. Select it with the query.mapTo(Class, DtoMapper) overload, which takes an already-resolved mapper instance directly - no string-based lookup:

UserMapper userMapper = new UserMapper();

// full shape, with fleets fetched/mapped
List<User> withFleets = DB.find(CUser.class)
  .mapTo(User.class, userMapper) // or plain .mapTo(User.class)
  .findList();

// bulk listing shape - fleets excluded from both the fetch spec and the output
List<User> noFleets = DB.find(CUser.class)
  .mapTo(User.class, userMapper.noFleets())
  .findList();

Only nested ToOne/ToMany properties can be excluded - a scalar or @DtoRef property can't be, since there's no type-safe "absent" value for an arbitrary scalar type. Named variants are scoped to independent, top-level query results only - unlike the base mapping, they don't participate in DtoMapContext identity de-duplication when nested elsewhere in a graph, since a variant is never intended to be nested inside another DTO's mapping.


@DtoPath — renamed or flattened properties

By default a DTO property is matched to the source entity property (or nested DTO mapper) of the same name. @DtoPath overrides that, allowing a DTO property to be renamed and/or flattened from a nested path using dot-notation:

public class ContactDto {
  private final long id;
  private final String firstName;
  private final String lastName;

  @DtoPath("customer.billingAddress.city")
  private final String customerCity;   // flattened, 2 hops through customer

  // constructor / getters ...
}

The generated mapper reads the path with a null-guard at each hop and adds the necessary joins to the fetch spec automatically:

(s.getCustomer() == null ? null
  : (s.getCustomer().getBillingAddress() == null ? null
      : s.getCustomer().getBillingAddress().getCity()))

@DtoPath is purely a compile-time/codegen-time hint — the DTO class itself carries no runtime dependency on the annotation.

Fetch-path collisions are a compile-time error

A @DtoPath whose fetch path is identical to a nested ToOne/ToMany property's own fetch path on the same DTO (e.g. a nested customer field alongside @DtoPath("customer.name") — both resolve to fetch path "customer") fails the build with a clear error, rather than silently discarding one side's fetched properties:

error: @DtoPath property 'customerName' on FooDto resolves to fetch path 'customer',
  which collides with the nested mapping already using that same fetch path - Ebean's
  fetch spec can only carry one set of properties per path, so one silently discards
  the other. Move 'customerName' onto the nested DTO type instead, or choose a
  @DtoPath that reaches into a different, non-colliding path.

Fix it either way it suggests: move the property onto the nested DTO type, or choose a @DtoPath that reaches a different path (as customerCity above does deliberately, using a 3-segment path through customer.billingAddress rather than colliding with a plain customer nested field).


@DtoRef — id-only back-references (breaking cycles)

The DTO graph derived from a set of DTO types must form a DAG — codegen fails if it doesn't. @DtoRef is the explicit escape hatch for an intentional back-reference, e.g. a Contact DTO referencing its parent Customer by id only, rather than re-embedding a full CustomerDto (which would recreate the Customer → Contact → Customer cycle):

public class ContactDto {
  private final long id;

  @DtoRef
  private final Long customerId;   // id-only, no nested CustomerDto re-embedded

  // constructor / getters ...
}

The generated fetch spec adds the association to the root select(...) rather than a nested .fetch(...) — this reads the foreign-key column directly off the base table (no SQL join):

this.fetchGroup = FetchGroup.of(ContactStats.class)
  .select("customer,contactCount,engagementScore")   // "customer" -> FK column, no join
  .build();
(source.getCustomer() == null ? null : source.getCustomer().getId())

If the same association is also independently nested-fetched elsewhere on the DTO (e.g. ContactDto has both a nested customer field and @DtoRef Long customerId), the generator recognizes the association is already covered and doesn't add a redundant/duplicate select — no join is added twice.


@DtoConvert — custom property conversion

Some properties need more than a plain getter copy — a scalar coercion (short to boolean), an enum-to-String mapping, or a conversion needing a real dependency (e.g. decrypting a value with a cipher). @DtoConvert(value = ConverterType.class, method = "name") covers both, combinable with @DtoPath when the source value also needs a path/rename override:

public class ContactDto {
  @DtoPath("status")
  @DtoConvert(value = ContactConversions.class, method = "toActive")
  private final boolean active;             // Contact.status (Short) -> boolean

  @DtoConvert(value = SecretCipher.class, method = "decode")
  private final String secretCode;          // decrypted via a registered SecretCipher

  // constructor / getters ...
}

The generator resolves the referenced method at codegen time and dispatches one of two ways, purely based on whether it's static:

  • Static method — inlined as a direct static call (ContactConversions.toActive(source.getStatus())). No registration needed at all — use this for common, reusable, dependency-free coercions.
  • Instance method — the generated mapper resolves one shared instance via DtoConverterManager.get(SecretCipher.class), wired as a constructor parameter/field (the same shape as nested-mapper constructor injection), then calls secretCipher.decode(source.getSecretCode()). Use this when the conversion needs a real dependency.

Registering an instance-dispatch converter

DtoConverterManager is a small, deliberately-scoped static put/get bridge — register an already-constructed converter instance (e.g. built by your DI container) before building the Database:

AES256Cipher cipher = ...;                                    // already DI-constructed
DtoConverterManager.put(SecretCipher.class, cipher::decrypt); // or a small adapter class

Database db = DatabaseFactory.create(...); // generated mappers resolve converters from here

If nothing is registered for a required type, DtoConverterManager.get(...) throws a PersistenceException immediately — this happens as an eager field initializer on the generated EbeanDtoMapperRegister, so a missing registration fails fast at Database build time, not lazily on first mapTo(...) call.

Testing tip: since EbeanDtoMapperRegister's mapper fields are all constructed together when the Database starts, register converters via a DatabaseConfigProvider (a ServiceLoader hook that runs before the Database is built) rather than a test @BeforeAll, so registration always happens before any test triggers startup — regardless of which test class runs first.

@DtoMixin — overlaying annotations onto a DTO you can't edit

Some DTOs are generated elsewhere (e.g. from an OpenAPI spec, regenerated on every build) and can't be annotated directly. @DtoMixin(Target.class) overlays @DtoPath/@DtoRef/@DtoConvert from a separate companion type instead — directly mirroring avaje-jsonb's @Json.MixIn mechanism. Declare a companion interface (or class) whose method names match the target DTO's property names:

// ContactMixinDto itself carries no Ebean annotations at all
public class ContactMixinDto {
  public ContactMixinDto(long id, String firstName, boolean active, String secretCode) { ... }
  // getters ...
}

@DtoMixin(ContactMixinDto.class)
interface ContactMixinDtoMixin {

  @DtoPath("status")
  @DtoConvert(value = ContactConversions.class, method = "toActive")
  boolean active();

  @DtoConvert(value = SecretCipher.class, method = "decode")
  String secretCode();
}

The processor matches each mixin method to the target's property by name and applies whichever annotations are present as if they were declared on the target field itself. The mixin type is never instantiated and carries no runtime footprint — it's purely a compile-time/codegen-time hint.


Computed / aggregate properties via @Entity @View

There's no dedicated "formula on DTO" annotation (a narrower @Formula2-on-DTO variant was explored and rejected — see dto-mapping-design.md for the reasoning). Instead, model the computed value as its own read-only entity using @View, then map that entity to a plain DTO with the same @DtoMapping machinery described above. @View(name = "...") here just points a second entity at an existing table — it does not create a new database view or table.

Worked example — computed column (@Formula2)

@Entity
@View(name = "contact")   // reads the existing 'contact' table, no new DDL
public class ContactSummary {
  @Id
  private Long id;
  private String firstName;
  private String lastName;

  @Formula2("concat(firstName, ' ', lastName)")
  private String fullName;

  // getters ...
}
public class ContactSummaryDto {
  private final Long id;
  private final String fullName;
  // constructor / getters ...
}
@DtoMapping(source = ContactSummary.class, target = ContactSummaryDto.class)
List<ContactSummaryDto> summaries = DB.find(ContactSummary.class)
  .mapTo(ContactSummaryDto.class)
  .findList();

Worked example — group-by aggregation (@Sum/@Aggregation)

The same @View-on-base-table pattern applies to Ebean's @Sum/@Aggregation group-by formulas — the Blaze-Persistence parallel is an @EntityView with @Mapping("SIZE(...)")/@Mapping("SUM(...)") correlated mappings:

@Entity
@View(name = "contact")
public class ContactStats {
  @Id
  private Long id;              // required so @Aggregation("count(id)") has something to
                                 // count; deliberately never selected/mapped - selecting it
                                 // would defeat the aggregation (one row per contact
                                 // instead of one row per customer)
  @ManyToOne
  private Customer customer;

  @Aggregation("count(id)")
  private Long contactCount;

  @Sum
  private Integer engagementScore;

  // getters ...
}
public class ContactStatsDto {
  @DtoRef
  private final Long customerId;   // also the implicit GROUP BY key
  private final Long contactCount;
  private final Integer engagementScore;
  // constructor / getters ...
}

Because customerId uses @DtoRef, the generated fetch spec is select("customer,contactCount,engagementScore") with no join — the query groups by the FK column directly:

select t0.customer_id, count(t0.id), sum(t0.engagement_score)
from contact t0
group by t0.customer_id

Performance notes

Fail-fast, no accidental lazy loading

mapTo(...) forces query.setUnmodifiable(true) under the hood. If the mapper ever needs a property that wasn't fetched, it throws LazyInitialisationException immediately rather than silently issuing an extra query per row or returning null. InterceptReadOnly (the unmodifiable-graph bean state) is also cheap — a boolean[] loaded flag array plus a frozen flag, not a full second copy of bean state.

One mapper instance per pair

Generated mappers are constructed once (in dependency order — a mapper with nested mappers takes them as constructor params) and reused across every mapTo(...) call for that pair, resolved and cached by DtoMapperManager keyed on (sourceType, dtoType).

DtoMapContext overhead only where it earns its keep

As shown above, the generator only involves DtoMapContext for mappers that can actually be reached via more than one path in some graph (dedup) or that have nested mappers of their own (need to thread the context down); a DTO that's only ever a top-level query result skips it entirely.

Fetch strategy and pagination carry over unchanged

Existing fetch-strategy control (+query/+lazy, fetchQuery()) and pagination (including keyset pagination and findPagedList()) work the same whether the query target is an entity graph or a mapTo(...) DTO graph — no special-casing needed.


Which should I use?

  • mapTo(Dto.class) — the target is a nested shape (has its own ToOne/ToMany DTO fields) that should mirror part of the entity graph; you want the fetch spec derived automatically and verified to match the DTO's declared shape.
  • asDto(Dto.class) / DB.findDto(...) — the target is a flat row (report, summary, native/vendor SQL); you're comfortable with runtime-checked column-to-bean matching, or the SQL doesn't map cleanly to entity property paths at all.
  • Plain entity query — the caller needs a real, persistable, mutable entity — not a read-only projection.

Reference

Why package-info.java?

@DtoMapping is declared on a package (ElementType.PACKAGE), not the DTO or the entity, because:

  • the DTO type is often owned/generated elsewhere (e.g. from an OpenAPI spec) and shouldn't need to be annotated with an internal persistence/entity type;
  • one entity may be the source for several different DTOs (e.g. a summary vs. a detail view), and the same entity/DTO pair may need registering from multiple consuming modules.

Annotations at a glance

AnnotationTargetPurpose
@DtoMapping(source=, target=)package-info.javaRegisters an entity → DTO pair, triggers mapper generation
@DtoMapping(..., builder=)package-info.javaAUTO (default, threshold-based) / ALWAYS / NEVER - builder-chain vs positional constructor
@DtoMapping(..., name=, exclude=)package-info.javaRegisters a named variant sharing the base mapping's generated class, excluding nested paths
@DtoPath("a.b.c")DTO field/getterRenamed and/or flattened multi-hop property mapping
@DtoRefDTO field/getterId-only back-reference; breaks a cycle; root-selects the FK (no join)
@DtoConvert(value=, method=)DTO field/getterCustom scalar conversion - static (no registration) or instance (via DtoConverterManager) dispatch
@DtoMixin(Target.class)Companion interface/classOverlays @DtoPath/@DtoRef/@DtoConvert onto a DTO that can't be annotated directly

Parallels with other tools

If you're coming from another mapping library, here's the rough correspondence:

EbeanMapStructBlaze-Persistence
Generated DtoMapper per (source, DTO) pairGenerated @Mapper implementation@EntityView (interface + runtime proxy)
@DtoPath("a.b.c")@Mapping(target = "x", source = "a.b.c")@Mapping("a.b.c")
@DtoRef@Context/manual cycle-breaking (no dedicated annotation)Sub-view referencing an id-only projection
@DtoConvert(value=, method=)@Mapping(qualifiedByName = "...") / custom mapper methodsCustom converter/@Mapping expression
@DtoMixin(Target.class)N/A (annotate the @Mapper interface's abstract methods instead)N/A
DtoMapContext identity de-dupNot built in (opt-in @MappingTarget/manual caching)Built in (entity-view identity)
@Entity @View + @Formula2/@Sum/@Aggregation for computed DTO valuesN/A (MapStruct doesn't touch SQL)@Mapping("SIZE(...)") / @Mapping("SUM(...)") correlated mappings

See dto-mapping-design.md for the full design rationale and dto-mapping-requirements.md for the accepted/rejected requirements this feature was scoped against (issue #2540).