Skip to content

The DTO Wurblet and the @RecordDTO Annotation

What this document answers

Every application eventually needs plain data carriers — small, boilerplate-heavy classes that hold a handful of fields and travel between tiers, get serialized over TRIP, or feed a form, a report, or a JSON payload. Writing them by hand means hand-maintaining fields, a constructor, getters, (optionally) setters, equals/hashCode, a builder, and copy factories — the kind of mechanical code that drifts and rots.

The DTO wurblet generates all of it. Unlike every other wurblet in tentackle-wurblets, it is not tied to a persistent entity or the application model — a DTO is just a value carrier, not a PDO. It takes its shape from one of two inputs:

  1. a small property model — a here-document describing the properties, or
  2. a Java record annotated with @RecordDTO, from which the property list is read directly.

This document explains both modes, describes exactly what the @RecordDTO annotation does (a build-time analysis step that produces a record.info file the wurblet consumes), and gives a decision procedure for when to reach for an annotated record versus a hand-written property model.

If you only need to know which to use: write a record and annotate it @RecordDTO whenever a plain immutable record is the natural shape and you just want the wurblet to add a builder and/or copy factories. Write a property model when you need something a record cannot give you — mutable or transient fields, a super-class constructor, generated equals/hashCode/validation, or serialization-friendly non-final fields.


The two input modes

The DTO wurblet is invoked from an anchor comment, exactly like any other wurblet (see wurblets.md → How a Wurblet Is Built and Run). What differs is where it gets its list of properties.

Mode 1 — the property model (here-document)

The classic mode. The properties are described in a small heap file — usually a here-document named .<filename> created inside the leading comment block of the DTO's own source file — and the wurblet generates the whole class body: fields, constructor, getters, and whatever the options ask for.

/*
$> .model
String  name    the object's name
int     count   the counter
=String note    a free-text note
<$
*/
public class CustomerDTO {

  // @wurblet dto DTO .model
  //<editor-fold ... desc="code 'dto' generated by wurblet DTO">//GEN-BEGIN:dto
  // ... generated fields, constructor, getters, setter for 'note' ...
  //</editor-fold>//GEN-END:dto
}

Each model line is <type> <name> <comment>, with single-character prefixes selecting per-property behavior (^ pass-to-super, = mutable, ~ mutable+transient, ! required-in-builder, + generate a copy factory), and per-property or global annotations in square brackets. The full grammar lives in the wurblet's own @{comment}@ block and is summarized in wurblets.md → The DTO wurblet.

Because the wurblet owns the entire class body here, this mode can generate everything: mutable properties with setters, transient fields, a constructor that forwards ^-prefixed properties to a superclass, equals/hashCode, post-construction validation, and non-final fields for serialization.

Mode 2 — a Java record + @RecordDTO

When the natural shape of the carrier already is an immutable record, you do not write a property model at all. You declare a normal Java record — which already gives you the fields, the canonical constructor, the accessors, and compiler-generated equals/hashCode/toString for free — annotate it with @RecordDTO, and let the wurblet add only the extras a record cannot express by itself: a builder and/or copy factories (from… / with… methods).

@RecordDTO
public record Position3D(int x, int y, int z) implements Serializable {

  // @wurblet dto DTO '--builder[@JsonPOJOBuilder(withPrefix = "")]' --with
  //<editor-fold ... desc="code 'dto' generated by wurblet DTO">//GEN-BEGIN:dto
  // ... generated builder and with… copy methods ...
  //</editor-fold>//GEN-END:dto
}

Notice there is no .model filename argument — the wurblet detects it is running inside a record (isRecord()) and reads the component list from the record instead. In this mode the wurblet deliberately emits less: no fields, no getters, and no separate all-args constructor (the record already has them). It emits only the builder and the copy factories, wiring them to the record's canonical constructor and accessors. Consequently the options that would duplicate what a record already provides are rejected for records: --equals, --hashCode, and --validate all raise a build error on a record.

With --from / --with, records default to generating a copy factory for every component (a hand-written property model instead requires the + prefix per property). This is the one place where record mode is more generous than the property-model mode.


What @RecordDTO actually does

@RecordDTO is not a runtime marker and does nothing at runtime. It is a build-time analysis trigger. The mechanism is the same @Analyze service pattern used throughout the framework (see build-support.md → Info Files and services.md):

The annotation itself, in tentackle-common, is tiny — it exists only to be discovered and to name its handler:

@Documented
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
@Analyze("org.tentackle.buildsupport.RecordDTOAnalyzeHandler")
public @interface RecordDTO { }

The chain it sets in motion:

  1. Annotate. You put @RecordDTO on a record.
  2. Analyze (annotation-processing phase). Because the annotation is meta-annotated with @Analyze, the tentackle-maven-plugin routes it to RecordDTOAnalyzeHandler. The handler walks the record's components (via RecordComponentElement) and writes a small text file describing each component's type and name.
  3. Emit record.info. The result is a RecordDTOInfo serialized to a versioned file named record.info, placed in the analyze directory under the record's fully-qualified name. It is a stable, line-oriented format (one arg[n] line per component) that the write side and the read side agree on.
  4. Consume (wurblet phase). When the DTO wurblet runs inside that record, it locates and deserializes the record's record.info, turning each recorded component into a DTO property. From there generation proceeds as for any DTO — except that fields/getters/constructor are suppressed because the record supplies them.

If the annotation is missing, no record.info is produced, and the wurblet cannot discover the record's components. It fails with a pointed message — "reading info … failed (@RecordDTO annotation missing?)" — which is the single most common cause of a broken record-mode DTO build.

Why an info file instead of reflecting on the record? The wurblet runs as a source-weaving build step, not against compiled classes. The annotation-processing round is the reliable place to read a record's components with fully resolved types from source. Capturing them into record.info decouples the analyze phase from the wurblet phase, exactly as @RemoteMethod uses a .remote file for the RemoteMethod wurblet.

A couple of details worth knowing:

  • @RecordDTO targets types and is honored only on records. The handler ignores annotated annotation types and warns (then skips) if the annotated element is not a record.
  • The captured info is just type + name per component — enough to drive the builder and copy factories. Per-property annotations you want on the generated builder methods (e.g. @Bindable) are written directly on the record components in the usual Java way; they are not part of record.info.

What the wurblet generates, mode by mode

Generated element Property-model DTO @RecordDTO record
Private fields Yes (the wurblet owns the body) No — the record declares them
All-args constructor Yes (unless everything is mutable) No — the record's canonical constructor is used
Getters Yes (getX / isX) No — the record's accessors (x()) are used
Setters For = / ~ properties n/a (records are immutable)
--names constants (PN_*) Yes Yes
--builder Yes Yes (builder feeds the canonical constructor)
--from / --with copy factories per-property + prefix all components by default
--equals / --hashCode Yes Rejected (records already have them)
--validate Yes (needs Validateable) Rejected (use a compact canonical constructor)
--nofinal (serialization-friendly) Yes n/a
^ super-constructor forwarding Yes n/a

The options themselves are shared and documented in wurblets.md → The DTO wurblet and in the wurblet's @{comment}@ header. The table above only shows which apply in each mode.


When to use @RecordDTO vs. a property model

Ask, in order:

  1. Is a plain immutable record the natural shape, and do you only want the wurblet to add a builder and/or copy factories?record + @RecordDTO. You get the record's own fields, accessors, and equals/hashCode/toString from the compiler, and let the wurblet supply the ergonomic extras. This is the lightest path and should be the default for new value carriers on JDK records.

  2. Do you need a mutable or transient field, or setters?property model (= / ~ prefixes). Records are immutable; there is no record-mode equivalent.

  3. Must the carrier extend a superclass and forward some fields to its constructor, or generate equals/hashCode across an inheritance hierarchy?property model (^ prefix; --equals / --hashCode). Records cannot extend a class, and those options are rejected in record mode.

  4. Do you want post-construction validation baked into the constructor via --validate?property model (implement Validateable). In record mode, put the equivalent checks in a compact canonical constructor instead.

  5. Do you need serialization-friendly non-final fields (--nofinal, e.g. for a serializer that cannot touch final fields)?property model. A record's components are inherently final.

Everything else — name constants, builder, copy factories, per-property annotations on the generated methods, --canonical for TRIP, --nott for a Tentackle-free class — works in both modes, so the list above is the whole of the decision.


A worked example: Position3D

The embedded-vs-datatype discussion uses a three-coordinate Position3D value. It is exactly the kind of thing record-mode DTOs are for: an immutable value whose fields, accessors, and equals/hashCode come from the record, with the wurblet adding a builder (here annotated for Jackson) and with… copy factories:

@RecordDTO
public record Position3D(@Bindable(options = "AUTOSELECT") int x,
                         @Bindable(options = "AUTOSELECT") int y,
                         @Bindable(options = "AUTOSELECT") int z) implements Serializable {

  public static final Position3D ORIGIN = new Position3D(0, 0, 0);

  // @wurblet dto DTO '--builder[@JsonPOJOBuilder(withPrefix = "")]' --with
  //<editor-fold ...>//GEN-BEGIN:dto
  // generated: a Builder (feeding the canonical constructor) and withX/withY/withZ copy factories
  //</editor-fold>//GEN-END:dto

  public Position3D midPoint(Position3D other) { /* ... */ }
}
  • @RecordDTO makes the analyze phase emit a record.info describing int x, int y, int z.
  • The wurblet reads that file, sees it is in a record, and generates only a Builder and withX/withY/withZ copy methods — no fields, no getters, no all-args constructor, because the record already has them.
  • @Bindable on each component rides along on the record; it is not stored in record.info but stays visible on the accessors and (as the wurblet weaves them) the builder methods.

Compare this with a property-model CustomerDTO that needs a mutable note field and generated equals: that one must be a property model, because a record cannot express a setter.