Context
While addressing review feedback on TimefoldAI/timefold-quickstarts#1112 (conference-scheduling conversion to the Models Service SDK), this constructor had to stay in the model's ModelConfigOverrides record:
public ConferenceScheduleConfigOverrides() {
this(null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null,
null, null);
}
Review comment (rightly): "One of those ridiculous constructors. This code does not tell the reader anything. What does the 15th null do?" — TimefoldAI/timefold-quickstarts#1112 (comment)
Why the model can't avoid it
DefaultConfigProfileProcessor (service quarkus deployment module) instantiates the model's ModelConfigOverrides type reflectively via getDeclaredConstructor().newInstance(), i.e. it hard-requires a no-arg constructor. For a Java record that requirement is inherently ugly:
- Every record has a canonical constructor covering all components; a
ConfigOverrides for a model with 20 constraints has 20 components.
- Every secondary constructor must delegate to the canonical one (JLS 8.10.4), so a no-arg constructor is forced to spell out one
null per component.
- The canonical constructor can't be hidden either: it must be at least as accessible as the record class, and these records are public (REST/Jackson/OpenAPI).
So every converted model carries an N-null constructor whose only purpose is to satisfy the SDK's reflection, and N grows with the constraint count.
Suggested fix
Records carry everything needed to construct an "empty" instance without a no-arg constructor. In DefaultConfigProfileProcessor (and anywhere else the SDK reflectively instantiates user config types):
static <T> T emptyInstance(Class<T> type) throws ReflectiveOperationException {
if (type.isRecord()) {
var components = type.getRecordComponents();
var paramTypes = Arrays.stream(components).map(RecordComponent::getType).toArray(Class<?>[]::new);
var args = Arrays.stream(components)
.map(c -> defaultValueFor(c.getType())) // null for references, 0/false for primitives
.toArray();
return type.getDeclaredConstructor(paramTypes).newInstance(args);
}
return type.getDeclaredConstructor().newInstance();
}
This keeps backwards compatibility (classes still use the no-arg constructor), and models can then delete the null-litany constructor entirely. A compact constructor that applies defaults/normalization still runs, since the canonical constructor is invoked.
Why not "just use a class instead of a record"
Records are the right vehicle for these DTOs and the SDK conventions already lean on that: immutability by construction, a single canonical construction path whose compact constructor centralizes normalization/validation, component-driven schema generation (@ConstraintReference/@Schema on components), and free equals/hashCode/toString. Replacing a 20-component record with a class means 20 fields + 20 accessors + hand-written plumbing — strictly more of exactly the boilerplate the quickstarts review asked to remove — and a mutable overrides object shared through profile merging is a footgun. So the class route is not a good trade.
That said, if there's a better way to solve this than the record-aware instantiation sketched above, ideas are very welcome.
Context
While addressing review feedback on TimefoldAI/timefold-quickstarts#1112 (conference-scheduling conversion to the Models Service SDK), this constructor had to stay in the model's
ModelConfigOverridesrecord:Review comment (rightly): "One of those ridiculous constructors. This code does not tell the reader anything. What does the 15th null do?" — TimefoldAI/timefold-quickstarts#1112 (comment)
Why the model can't avoid it
DefaultConfigProfileProcessor(service quarkus deployment module) instantiates the model'sModelConfigOverridestype reflectively viagetDeclaredConstructor().newInstance(), i.e. it hard-requires a no-arg constructor. For a Java record that requirement is inherently ugly:ConfigOverridesfor a model with 20 constraints has 20 components.nullper component.So every converted model carries an N-null constructor whose only purpose is to satisfy the SDK's reflection, and N grows with the constraint count.
Suggested fix
Records carry everything needed to construct an "empty" instance without a no-arg constructor. In
DefaultConfigProfileProcessor(and anywhere else the SDK reflectively instantiates user config types):This keeps backwards compatibility (classes still use the no-arg constructor), and models can then delete the null-litany constructor entirely. A compact constructor that applies defaults/normalization still runs, since the canonical constructor is invoked.
Why not "just use a class instead of a record"
Records are the right vehicle for these DTOs and the SDK conventions already lean on that: immutability by construction, a single canonical construction path whose compact constructor centralizes normalization/validation, component-driven schema generation (
@ConstraintReference/@Schemaon components), and freeequals/hashCode/toString. Replacing a 20-component record with a class means 20 fields + 20 accessors + hand-written plumbing — strictly more of exactly the boilerplate the quickstarts review asked to remove — and a mutable overrides object shared through profile merging is a footgun. So the class route is not a good trade.That said, if there's a better way to solve this than the record-aware instantiation sketched above, ideas are very welcome.