TimefoldAI / TimefoldAI/timefold-solver
Service SDK: ModelConfigOverrides records are forced to declare an all-null no-arg constructor
Nobody has claimed this yet.
- Dominant language
- Java
- Stars
- 1.8k
- Forks
- 228
- Avg merge
- 1d 13h
- Merged PRs (30d)
- 46
Description
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?" — https://github.com/TimefoldAI/timefold-quickstarts/pull/1112#discussion_r3569353451
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
ConfigOverridesfor 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
nullper 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.
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start in DefaultConfigProfileProcessor and trace all SDK paths that reflectively instantiate user config types. Compare record component constructors with the existing no-arg path, then verify that records can be instantiated without null-litany constructors while ordinary classes retain the no-arg behavior.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- java
- Domain
- backend
- Issue type
- Refactor
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Quiet
- Clarity
- Mostly clear
- Newbie friendliness
- 48/100