TimefoldAI / TimefoldAI/timefold-solver

Service SDK: ModelConfigOverrides records are forced to declare an all-null no-arg constructor

Open
#2,493 2 comments 0 reactions 0 assignees View on GitHub

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 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.

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.