Describe the bug
Since springdoc-openapi 2.8.17, a field annotated with org.springframework.lang.Nullable whose type becomes a separate component schema causes the referenced component itself to be mutated — not the reference. In OAS 3.1 mode (springdoc's default from 2.8.x) the referent's type is set to "null" while its properties remain populated, producing a contradictory schema such as {"type": "null", "properties": { … }}. Separately, the referring field's $ref carries no nullability marker at all, so the change both (a) writes to the wrong schema and (b) fails to express the nullability it was meant to add.
Downstream tooling is broken: openapi-generator-maven-plugin 7.14.0 treats "type": "null" as the JSON-Schema null type, renames the component to ModelNull, and emits an uncompilable Java class. 2.8.16 produces the expected "type": "object" for the same input, so this is a regression introduced in 2.8.17.
The suspected trigger is the fix for #3255 "Handle $ref nullable wrapping and OAS 3.1 support", listed under "Fixed" in the 2.8.17 release notes.
To Reproduce
Standalone reproducer project: https://github.com/franzmathauser/springdoc-2817-reproducer-repro
git clone https://github.com/franzmathauser/springdoc-2817-reproducer-repro.git
cd springdoc-2817-reproducer-repro
./mvnw spring-boot:run
# in another terminal:
curl -s http://localhost:8080/v3/api-docs | python3 -m json.tool
Steps to reproduce the behavior:
- Spring Boot version: 3.5.13
- springdoc-openapi modules and versions:
springdoc-openapi-starter-webmvc-ui:2.8.17
- Java version: 21
- Sample code that reproduces the problem:
package com.example.repro;
import io.swagger.v3.oas.annotations.Operation;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.lang.Nullable;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@SpringBootApplication
public class App {
public static void main(String[] args) { SpringApplication.run(App.class, args); }
public record Inner(int lines, long bytes, String bucket, String key) {}
public record Outer(String id, @Nullable Inner result) {}
@RestController
static class DemoController {
@Operation(operationId = "getOuter")
@GetMapping("/outer")
public Outer getOuter() { return new Outer("x", null); }
}
}
pom.xml:
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.5.13</version>
</parent>
<properties>
<java.version>21</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<version>2.8.17</version>
</dependency>
</dependencies>
Run ./mvnw spring-boot:run, then curl http://localhost:8080/v3/api-docs.
Actual result (verbatim from the reproducer, OAS 3.1 default):
{
"openapi": "3.1.0",
"paths": {
"/outer": {
"get": {
"operationId": "getOuter",
"responses": {
"200": {
"description": "OK",
"content": { "*/*": { "schema": { "$ref": "#/components/schemas/Outer" } } }
}
}
}
}
},
"components": {
"schemas": {
"Inner": {
"type": "null",
"properties": {
"lines": { "type": "integer", "format": "int32" },
"bytes": { "type": "integer", "format": "int64" },
"bucket": { "type": "string" },
"key": { "type": "string" }
}
},
"Outer": {
"type": "object",
"properties": {
"id": { "type": "string" },
"result": { "$ref": "#/components/schemas/Inner" }
}
}
}
}
}
Note that Inner.type is "null" even though Inner.properties is populated, and Outer.result carries no nullability marker.
Expected behavior
Inner should keep its valid object schema; nullability of Outer.result should either be dropped (as in 2.8.16) or be expressed on the reference, not on the referent.
Expected JSON for Inner (as produced by springdoc 2.8.16 for the same reproducer):
{
"Inner": {
"type": "object",
"properties": {
"lines": { "type": "integer", "format": "int32" },
"bytes": { "type": "integer", "format": "int64" },
"bucket": { "type": "string" },
"key": { "type": "string" }
}
}
}
The correct OAS 3.1 encoding of a nullable reference is a wrapper on the reference, never a mutation of the referent:
{
"result": {
"oneOf": [
{ "$ref": "#/components/schemas/Inner" },
{ "type": "null" }
]
}
}
OAS 3.0 has no valid encoding for a nullable $ref (OAI/OpenAPI-Specification#1368) — in that mode the nullability should be dropped, not applied incorrectly.
Screenshots
Not applicable — the relevant artifact is the /v3/api-docs JSON (shown verbatim above). The standalone reproducer project at https://github.com/franzmathauser/springdoc-2817-reproducer-repro includes the full bisection matrix in its README so the behaviour differences across versions / OAS modes can be verified with a single-line version flip in pom.xml.
Additional context
Bisection
| springdoc |
swagger-core |
OAS mode |
Inner component |
| 2.8.16 |
2.2.46 (managed) |
3.1 (default) |
"type": "object" ✓ |
| 2.8.17 |
2.2.47 (managed) |
3.1 (default) |
"type": "null" + properties ✗ |
| 2.8.17 |
2.2.46 (pinned) |
3.1 (default) |
"type": "null" + properties ✗ |
| 2.8.17 |
2.2.47 (managed) |
3.0 (forced) |
"type": "object" + "nullable": true on referent ✗ |
- Row 3 pins swagger-core back to 2.8.16's transitive version; the bug persists. The regression is in springdoc-openapi 2.8.17 itself, not the concurrent swagger-core 2.2.46→2.2.47 bump.
- Row 4 sets
springdoc.api-docs.version=openapi_3_0. type is no longer mutated to "null", but springdoc instead writes "nullable": true onto the referenced component. Same root cause ("adapter mutates the referent"), different manifestation — every reference to Inner now implicitly accepts null, not just the one field that was annotated. openapi-generator tolerates this (no ModelNull), so forcing OAS 3.0 is a partial workaround but still semantically wrong.
Root-cause hypothesis
Springdoc's converters delegate Schema construction to swagger-core's ModelConverters, which cache one Schema instance per component. The #3255 change appears to apply nullability to that shared instance rather than to a per-call wrapper. The same "adapter mutates the referent" pattern has prior art in springdoc: #1870 (field-level @Schema overrides the referenced schema) and #2902 (HateoasLinksConverter replaces a referenced schema with String because it was resolved with resolveAsRef(false)). The fix pattern from #2902 (use resolveAsRef(true) before touching the schema) likely applies here as well.
Workaround
Post-process the OpenAPI model with a customizer that restores type: "object" on any component with properties but type/types only equal to "null":
@Bean
OpenApiCustomizer fixNullTypeObjectSchemas() {
return openApi -> {
if (openApi.getComponents() == null || openApi.getComponents().getSchemas() == null) return;
openApi.getComponents().getSchemas().values().stream()
.filter(s -> s.getProperties() != null && !s.getProperties().isEmpty())
.filter(s -> "null".equals(s.getType())
|| (s.getTypes() != null && s.getTypes().size() == 1 && s.getTypes().contains("null")))
.forEach(s -> {
s.setType("object");
if (s.getTypes() != null) { s.getTypes().remove("null"); s.getTypes().add("object"); }
s.setDefault(null);
});
};
}
Downgrading to 2.8.16 also avoids the symptom.
Related issues
Describe the bug
Since springdoc-openapi 2.8.17, a field annotated with
org.springframework.lang.Nullablewhose type becomes a separate component schema causes the referenced component itself to be mutated — not the reference. In OAS 3.1 mode (springdoc's default from 2.8.x) the referent'stypeis set to"null"while itspropertiesremain populated, producing a contradictory schema such as{"type": "null", "properties": { … }}. Separately, the referring field's$refcarries no nullability marker at all, so the change both (a) writes to the wrong schema and (b) fails to express the nullability it was meant to add.Downstream tooling is broken:
openapi-generator-maven-plugin7.14.0 treats"type": "null"as the JSON-Schema null type, renames the component toModelNull, and emits an uncompilable Java class. 2.8.16 produces the expected"type": "object"for the same input, so this is a regression introduced in 2.8.17.The suspected trigger is the fix for #3255 "Handle $ref nullable wrapping and OAS 3.1 support", listed under "Fixed" in the 2.8.17 release notes.
To Reproduce
Standalone reproducer project: https://github.com/franzmathauser/springdoc-2817-reproducer-repro
Steps to reproduce the behavior:
springdoc-openapi-starter-webmvc-ui:2.8.17pom.xml:Run
./mvnw spring-boot:run, thencurl http://localhost:8080/v3/api-docs.Actual result (verbatim from the reproducer, OAS 3.1 default):
{ "openapi": "3.1.0", "paths": { "/outer": { "get": { "operationId": "getOuter", "responses": { "200": { "description": "OK", "content": { "*/*": { "schema": { "$ref": "#/components/schemas/Outer" } } } } } } } }, "components": { "schemas": { "Inner": { "type": "null", "properties": { "lines": { "type": "integer", "format": "int32" }, "bytes": { "type": "integer", "format": "int64" }, "bucket": { "type": "string" }, "key": { "type": "string" } } }, "Outer": { "type": "object", "properties": { "id": { "type": "string" }, "result": { "$ref": "#/components/schemas/Inner" } } } } } }Note that
Inner.typeis"null"even thoughInner.propertiesis populated, andOuter.resultcarries no nullability marker.Expected behavior
Innershould keep its valid object schema; nullability ofOuter.resultshould either be dropped (as in 2.8.16) or be expressed on the reference, not on the referent.Expected JSON for
Inner(as produced by springdoc 2.8.16 for the same reproducer):{ "Inner": { "type": "object", "properties": { "lines": { "type": "integer", "format": "int32" }, "bytes": { "type": "integer", "format": "int64" }, "bucket": { "type": "string" }, "key": { "type": "string" } } } }The correct OAS 3.1 encoding of a nullable reference is a wrapper on the reference, never a mutation of the referent:
{ "result": { "oneOf": [ { "$ref": "#/components/schemas/Inner" }, { "type": "null" } ] } }OAS 3.0 has no valid encoding for a nullable
$ref(OAI/OpenAPI-Specification#1368) — in that mode the nullability should be dropped, not applied incorrectly.Screenshots
Not applicable — the relevant artifact is the
/v3/api-docsJSON (shown verbatim above). The standalone reproducer project at https://github.com/franzmathauser/springdoc-2817-reproducer-repro includes the full bisection matrix in its README so the behaviour differences across versions / OAS modes can be verified with a single-line version flip inpom.xml.Additional context
Bisection
Innercomponent"type": "object"✓"type": "null"+properties✗"type": "null"+properties✗"type": "object"+"nullable": trueon referent ✗springdoc.api-docs.version=openapi_3_0.typeis no longer mutated to"null", but springdoc instead writes"nullable": trueonto the referenced component. Same root cause ("adapter mutates the referent"), different manifestation — every reference toInnernow implicitly accepts null, not just the one field that was annotated.openapi-generatortolerates this (noModelNull), so forcing OAS 3.0 is a partial workaround but still semantically wrong.Root-cause hypothesis
Springdoc's converters delegate
Schemaconstruction to swagger-core'sModelConverters, which cache oneSchemainstance per component. The #3255 change appears to apply nullability to that shared instance rather than to a per-call wrapper. The same "adapter mutates the referent" pattern has prior art in springdoc: #1870 (field-level@Schemaoverrides the referenced schema) and #2902 (HateoasLinksConverterreplaces a referenced schema withStringbecause it was resolved withresolveAsRef(false)). The fix pattern from #2902 (useresolveAsRef(true)before touching the schema) likely applies here as well.Workaround
Post-process the
OpenAPImodel with a customizer that restorestype: "object"on any component withpropertiesbuttype/typesonly equal to"null":Downgrading to 2.8.16 also avoids the symptom.
Related issues
@Nullablefeature request, shares code paths.