6767 * <li>{@code origin.collection} ({@code @via "Parent.rel"}) —
6868 * {@code List<TargetPayload>}, and the nested payload class is recursively
6969 * emitted alongside (deduped per {@link #execute(MetaDataLoader)} run).</li>
70- * <li>No origin child — fall back to {@link SpringTypeMapper#javaTypeName(MetaField)}.</li>
70+ * <li>{@code field.object} with {@code @objectRef} (no {@code origin.*} child) —
71+ * recursively emit {@code <TargetShortName>Payload} (per-run deduped) and
72+ * return that type, or {@code java.util.List<TargetPayload>} when the field
73+ * is {@code isArray: true}. Mirrors Kotlin's plain {@code ObjectField}
74+ * arm.</li>
75+ * <li>No origin child and not a {@code field.object} — fall back to
76+ * {@link SpringTypeMapper#javaTypeName(MetaField)}.</li>
7177 * </ul>
7278 *
79+ * <p>Class-naming convention: the emitted record name is
80+ * {@code <PascalCaseTemplateShortName>Payload}. Templates declared in
81+ * {@code camelCase} (e.g. {@code adjudicationUser}) are capitalised before
82+ * appending {@code Payload}, matching Kotlin/C#/TS/Python and Java's own
83+ * PascalCase class-naming convention. Nested payload class names from
84+ * {@code origin.collection} / {@code field.object} arms are capitalised the
85+ * same way.
86+ *
7387 * <p>Skips and defensive cases:
7488 * <ul>
7589 * <li>Missing {@code @payloadRef} — skipped (loader's validation pass
7690 * normally rejects this first; defensive only).</li>
7791 * <li>{@code @payloadRef} resolves to a non-VO target — skipped (payloads
7892 * must be {@code object.value}; matches the cross-port contract).</li>
7993 * <li>Templates are processed in stable name order for deterministic emission.</li>
80- * <li>{@link ObjectField} children are skipped (mirrors {@link SpringDtoGenerator}).</li>
8194 * </ul>
8295 *
8396 * <p>Args:
@@ -133,7 +146,7 @@ private void emit(MetaTemplate template, MetaDataLoader loader, Path outRoot,
133146 String templatePkg = split [0 ];
134147 String templateShort = split [1 ];
135148 String outPkg = templatePkg .isEmpty () ? "prompts" : templatePkg + ".prompts" ;
136- String recordName = templateShort + "Payload" ;
149+ String recordName = capitalizeFirst ( templateShort ) + "Payload" ;
137150
138151 emitPayloadRecord (
139152 outPkg ,
@@ -165,8 +178,12 @@ private void emitPayloadRecord(String outPkg,
165178 src .append ("/** " ).append (banner ).append (" */\n " );
166179 src .append ("public record " ).append (recordName ).append ("(\n " );
167180
168- List <MetaField > fields = scalarFields (voObject );
169- Iterator <MetaField > it = fields .iterator ();
181+ // Route every field through resolveFieldType — including ObjectField,
182+ // which needs the field.object arm (single ref or isArray list). The
183+ // previous scalarFields() filter dropped ObjectField entirely; mirror
184+ // KotlinPayloadGenerator's no-filter iteration. getMetaFields()
185+ // returns Collection, so go through the iterator directly.
186+ Iterator <MetaField > it = voObject .getMetaFields ().iterator ();
170187 while (it .hasNext ()) {
171188 MetaField field = it .next ();
172189 String type = resolveFieldType (field , loader , outPkg , outRoot , emittedNestedFqns );
@@ -187,19 +204,17 @@ private void emitPayloadRecord(String outPkg,
187204 }
188205
189206 /**
190- * Resolve the Java type expression of a single payload-VO field, honoring
191- * any {@code origin.*} child. Falls back to
192- * {@link SpringTypeMapper#javaTypeName} when no origin is present.
207+ * Resolve the Java type expression of a single payload-VO field. Precedence:
208+ * (1) {@code origin.*} child wins if present; (2) otherwise a
209+ * {@code field.object} routes through the nested-payload emission arm; (3)
210+ * otherwise the scalar fallback via {@link SpringTypeMapper#javaTypeName}.
193211 */
194212 private String resolveFieldType (MetaField <?> field ,
195213 MetaDataLoader loader ,
196214 String nestedPkg ,
197215 Path outRoot ,
198216 Set <String > emittedNestedFqns ) {
199217 MetaOrigin origin = firstOriginChild (field );
200- if (origin == null ) {
201- return SpringTypeMapper .javaTypeName (field );
202- }
203218 if (origin instanceof PassthroughOrigin pt ) {
204219 return resolvePassthroughType (pt , loader , field );
205220 }
@@ -209,9 +224,35 @@ private String resolveFieldType(MetaField<?> field,
209224 if (origin instanceof CollectionOrigin co ) {
210225 return resolveCollectionType (co , loader , nestedPkg , outRoot , emittedNestedFqns , field );
211226 }
227+ if (field instanceof ObjectField of ) {
228+ return resolveObjectFieldType (of , loader , nestedPkg , outRoot , emittedNestedFqns );
229+ }
212230 return SpringTypeMapper .javaTypeName (field );
213231 }
214232
233+ /**
234+ * Naked {@code field.object @objectRef}: recursively emit
235+ * {@code <CapitalizedTargetShortName>Payload} for the referenced VO, and
236+ * return that type — or {@code java.util.List<TargetPayload>} when
237+ * {@code isArray: true}. Mirrors Kotlin's plain-{@code ObjectField} arm in
238+ * {@code KotlinPayloadGenerator.resolveFieldType}.
239+ */
240+ private String resolveObjectFieldType (ObjectField field ,
241+ MetaDataLoader loader ,
242+ String nestedPkg ,
243+ Path outRoot ,
244+ Set <String > emittedNestedFqns ) {
245+ MetaObject target = field .getObjectRef ();
246+ if (target == null ) return SpringTypeMapper .javaTypeName (field );
247+ if (!MetaObject .SUBTYPE_VALUE .equals (target .getSubType ())) {
248+ // Same payload-VO contract as @payloadRef — nested payloads must
249+ // themselves be object.value. Loader validation normally catches
250+ // this; defensive fallback to scalar type.
251+ return SpringTypeMapper .javaTypeName (field );
252+ }
253+ return emitNestedAndReturnType (target , loader , nestedPkg , outRoot , emittedNestedFqns , field .isArray ());
254+ }
255+
215256 /**
216257 * {@code origin.passthrough @from "Entity.field"}: resolve to the source
217258 * field's Java type. Falls back to the payload field's own type if the
@@ -293,22 +334,39 @@ private String resolveCollectionType(CollectionOrigin origin,
293334
294335 MetaObject target = resolveObjectByShortOrFqn (loader , targetRef );
295336 if (target == null ) return SpringTypeMapper .javaTypeName (fallbackField );
337+ return emitNestedAndReturnType (target , loader , nestedPkg , outRoot , emittedNestedFqns , true );
338+ }
296339
297- String nestedShort = SpringNaming .splitFqn (target .getName ())[1 ];
340+ /**
341+ * Shared nested-payload emit path used by {@link #resolveCollectionType}
342+ * (always a list) and {@link #resolveObjectFieldType} (single or list,
343+ * depending on {@code asList}). Emits the record at most once per run via
344+ * the {@code emittedNestedFqns} dedupe set, and returns the type expression
345+ * to use as the parent field's component type. Returns
346+ * {@code java.util.List<TargetPayload>} (fully-qualified to sidestep any
347+ * potential {@code List} import collision in generated code) when
348+ * {@code asList} is true, else just {@code TargetPayload}.
349+ */
350+ private String emitNestedAndReturnType (MetaObject target ,
351+ MetaDataLoader loader ,
352+ String nestedPkg ,
353+ Path outRoot ,
354+ Set <String > emittedNestedFqns ,
355+ boolean asList ) {
356+ String nestedShort = capitalizeFirst (SpringNaming .splitFqn (target .getName ())[1 ]);
298357 String nestedRecord = nestedShort + "Payload" ;
299-
300358 if (emittedNestedFqns .add (target .getName ())) {
301359 emitPayloadRecord (
302360 nestedPkg ,
303361 nestedRecord ,
304- "GENERATED — nested payload for collection target `" + target .getName () + "`. "
362+ "GENERATED — nested payload for `" + target .getName () + "`. "
305363 + "Do not hand-edit; regenerated from metadata." ,
306364 target ,
307365 loader ,
308366 outRoot ,
309367 emittedNestedFqns );
310368 }
311- return "java.util.List<" + nestedRecord + ">" ;
369+ return asList ? "java.util.List<" + nestedRecord + ">" : nestedRecord ;
312370 }
313371
314372 // -------------------------------------------------------------------------
@@ -380,14 +438,18 @@ private static String shortName(String fqn) {
380438 return idx < 0 ? fqn : fqn .substring (idx + 2 );
381439 }
382440
383- /** Scalar payload fields — same filter SpringDtoGenerator uses (skips ObjectField). */
384- private static List <MetaField > scalarFields (MetaObject vo ) {
385- List <MetaField > out = new ArrayList <>();
386- for (MetaField field : vo .getMetaFields ()) {
387- if (field instanceof ObjectField ) continue ;
388- out .add (field );
389- }
390- return out ;
441+ /**
442+ * Uppercase the first character of {@code s}; pass {@code s} through
443+ * unchanged when empty or already capitalised. Used to PascalCase the
444+ * payload record name regardless of whether the template short name was
445+ * authored as {@code camelCase} or {@code PascalCase}, matching Kotlin /
446+ * C# / TS / Python convention + Java's PascalCase class-naming rule.
447+ */
448+ private static String capitalizeFirst (String s ) {
449+ if (s == null || s .isEmpty ()) return s ;
450+ char c0 = s .charAt (0 );
451+ if (Character .isUpperCase (c0 )) return s ;
452+ return Character .toUpperCase (c0 ) + s .substring (1 );
391453 }
392454
393455 // === MultiFileDirectGeneratorBase abstract-method stubs ====================
@@ -418,6 +480,6 @@ protected String getSingleOutputFilePath(MetaObject md) {
418480
419481 @ Override
420482 protected String getSingleOutputFilename (MetaObject md ) {
421- return SpringNaming .splitFqn (md .getName ())[1 ] + "Payload.java" ;
483+ return capitalizeFirst ( SpringNaming .splitFqn (md .getName ())[1 ]) + "Payload.java" ;
422484 }
423485}
0 commit comments