Skip to content

[KOTLIN-SPRING;JAVA-SPRING] bugfix: update JsonInclude annotations for optional+nullable fields to handle serialization correctly when openApiNullable = true - #24185

Merged
wing328 merged 4 commits into
OpenAPITools:masterfrom
Picazsoo:bugfix/open-api-nullable-json-include-non-absent-for-JsonNullable
Jul 7, 2026
Merged

[KOTLIN-SPRING;JAVA-SPRING] bugfix: update JsonInclude annotations for optional+nullable fields to handle serialization correctly when openApiNullable = true#24185
wing328 merged 4 commits into
OpenAPITools:masterfrom
Picazsoo:bugfix/open-api-nullable-json-include-non-absent-for-JsonNullable

Conversation

@Picazsoo

@Picazsoo Picazsoo commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

Fix: @JsonInclude(NON_ABSENT) for JsonNullable fields in kotlin-spring and java-spring generators

Issue

When openApiNullable=true, optional+nullable fields are generated as JsonNullable<T> but without any @JsonInclude annotation. This means JsonNullable.undefined() (the "absent" state) could be serialized into the JSON output, defeating the purpose of the three-state wrapper.

Fix

Added @field:JsonInclude(JsonInclude.Include.NON_ABSENT) to any field where x-is-jackson-optional-nullable is true, in dataClassOptVar.mustache. This ensures:

State Serialized?
JsonNullable.undefined() ❌ excluded
JsonNullable.of(null) ✅ included as null
JsonNullable.of("value") ✅ included as "value"

This is a nasty bug that should be fixed - because someone using the generated clients for patch operations might be unknowingly deleting resources even when setting JsonNullable as undefined.

PR checklist

  • Read the contribution guidelines.
  • Run the following to build the project and update samples:
    ./mvnw clean package || exit
    ./bin/generate-samples.sh ./bin/configs/*.yaml || exit
    ./bin/utils/export_docs_generators.sh || exit
    
    (For Windows users, please run the script in WSL)
    Commit all changed files.
    This is important, as CI jobs will verify all generator outputs of your HEAD commit as it would merge with master.
    These must match the expectations made by your contribution.
    You may regenerate an individual generator by passing the relevant config(s) as an argument to the script, for example ./bin/generate-samples.sh bin/configs/java*.
    IMPORTANT: Do NOT purge/delete any folders/files (e.g. tests) when regenerating the samples as manually written tests may be removed.
  • If your PR is targeting a particular programming language, @mention the technical committee members, so they are more likely to review the pull request. - spring: @cachescrubber (2022/02) @welshm (2022/02) @MelleD (2022/02) @atextor (2022/02) @manedev79 (2022/02) @javisst (2022/02) @borsch (2022/02) @banlevente (2022/02) @Zomzog (2022/09) @martin-mfg (2023/08); kotlin: @karismann (2019/03) @Zomzog (2019/04) @andrewemery (2019/10) @4brunu (2019/11) @yutaka0m (2020/03) @stefankoppier (2022/06) @e5l (2024/10) @dennisameling (2026/02), @wing328

Summary by cubic

Fixes serialization of optional+nullable fields for kotlin-spring and java-spring when openApiNullable=true by adding @JsonInclude(JsonInclude.Include.NON_ABSENT) to org.openapitools.jackson.nullable.JsonNullable fields. Undefined values are omitted; nulls and concrete values are included.

  • Bug Fixes
    • kotlin-spring: emit @field:JsonInclude(JsonInclude.Include.NON_ABSENT) in dataClassOptVar.mustache when x-is-jackson-optional-nullable is true.
    • java-spring: emit @JsonInclude(JsonInclude.Include.NON_ABSENT) in JavaSpring/pojo.mustache; ensure JsonInclude import in SpringCodegen.postProcessModelProperty for optional+nullable fields when openApiNullable=true.
    • Tests: expanded Java and Kotlin generator tests for openApiNullable true/false; added Kotlin runtime tests to verify undefined/null/value serialization.
    • Samples: regenerated Spring Boot and Spring HTTP Interface models to include NON_ABSENT on JsonNullable fields.

Written for commit 000a348. Summary will update on new commits.

Review in cubic

@Picazsoo
Picazsoo marked this pull request as ready for review July 2, 2026 10:41
@Picazsoo
Picazsoo marked this pull request as draft July 2, 2026 10:41

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No issues found across 4 files

Re-trigger cubic

@Picazsoo Picazsoo changed the title [KOTLIN-SPRING] fix: update JsonInclude annotations for optional+nullable fields to handle serialization correctly when openApiNullable = true [KOTLIN-SPRING;JAVA-SPRING] fix: update JsonInclude annotations for optional+nullable fields to handle serialization correctly when openApiNullable = true Jul 2, 2026
@Picazsoo
Picazsoo marked this pull request as ready for review July 2, 2026 13:40
@Picazsoo Picazsoo changed the title [KOTLIN-SPRING;JAVA-SPRING] fix: update JsonInclude annotations for optional+nullable fields to handle serialization correctly when openApiNullable = true [KOTLIN-SPRING;JAVA-SPRING] bugfix: update JsonInclude annotations for optional+nullable fields to handle serialization correctly when openApiNullable = true Jul 2, 2026

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No issues found across 7 files

Re-trigger cubic

@wing328 wing328 added this to the 7.24.0 milestone Jul 7, 2026
@wing328
wing328 merged commit 5bf1037 into OpenAPITools:master Jul 7, 2026
138 checks passed
@ugrepel

ugrepel commented Jul 22, 2026

Copy link
Copy Markdown

We just ran into this fix when renovate updated openapi generator from 7.23.0 to 7.24.0 where it is included: our openapi spec has an optional field with nullable: true, and beginning with 7.24.0, the https://github.com/field:JsonInclude(JsonInclude.Include.NON_NULL) annotation is added. (We're using kotlin-spring here). Consequently, the serialized json doesn't include the field anymore when it is set to null. Is this intended?

(add. info: we're not using openApiNullable here)

@MelleD

MelleD commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

I also think we should be careful with these annotations in generated code because they make it difficult to override them.
Normally, you can simply set the desired include in the JsonMapper or ObjectMapper.
I don't see any reason to include something like that in the generated code.

@Picazsoo

Picazsoo commented Jul 22, 2026

Copy link
Copy Markdown
Contributor Author

We just ran into this fix when renovate updated openapi generator from 7.23.0 to 7.24.0 where it is included: our openapi spec has an optional field with nullable: true, and beginning with 7.24.0, the https://github.com/field:JsonInclude(JsonInclude.Include.NON_NULL) annotation is added. (We're using kotlin-spring here). Consequently, the serialized json doesn't include the field anymore when it is set to null. Is this intended?

(add. info: we're not using openApiNullable here)

Hello @ugrepel , I would need to see a sample spec. How do you specify the nullable: true for the attribute?
I tried some schemas on my end and I cannot reproduce it. My suspicion is that you have OAS 3.0.x with something invalid like:

optRefSiblingNullable:
  nullable: true
  $ref: '#/components/schemas/Sub'

instead of:

optRefSiblingNullable:
  nullable: true
  allOf:
    - $ref: '#/components/schemas/Sub'

or you are using nullable: true but your spec is actually OAS 3.1.x which ignores the nullable attribute entirely.

@Picazsoo

Copy link
Copy Markdown
Contributor Author

I also think we should be careful with these annotations in generated code because they make it difficult to override them. Normally, you can simply set the desired include in the JsonMapper or ObjectMapper. I don't see any reason to include something like that in the generated code.

You raise a fair point about override precedence, but I'd argue the annotation is the right call here - specifically for the optional non-nullable case.

For an optional + non-nullable field, the Kotlin null isn't a JSON value; it's the "absent" sentinel. There's no other way to represent "not set" for a non-nullable type than T? = null. So the only correct serialization of that null is to omit the key. Sending "field": null would violate the schema. A strict consumer should reject an explicit null on a non-nullable field, so @JsonInclude(NON_NULL) just encodes that contract at the source.

Why the per-field annotation beats "just configure the ObjectMapper":

  • The null-inclusion policy is schema-dependent and differs field-to-field. A genuinely nullable: true field should serialize null (it's meaningful), while an optional non-nullable field must not. A single global mapper inclusion setting can't distinguish these two. It is too coarse to express both correctly within the same model.
  • Spring's default Jackson inclusion is ALWAYS. So without the annotation, the default behavior is to emit spec-invalid explicit nulls for absent optional non-nullable fields. The annotation keeps the output spec-compliant regardless of mapper config. The only other safe-looking global default, NON_NULL , is even worse: it would silently drop meaningful nulls on genuinely nullable: true fields.

For completeness, the nullable + optional case (without openApiNullable) is unaffected by this: the field carries no @JsonInclude, so a null serializes as an explicit "field": null. That's arguably questionable - it can wreak havoc with PATCH semantics, since "unset" and "explicitly set to null" both go out as null - but it's the long-standing behavior here and not a regression of any kind. I can imagine the generator could emit a warning that this risk exists and setting openApiNullable to true would allow the serializer to properly differentiate.

The kernel of truth in your concern: field-level @JsonInclude does outrank the global mapper, so it is hard to override. But the only thing you'd override here is producing a payload that contradicts the field's own non-nullable contract, which I would say is not a legitimate use case. If someone genuinely wants null on the wire, the correct fix is to mark the field nullable: true, not to loosen serialization.

So the annotation isn't an arbitrary opinion baked into the code. It is the schema's own nullability contract expressed at the only granularity that can represent it.

I am looking forward to what your thoughts are on the above.

@ugrepel

ugrepel commented Jul 22, 2026

Copy link
Copy Markdown

We just ran into this fix when renovate updated openapi generator from 7.23.0 to 7.24.0 where it is included: our openapi spec has an optional field with nullable: true, and beginning with 7.24.0, the https://github.com/field:JsonInclude(JsonInclude.Include.NON_NULL) annotation is added. (We're using kotlin-spring here). Consequently, the serialized json doesn't include the field anymore when it is set to null. Is this intended?
(add. info: we're not using openApiNullable here)

Hello @ugrepel , I would need to see a sample spec. How do you specify the nullable: true for the attribute? I tried some schemas on my end and I cannot reproduce it. My suspicion is that you have OAS 3.0.x with something invalid like:

optRefSiblingNullable:
  nullable: true
  $ref: '#/components/schemas/Sub'

instead of:

optRefSiblingNullable:
  nullable: true
  allOf:
    - $ref: '#/components/schemas/Sub'

or you are using nullable: true but your spec is actually OAS 3.1.x which ignores the nullable attribute entirely.

uhhh... yes, we're using OAS 3.1.0 here.

If nullable is ignored it won't matter, but we've put the nullable directly in the properties definition of the field in question, no $ref in use here either. Can't post our source here, but in general it looks like

components:
  schemas:
    MySchema:
      type: object
      properties:
        id:
          type: integer
          format: int32
          example: 51
        myOptionalField:
          type: string
          nullable: true
          description: my optional field
          example: my optional field example value
        required:
        - id

If OAS 3.1.x ignores nullable - what's the suggested way to get a null value into a JSON field now?

@Picazsoo

Copy link
Copy Markdown
Contributor Author

Thanks for the prompt reply. In OAS 3.1 the way to specify nullability is to specify it in the type array.

components:
  schemas:
    MySchema:
      type: object
      required:
        - id
      properties:
        id:
          type: integer
          format: int32
        myOptionalField:
          type:
            - string
            - "null"
          description: my optional field

Does that resolve the issue?

I checked and the current validation message when using nullable with OAS 3.1.x is unfortunate. It mentions it as deprecated but it is in fact entirely ignored.

@Picazsoo

Copy link
Copy Markdown
Contributor Author

For the misleading validation message I opened a PR here #24389

@ugrepel

ugrepel commented Jul 22, 2026

Copy link
Copy Markdown

Thanks for the prompt reply. In OAS 3.1 the way to specify nullability is to specify it in the type array.

components:
  schemas:
    MySchema:
      type: object
      required:
        - id
      properties:
        id:
          type: integer
          format: int32
        myOptionalField:
          type:
            - string
            - "null"
          description: my optional field

Does that resolve the issue?

I checked and the current validation message when using nullable with OAS 3.1.x is unfortunate. It mentions it as deprecated but it is in fact entirely ignored.

Yes, it does, thanks for the info! Following OAS spec changes just isn't for the faint of heart...

@Picazsoo

Copy link
Copy Markdown
Contributor Author

Following OAS spec changes just isn't for the faint of heart...

Hard agree. I have learned a lot since I started contributing to this project. And there is still a lot more I probably do not grasp.

@MelleD

MelleD commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

@Picazsoo

The problem with the spec is that you can interpret it in different ways, and many people have opinions about it. You can see this in the various tickets too. I keep trying to say: take a step back and look at your use case independently of the spec, and what you can actually express with it. So far there haven't been good examples or use cases, even though people can somehow express things in the spec that make it seem "correct."

Nullable should always be considered in relation to required/mandatory.

Let me give a few examples.

Say you have a person where the name is mandatory. What semantic difference does it make (independent of the spec) whether the field is absent, blank, or null in the JSON?

From my point of view, it makes no semantic difference — the field simply isn't there, and accordingly it's an error. The same applies to mandatory collections, i.e., you can have multiple names, but at least one is mandatory.

What semantic difference does it make in JSON whether your collection is absent, null, or an empty array?

From my point of view, it makes no semantic difference. Applied to the spec, the case of required and nullable therefore makes little sense.

Now let's assume you have an optional name. Same question: what semantic difference does it make (independent of the spec) whether the field is absent, blank, or null in the JSON?

From my point of view, none — every time it should result in an Optional.empty

Now there's an exception case where the word NULL really does have a different semantic meaning than absent and blank, and that's the case of JSON Merge Patch, because there it means "reset." That's where you run into the semantic problem of needing a tri-state:

  • absent = undefined
  • null = reset
  • value = new value

From my point of view, you could have avoided this by giving the "null" state a meaning through meaningful names instead.

And this is where the "evil" begins. Now you have to distinguish this case in the spec(optional vs jsonnullable), and nullable was used for that (maybe not a good choice and didn't look into the new spec with >3.1). Since you can now represent 4 states with required and nullable, everyone interprets it differently, and everyone argues about the technical solutions and what's possible with them. But I believe that to truly solve this problem once and for all, you need to get clear on the semantics as mentioned above, and on what problem you actually want to solve.

Then you document the states and their meaning once, and it's done.

That's why I think so far the spec can represent the semantics quite well and implement REST best practices quite well:

  • Required field doesn't need nullable
  • Not required & nullable false = Optional
  • Not required & nullable true = JsonNullable (which I would only use for a patch, since that's what it's designed for)

I've also seen cases where people want to use Optional with null. I think that's more than just bad practice in Java, but because of the way the spec is worded, there are many possibilities. The question is simply whether every combination or possibility actually makes sense.

Maybe you have other examples for why you really need this. So far I've mostly seen examples that sound like workarounds.

By the way, I don't think the Spring defaults are a good argument, because you have to adjust most of them anyway.

@ugrepel

ugrepel commented Jul 23, 2026

Copy link
Copy Markdown

@MelleD

I'm actually fine with the spec and what it can express. As the JsonPatch reference shows there is a somewhat subtle difference between null and absence, and, other than e.g. Java or Kotlin, JSON is able to represent it directly.

We actually didn't have either - nullable: true or the OAS 3.1.0 variant of type: -string - "null" and fall into the trap with the automated update that fixed the generator bug (and showed our bug in the spec). Asking our current favorite AI presented us with the nullable: true solution which didn't work, and that brought me here. Digging deeper with the AI might have found the right solution for OAS 3.1.0 as well, but in this case, Picazsoo was faster.

Also, the OAS 3.1.0 change to specifying "null" as one of the types seems reasonable, just a bit unexpected.

@Picazsoo

Picazsoo commented Jul 23, 2026

Copy link
Copy Markdown
Contributor Author

@MelleD Thanks, this is a really useful framing and I agree with a lot of it. Let me go through it piece by piece.

The problem with the spec is that you can interpret it in different ways, and many people have opinions about it. You can see this in the various tickets too. I keep trying to say: take a step back and look at your use case independently of the spec, and what you can actually express with it.

Fully agree with the method. Reasoning from semantics is the right lens, and I'll use exactly that below.

Say you have a person where the name is mandatory. What semantic difference does it make (independent of the spec) whether the field is absent, blank, or null in the JSON?

From my point of view, it makes no semantic difference — the field simply isn't there, and accordingly it's an error. The same applies to mandatory collections, i.e., you can have multiple names, but at least one is mandatory.

What semantic difference does it make in JSON whether your collection is absent, null, or an empty array?

From my point of view, it makes no semantic difference. Applied to the spec, the case of required and nullable therefore makes little sense.

This is where I'd push back. Your examples are ones where null genuinely carries no distinct meaning, and for those I agree required+nullable adds nothing. But that doesn't generalize: "required" and "nullable" answer two orthogonal questions. Required is about presence (must the key appear?), nullable is about the value domain (may the value be null?). A field can legitimately be both, e.g. a PUT/full-representation payload where the client must always send middleName (absent = you forgot it → error) and null is the meaningful "this person has no middle name". Absent, null, and "Quinn" are three distinct, useful states there.

Now there's an exception case where the word NULL really does have a different semantic meaning than absent and blank, and that's the case of JSON Merge Patch, because there it means "reset." That's where you run into the semantic problem of needing a tri-state:

  • absent = undefined
  • null = reset
  • value = new value

From my point of view, you could have avoided this by giving the "null" state a meaning through meaningful names instead.

Agreed that merge-patch is the canonical tri-state case. Your "use meaningful names instead" alternative works in some designs, but it isn't always available: you often don't own the wire format (external/legacy API, RFC 7386 endpoints), and JSON already represents absent-vs-null directly, so inventing sentinel field names is itself the workaround. The required+nullable case above is really just the explicit, always-sent sibling of the same distinct-null semantics you grant here.

That's why I think so far the spec can represent the semantics quite well and implement REST best practices quite well:

  • Required field doesn't need nullable
  • Not required & nullable false = Optional
  • Not required & nullable true = JsonNullable (which I would only use for a patch, since that's what it's designed for)

This is a reasonable set of defaults, but I don't think it's a complete semantic model. It only covers three of the four combinations — required + nullable is simply absent, which quietly assumes the "required+nullable makes little sense" claim I pushed back on above. And "JsonNullable only for patch" is narrower than the semantics warrant: the absent-vs-null distinction it captures isn't unique to merge-patch. But set that aside, because my point is orthogonal to which rows we bless: given that these combinations already get generated today, where must the null-inclusion decision live? Even across your own rows, the required wire behavior differs per field (optional non-nullable must omit null; nullable must emit it), yet in the default config (openApiNullable=false) these fields all end up as plain T? = null on the JVM. So no single global ObjectMapper inclusion setting can be correct for all of them at once. That's why the annotation has to be per-field — it's an implementation-granularity fact, independent of the modeling debate.

Maybe you have other examples for why you really need this. So far I've mostly seen examples that sound like workarounds.

The strongest one isn't exotic: three fields in the same class. One optional non-nullable, one optional nullable, and one required nullable. Granting your semantic model, how would a single global ObjectMapper inclusion setting serialize all three correctly? Walk through the options (openApiNullable=false):

  • Include.ALWAYS → correct for the two nullable fields (emits null), but the optional non-nullable field now serializes "a": null for its "absent" state. That contradicts the field's own non-nullable contract, and a strict server will reject it.
  • Include.NON_NULL → correct for the optional non-nullable field (omits null), but now silently drops the meaningful null everywhere else. On the optional nullable field it becomes absent on the wire, so if a default is specified for it in the spec, a strict deserializer falls back to that default instead of the intended null, turning a lossy omission into an outright incorrect value. On the required nullable field it's worse still: null is a mandatory, legal value there, so dropping it produces an invalid payload that's missing a required field.
  • Any other global setting → same story; it applies uniformly and can't tell the fields apart.

The reason no global setting can win is structural: under openApiNullable=false these fields are all just T? = null on the JVM, so they're indistinguishable to a mapper-level policy while the schema demands different wire behavior for each. There is no value of the global knob that is correct for all at once, which is precisely why the fix is a per-field annotation rather than a mapper setting.

By the way, I don't think the Spring defaults are a good argument, because you have to adjust most of them anyway.

Agreed, and I want to be explicit: my argument does not rest on Spring's default. Even assuming you tune the mapper freely, there is still no single global inclusion value that is simultaneously correct for these fields in the same class, because at the JVM level they're indistinguishable (T? = null) under openApiNullable=false. The default only determines which wrong output you get, not whether a correct global choice exists.

(openApiNullable=true avoids the collision, because the optional-nullable field becomes JsonNullable<T> whose absent/present states Jackson can distinguish structurally via NON_ABSENT. It also forces the developer to actively decide between undefined vs null at the call site, rather than conflating them behind a single T? = null. But that's not the default configuration, and it's precisely the default path that this thread's report hit.)


On the practical path forward: the annotations are inherently per-field, since the correct inclusion differs field-to-field, and I'd keep that as the default (annotations on). Maybe we can add a new configOption flag for anyone who wants to own inclusion entirely at the ObjectMapper level? Such a flag should strip all the generated inclusion annotations wholesale - a global on/off. But IMO it should definitely be off by default (meaning - include the field-level annotations unless overriden)

@Picazsoo

Copy link
Copy Markdown
Contributor Author

One more angle that I think cuts to the heart of it: it's worth being precise about what these annotations actually take away from anyone, because I don't think it's anything legitimate.

For an optional non-nullable field, Kotlin null isn't a JSON value. It's the only available "absent" sentinel for a T? = null property. So the sole thing @JsonInclude suppresses is emitting "field": null on a field the schema itself declares non-nullable. That output is spec-invalid by construction, and a correctly implemented strict server will reject it.

It's also worth looking at what the generator actually emits, because it makes the "this is too strict / hard to override" worry less sharp than it sounds. For an optional non-nullable field the template doesn't only add @JsonInclude. It emits a symmetric pair (see e.g. the generated Category.name):

@field:JsonInclude(JsonInclude.Include.NON_NULL)
@field:JsonSetter(nulls = Nulls.SKIP)

That's Postel's law applied per field, and only on the openApiNullable=false path:

  • On serialization, be conservative in what you send: never emit "field": null for a field the schema declares non-nullable, since that output is spec-invalid by construction.
  • On deserialization, be liberal in what you accept: if a non-compliant peer does send "field": null, Nulls.SKIP just ignores it and leaves the property at its default, rather than shoving null into a non-nullable property or failing the parse. You can very well argue that this is incorrect and we should be stricter in rejecting such attributes.

So the annotations aren't a rigid contract that rejects real traffic. The generator is strict about the invalid thing it produces and tolerant about the invalid thing it receives, which is exactly the robustness principle for this field shape.

That's why the "hard to override" concern really reduces to "I can't make the generator emit output that contradicts its own schema", which isn't a use case being lost, it's a bug being prevented. If someone genuinely wants null on the wire, the right fix is to model the field nullable: true (or the OAS 3.1 type: [..., "null"]), not to loosen serialization globally.

And that's also why I don't think this changes the status quo for any valid payload: it only removes an invalid one. Concretely, a field the schema says can't be null simply stops being serialized as null. Any payload that was correct before is still produced identically; the only outputs that change are the ones that were violating the schema to begin with.

@ugrepel

ugrepel commented Jul 23, 2026

Copy link
Copy Markdown

@Picazsoo - I fully agree: you fixed a bug causing invalid output, which removed the hiding of a bug on our side, so I had to fix our bug too by adding the proper type: [..., "null] specification.

@MelleD

MelleD commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

PUT/full-representation payload where the client must always send middleName (absent = you forgot it → error) and null is the meaningful "this person has no middle name". Absent, null, and "Quinn" are three distinct, useful states there.

But that's not a good example for. If you don't have a middle name, it's simply “Optional.” I mean, that's the very definition of an optional case, and I would never treat it as an error.
The rule still applies: “absent,” “null,” or ‘blank’ is “Optional.empty,” and if you have a middle name, you enter it. :-)

The strongest one isn't exotic: three fields in the same class. One optional non-nullable, one optional nullable, and one required nullable. Granting your semantic model, how would a single global ObjectMapper inclusion setting serialize all three correctly? Walk through the options (openApiNullable=false):

See, and already we’re drifting off into technical discussions ;). Let’s take a step back—why should there even be three different fields like that from a semantic standpoint? What’s the use case? Sure, you can model and program anything. People also use null for Java Optional. Just because it’s possible doesn’t mean it makes sense.

In my current APIs, I use the semantic principles mentioned above with the JsonMapper’s non-absent option and, from a coding perspective, avoid having DTOs contain null—instead, I use optional fields and empty lists. It took a little while, but you realize the benefit pretty quickly once you get the hang of it.

@Picazsoo

Copy link
Copy Markdown
Contributor Author

Let's take a step back—why should there even be three different fields like that from a semantic standpoint? What's the use case?

I agree somewhat with that as advice for someone designing an API. Your principles (avoid null in DTOs, prefer optional + empty lists, reserve tri-state for merge-patch) are good design.

But this mostly affects the generator in client mode (e.g. spring-cloud or declarative http interface), and a client is by definition a consumer of an API it doesn't own. The person generating a client almost never controls the spec; it belongs to a third party, a legacy system, or another team. They can't "reconsider the use case," because someone else already decided it and shipped it. Their only job is to talk to that server correctly.

So "why would you ever model three fields like that?" isn't a question the generator's user gets to answer. If the upstream spec declares those fields, the client has to serialize each one the way that server expects, or the integration breaks. Whether the design is wise isn't ours to adjudicate.

That's the difference in our positions:

  • Your argument is normative: these combinations shouldn't exist, so we needn't serialize them faithfully.
  • Mine is descriptive: they do exist in specs people are handed, and the generator's job is to honor the spec, not impose a house style on an API it's only consuming.

The middle-name case resolves the same way. In an API you own, collapsing absent/null/blank into Optional.empty is fine. But against a server that treats absent as "you forgot it" (400) and null as "explicitly has no middle name," I can't collapse them. The server will reject my payload. My correctness is defined by its rules, not by the better design I'd have picked.

This is also why the per-field annotation isn't us baking in an opinion; it's the opposite. It's what lets the client reproduce the spec author's intent at the only granularity where the distinction survives (T? = null erases it at the JVM level, so no single global mapper setting can). Removing it doesn't make the client neutral; it makes it silently substitute our inclusion policy for the API's.

So I'd still land it as: per-field annotations on by default (faithful to the contract), plus the opt-out configOption for the minority who want to own inclusion at the ObjectMapper level and accept the consequences. That respects your philosophy for people who own their spec, without forcing every consumer of someone else's spec to inherit it.


On the concrete path forward: this ties into #24401, where a stricter global setting (e.g. spring.jackson.default-property-inclusion=non_empty) gets overridden by the field-level NON_NULL. So I'll make the annotation configurable in a follow-up PR, roughly:

  • NON_NULL (default) preserves current 7.24.0 behavior, so no breaking change for anyone who wants it.
  • NON_EMPTY / NON_DEFAULT still satisfy the contract (no explicit null for optional non-nullable fields) while matching common global policies.
  • none emits no field-level @JsonInclude, so the project ObjectMapper stays the single source of truth. This reopens the original explicit-null risk for optional non-nullable fields, but that becomes an explicit, opt-in choice rather than something forced on everyone.

The JsonNullable/NON_ABSENT handling from this PR stays intact regardless, since that's required for correctness. If that split works for you, I'll wire up the flag in the follow-up PR and tag you both on it.

So even though we don't fully converge on the modeling philosophy, the practical outcome should satisfy both of us: the configurable option restores the ability to defer entirely to the global ObjectMapper (the none setting), so anyone who wants that behavior back can have it.

The one caveat I'd flag for the record: opting into none also restores the ability to shoot yourself in the foot, i.e. optional non-nullable fields can once again serialize spec-invalid explicit nulls. But since that's now a deliberate, opt-in choice rather than the silent default, I think that's the right trade-off. Faithful-to-contract stays the default; the footgun is available to those who explicitly ask for it.

@MelleD

MelleD commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

If anyone has an example of such a legacy API, that's fine, but as I've already said, no real-world example has come up yet. But I think to handle as client is much easier.

The middle-name case resolves the same way. In an API you own, collapsing absent/null/blank into Optional.empty is fine. But against a server that treats absent as "you forgot it" (400) and null as "explicitly has no middle name," I can't collapse them. The server will reject my payload. My correctness is defined by its rules, not by the better design I'd have picked.

Yeah, sure, but if you're “just” the client, then you can do exactly that—instead of “absent,” you just enter null. That should be possible, right directly in the object mapper.

As I said, I don't want to judge whether the use case here is right or wrong in general just yet; I need to do an update first, and then I'll be able to see.

@Picazsoo

Copy link
Copy Markdown
Contributor Author

Here's the concrete, non-patch, real-world use case you asked for: a search/filter endpoint. It needs a genuine tri-state on ordinary fields, and it's a case where the client doesn't own the spec.

Scenario

A read-only endpoint POST /tasks/search on an API the client does not own. The client sends a filter DTO. The assigneeId attribute must support three distinct queries:

Client intent JSON on the wire Server behavior
Don't constrain by assignee field absent no assignee predicate
Find unassigned tasks "assigneeId": null WHERE assignee_id IS NULL
Find tasks for user 42 "assigneeId": 42 WHERE assignee_id = 42

Absent, null, and 42 return three different result sets. This is not JSON Merge Patch — it's an everyday search filter — so it sits outside the RFC 7386 box, yet still needs all three states. Here null is not an "absent sentinel"; it's a meaningful query value meaning "match records that have nothing here."

OpenAPI schema (OAS 3.1)

components:
  schemas:
    TaskFilter:
      type: object
      description: >
        All properties are OPTIONAL (none required), so an absent property means
        "do not filter by this attribute". Properties whose type permits "null"
        additionally allow an explicit null to mean "match records whose value IS NULL".
      properties:

        # optional + nullable -> three states: absent / null / value
        assigneeId:
          type: [integer, "null"]
          format: int64
          description: >
            Absent = no assignee filter.
            null   = match tasks with NO assignee.
            number = match tasks assigned to that user id.

        # optional + nullable -> three states as well
        status:
          type: [string, "null"]
          enum: [OPEN, IN_PROGRESS, DONE, null]
          description: >
            Absent = no status filter.
            null   = match tasks with NO status set.
            value  = match tasks with that status.

        # optional + NON-nullable -> only two meaningful states: absent / value
        # null here is NOT a query value; it just means "not provided".
        textContains:
          type: string
          description: Absent = no text filter. A value = substring match.

        # optional + NON-nullable paging control
        limit:
          type: integer
          format: int32
          minimum: 1
          description: Absent = server default page size. A value overrides it.

Key facts:

  • No property is required → for every field, absent legitimately means "omit this criterion".
  • assigneeId / status are optional + nullable → need all three states (absent / null / value).
  • textContains / limit are optional + non-nullablenull is not a query value; it only ever means "not provided", so it must never go on the wire as "limit": null.

Generated Kotlin model (kotlin-spring, openApiNullable=true)

data class TaskFilter(

    // optional + nullable: JsonNullable distinguishes undefined vs. null
    @field:JsonInclude(JsonInclude.Include.NON_ABSENT)
    @field:JsonProperty("assigneeId")
    val assigneeId: JsonNullable<Long> = JsonNullable.undefined(),

    @field:JsonInclude(JsonInclude.Include.NON_ABSENT)
    @field:JsonProperty("status")
    val status: JsonNullable<TaskFilter.Status> = JsonNullable.undefined(),

    // optional + non-nullable: null is the "absent" sentinel, must be omitted
    @field:JsonInclude(JsonInclude.Include.NON_NULL)
    @field:JsonSetter(nulls = Nulls.SKIP)
    @field:JsonProperty("textContains")
    val textContains: String? = null,

    @field:JsonInclude(JsonInclude.Include.NON_NULL)
    @field:JsonSetter(nulls = Nulls.SKIP)
    @field:JsonProperty("limit")
    val limit: Int? = null,
)

For assigneeId:

Kotlin value Serialized JSON Query meaning
JsonNullable.undefined() (omitted) no assignee filter
JsonNullable.of(null) "assigneeId": null find unassigned
JsonNullable.of(42L) "assigneeId": 42 assigned to 42

NON_ABSENT is what drops undefined() while still emitting the meaningful null. Without it, undefined() leaks as "assigneeId": null, silently turning "no filter" into "find unassigned" — a different query.

Why a single global ObjectMapper setting can't do this

Serialize one TaskFilter that sets assigneeId = null (find unassigned) but leaves limit unset. Walk the global options:

  • Include.ALWAYS → emits "assigneeId": null ✅ but also emits "limit": null ❌ (spurious/invalid criterion on a non-nullable field).
  • Include.NON_NULL → omits "limit": null ✅ but also omits "assigneeId": null ❌ (silently drops the "find unassigned" query, turning it into "no filter").
  • Any other single value → applies uniformly; still can't emit null for one field while omitting it for the other.

The two fields demand opposite null-handling in the same object, and under openApiNullable=false both are just T? = null on the JVM — indistinguishable to a mapper-level policy. That's the structural reason the choice has to live per field, not in the global mapper.

And the client here doesn't own the spec — it's just trying to query the endpoint correctly. It can't "reconsider the use case"; it has to reproduce absent-vs-null-vs-value exactly as the server defines them. "Just set the mapper to emit nulls" makes assigneeId: null work but simultaneously corrupts limit, so it isn't a real substitute for the per-field annotation.

But I still feel like we are talking past each other. I tagged you in another issue. I would prefer to continue the conversation there if you don't mind. If you have time, I would be glad if you could give some feedback on the proposed solution.

@Picazsoo

Copy link
Copy Markdown
Contributor Author

One clarification on the ObjectMapper argument: its scope is bigger than "a few awkward fields in one class."

The inclusion setting isn't per-class, it's per-serializer-instance, and a standard Spring setup has one shared mapper for the whole application. So any global default-property-inclusion applies to every DTO in the project, not just fields in the same class.

The conflict therefore needs no contrived model. It appears the moment the project has, anywhere:

  • one optional non-nullable field whose null is just an "absent" sentinel, so it must be omitted, and
  • one nullable field whose null is meaningful, so it must be emitted.

No single global value serves both: Include.ALWAYS emits spec-invalid "x": null for the non-nullable field, while Include.NON_NULL silently drops the meaningful null on the other. And "redesign that class" doesn't apply, since they aren't even in the same class.

This gets sharper for a client/gateway/BFF talking to multiple upstream APIs, each with its own spec and DTOs, all serialized by the same mapper. Different vendors own those specs, so they disagree on null semantics: Upstream A (merge-patch/filter) needs null emitted ("reset" / "match IS NULL"); Upstream B (strict create/PUT) rejects "x": null with a 400, so it must be omitted. No global value serves both, and I own neither spec, so "reconsider the modeling" isn't available to me.

"Use a second ObjectMapper" doesn't rescue this either: Jackson applies one mapper per serialization, so two fields in the same payload can't get different inclusion policies, and wiring per-upstream mappers into generated clients is plumbing nobody maintains.

That's why the decision has to be per-field: the schema declares nullability per property, so only a per-property mechanism can honor it. A single application-wide mapper setting is a category mismatch, and it can't reconcile conflicting contracts from different upstreams in one process.

@MelleD

MelleD commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

But in your case, it's something that can already be done with the plain ObjectMapper and without annotation, when I understand you correct.

  void test() {
      Test test = new Test();
      System.out.println( toJson( test ) );

      test = new Test();
      test.setJsonNullable( JsonNullable.of( "New" ) );
      test.setOptionalLong( Optional.of( 123L ) );
      System.out.println( toJson( test ) );

      test = new Test();
      test.setJsonNullable( JsonNullable.of( null ) );
      System.out.println( toJson( test ) );
   }

   public class Test {
      private JsonNullable<String> jsonNullable = JsonNullable.undefined();
      private Optional<Long> optionalLong = Optional.empty();

      public JsonNullable<String> getJsonNullable() {
         return jsonNullable;
      }

      public void setJsonNullable( JsonNullable<String> jsonNullable ) {
         this.jsonNullable = jsonNullable;
      }

      public Optional<Long> getOptionalLong() {
         return optionalLong;
      }

      public void setOptionalLong( Optional<Long> optionalLong ) {
         this.optionalLong = optionalLong;
      }
   }

   protected String toJson( final Object object ) {
      try {
         return mapper.writeValueAsString( object );
      } catch ( final JacksonException e ) {
         throw new IllegalStateException( e );
      }
   }
{}
{"jsonNullable":"New","optionalLong":123}
{"jsonNullable":null}

mprins added a commit to Tailormap/tailormap-api that referenced this pull request Jul 31, 2026
…e serilisation became more strict

see eg. OpenAPITools/openapi-generator#24185 and we get some fields annotated with @JsonInclude(JsonInclude.Include.NON_NULL)
mprins added a commit to Tailormap/tailormap-api that referenced this pull request Jul 31, 2026
…e serilisation became more strict

see eg. OpenAPITools/openapi-generator#24185 and we get some fields annotated with @JsonInclude(JsonInclude.Include.NON_NULL)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants