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.

For the model above, without any options, the generated region contains (Javadoc abbreviated):

  /** the object's name. */
  private final String name;

  /** the counter. */
  private final int count;

  /** a free-text note. */
  private String note;

  /**
   * Creates a CustomerDTO.
   * ...
   */
  public CustomerDTO(String name, int count) {
    this.name = name;
    this.count = count;
  }

  public String getName() {
    return name;
  }

  public int getCount() {
    return count;
  }

  public String getNote() {
    return note;
  }

  public void setNote(String note) {
    this.note = note;
  }

The mutable note is not a constructor argument. It is set through its setter after construction.

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. Annotations on the record components (e.g. @Bindable) stay where Java puts them — on the fields, the accessors and the canonical constructor's parameters. They are not part of record.info, so the generated builder and copy methods carry no annotations in record mode. A property model can annotate them with the [@Anno] syntax.

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
Constructor all immutable properties as arguments, or a protected constructor taking the Builder in builder mode only in builder mode: a protected constructor taking the Builder and delegating to the canonical one
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
toBuilder() (--builder with --from/--with) Yes Yes
Annotations on generated methods [@Anno] per property, global [...] line none
--equals / --hashCode Yes Rejected (records already have them)
--validate Yes (needs Validateable) Rejected (use a compact canonical constructor)
--nofinal (serialization-friendly) Yes Not rejected, but do not use: the generated no-args constructor does not compile in a record
^ 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.


Builder mode in detail

--builder (or --builderScope/--builderPrefix, which imply it) replaces the all-args constructor with a nested public static class Builder, a static builder() factory and a protected constructor that takes the builder. The details are easy to miss:

  • Only immutable properties are in the builder. A mutable (=/~) property has no builder method. Set it with its setter on the built object.
  • Required properties. For each ! property the builder records whether it was set. build() throws a TentackleRuntimeException (a plain RuntimeException with --nott) naming the first missing property.
  • Builder method names. By default a builder method is named like the property (name(...)), with --builderPrefix=with it becomes withName(...). The builder's constructor gets the scope of --builderScope, protected by default, so builder() is the public entry point.
  • toBuilder(). Together with --from or --with, the DTO gets a toBuilder() method and the builder a constructor taking a template instance. The copy factories then delegate to it: return toBuilder().name(name).build();. If the DTO also has mutable properties, the copy factories copy them over via their setters.
  • Annotating the builder. --builder[@Anno] puts an annotation on the Builder class, for example @JsonPOJOBuilder(withPrefix = "") so Jackson can deserialize through the builder.

Inheritance

A property-model DTO may extend another DTO:

  • Without builder, ^-prefixed properties are the ones the superclass declares. The generated constructor takes them as well and passes them to super(...). The copy factories read them through their getters.
  • With builder, ^ is rejected. The generated Builder instead extends the superclass's Builder, the constructor calls super(builder), and build() (and toBuilder(), if generated) override the superclass's. So every class of the hierarchy must be generated in builder mode. An abstract DTO's build() throws, because there is nothing to instantiate.
  • equals/hashCode. The generated methods call super.equals/super.hashCode when the class has a superclass. If a subclass is generated with --equals or --hashCode, all its superclasses must be generated with the same option.

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 is a plain Java annotation on the record. It is not stored in record.info, and the generated builder methods do not repeat it.

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.