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:
- a small property model — a here-document describing the properties, or
- a Java
recordannotated 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
recordand annotate it@RecordDTOwhenever 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, generatedequals/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:
- Annotate. You put
@RecordDTOon arecord. - Analyze (annotation-processing phase). Because the annotation is meta-annotated with
@Analyze, thetentackle-maven-pluginroutes it toRecordDTOAnalyzeHandler. The handler walks the record's components (viaRecordComponentElement) and writes a small text file describing each component's type and name. - Emit
record.info. The result is aRecordDTOInfoserialized to a versioned file namedrecord.info, placed in the analyze directory under the record's fully-qualified name. It is a stable, line-oriented format (onearg[n]line per component) that the write side and the read side agree on. - 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.infodecouples the analyze phase from the wurblet phase, exactly as@RemoteMethoduses a.remotefile for theRemoteMethodwurblet.
A couple of details worth knowing:
@RecordDTOtargets 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 ofrecord.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:
-
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, andequals/hashCode/toStringfrom 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. -
Do you need a mutable or transient field, or setters? → property model (
=/~prefixes). Records are immutable; there is no record-mode equivalent. -
Must the carrier extend a superclass and forward some fields to its constructor, or generate
equals/hashCodeacross an inheritance hierarchy? → property model (^prefix;--equals/--hashCode). Records cannot extend a class, and those options are rejected in record mode. -
Do you want post-construction validation baked into the constructor via
--validate? → property model (implementValidateable). In record mode, put the equivalent checks in a compact canonical constructor instead. -
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) { /* ... */ }
}
@RecordDTOmakes the analyze phase emit arecord.infodescribingint x,int y,int z.- The wurblet reads that file, sees it is in a record, and generates only a
BuilderandwithX/withY/withZcopy methods — no fields, no getters, no all-args constructor, because the record already has them. @Bindableon each component rides along on the record; it is not stored inrecord.infobut 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.
Related Documentation¶
- Wurblets — Interface-Level Code Generation — the module this wurblet belongs to, the anchor/guard mechanism, and the shared option reference in The DTO wurblet.
- Embedded Entities vs. Application-Specific DataTypes — the
Position3Dvalue type and why a bare value (not a PDO) is modeled as a record/DataType. - Build Support — the
@Analyzehandlers and the info-file bridge, includingRecordDTOAnalyzeHandlerandRecordDTOInfo. @RecordDTOand Common — where the annotation lives.- Services /
ServiceFinderand@Analyze— the build-time analysis mechanism the annotation plugs into. - Validation —
--validateandValidateablefor property-model DTOs. - TRIP —
--canonicaland how DTOs travel between JVMs.