Skip to content

Write grails.converters.JSON through Spring Boot's JsonMapper - #16414

Merged
codeconsole merged 35 commits into
apache:9.0.xfrom
codeconsole:feat/converters-json-on-jackson-9.0.x
Oct 16, 2026
Merged

codeconsole merged 35 commits into
apache:9.0.xfrom
codeconsole:feat/converters-json-on-jackson-9.0.x

Conversation

@codeconsole

@codeconsole codeconsole commented Sep 26, 2026 •

Copy link
Copy Markdown
Contributor

Description

grails.converters.JSON can write JSON through the application's Jackson JsonMapper, the one Spring Boot auto-configures, and render domain classes with a Jackson serializer, which an application can also register with that mapper. With it, a Grails application and a Spring Boot application return the same JSON for the same values, and spring.jackson.* applies to render ... as JSON.

In Grails 9 this is an opt-in: grails.converters.json.legacy defaults to true, and an application turns the mapper on with legacy: false. A later release makes false the default. With #16237, respond() also stays on the converter in 9, so in 9 an application's JSON stays as Grails 8 rendered it unless it opts in, apart from what Grails 8 failed to render or rendered as invalid JSON (a single value, NaN) and the PATH references Grails 8 got wrong, and both layers can flip together later.

Parsing does not change: JSON.parse, request.JSON and new JSONObject(String) still parse with Grails' JSONTokener, with the same lenient syntax, number types and Map/List containers.

By default, grails.converters.json.legacy: true renders the text Grails 8 rendered, including domain classes and map keys, whatever the application's Jackson settings, except where Grails 8 failed or wrote invalid JSON. The setting is not deprecated. While it is not set, Grails logs one warning at startup that a later release changes the default, and setting either value turns it off.

Usage

With the mapper turned on, Jackson configuration applies to the converter:

grails:
    converters:
        json:
            legacy: false
spring:
    jackson:
        time-zone: America/New_York

With grails.converters.json.domain.jackson.enabled: true, the application's JsonMapper renders a domain class as the converter does, so jsonMapper.writeValueAsString(book), or a Spring MVC controller returning book, writes the following. By default, the mapper renders domain classes as Jackson beans, as in Grails 8.

{"id":1,"title":"Grails","publisher":{"id":3},"authors":[{"id":1},{"id":2}]}

A Jackson module registered with the application (a JacksonModule bean or a @JacksonComponent) renders its types in render ... as JSON. The values its serializer writes are rendered by the converter, so registered marshallers still apply to them. A marshaller registered with JSON.registerObjectMarshaller or an ObjectMarshallerRegisterer still takes precedence over the mapper:

JSON.registerObjectMarshaller(Date) { Date date -> date.time }

With the mapper turned on, to keep the Grails 8 format of a few types only:

JSON.registerObjectMarshaller(Month) { Month month -> month.name() }
JSON.registerObjectMarshaller(Locale) { Locale locale -> locale.toString() }
JSON.registerObjectMarshaller(byte[]) { byte[] bytes -> bytes as List }

Output with legacy: false, compared with Grails 8

Grails 8's output is also the 9.x default. Measured against text rendered by Grails 8.0.x for the same values. They are listed in the new 9.0 upgrade guide.

Value Grails 8 Grails 9
OffsetTime "03:00:00-03:00", "03:00:00.5+05:30" "03:00-03:00", "03:00:00.500+05:30"
Month "SEPTEMBER" 9 (binds back)
Locale "zh_TW_#Hant" "zh-Hant-TW"
char {} "c"
byte[] [1,2,3] "AQID"
UUID, URI, @JsonValue types other than enums bean objects values
Pattern, Charset, InetAddress bean objects strings
File, Path, ByteBuffer failure "/tmp/a.txt", "file:///tmp/a.txt", "AQID"
char[] [{},{}] "ab"
an enum constant with a body bean object its name
JsonNode bean object the JSON it holds
a Class map key "class java.lang.String" "java.lang.String"
Optional {"empty":false,"present":true} value or null
NaN NaN (invalid JSON) "NaN"
pretty printing Grails layouts Jackson default pretty printer
toString(false) with pretty.print: true indented not indented
a single value (render Role.HEAD as JSON) ConverterException "HEAD"
a domain property annotated @JsonIgnore or write-only rendered left out

Dates, times, records, beans, collections, maps and domain classes through the converter render as before. Enums still render by name(), and strings are still escaped for an HTML <script> element (</ as <\u002f).

Design

  • JSONWriter writes through a Jackson JsonGenerator, and keeps its public methods and state machine, so existing marshallers and PathCapturingJSONWriterWrapper work unchanged.
  • JsonMapperValueMarshaller renders a value with the mapper when the mapper has a dedicated serializer for a single value of its type (dates and times, Month, UUID, Locale, byte[], @JsonValue types other than enums, module types). Containers, beans, enums and domain classes stay with Grails marshallers.
  • Nested values. The converter writes with a copy of the application's mapper whose serializers hand each value they write back to the converter, unless the converter would give it to the mapper anyway. JSONWriter.writeNested writes such a value and returns the writer to its state. The application's own mapper is not changed.
  • Domain classes. DomainClassSerializer (in org.grails.web.converters.jackson) renders an instance as DomainClassMarshaller did: id, version and class name when included, persistent properties, and associations as {"id":…} references or, deep, in full, with proxies unwrapped and references to proxies written from their identifier. With grails.converters.json.domain.jackson.enabled: true, DomainClassJacksonModule is registered as the domainClassJacksonModule bean, which Spring Boot adds to its JsonMapper. On the mapper the serializer also applies the mapper's naming strategy, @JsonNaming and @JsonProperty names, writes @JsonTypeInfo type ids and unwraps @JsonUnwrapped properties. It leaves a domain class with a @JsonValue or JacksonSerializable, and a subclass that is neither a domain class nor a GORM or Hibernate proxy class, to Jackson. DomainClassMarshaller and DeepDomainClassMarshaller hand instances to the serializer with the converter's includes and excludes, and their protected methods (includesProperty, excludesProperty, extractValue, asShortObject, isRenderDomainClassRelations) still apply.
  • RecordMarshaller and OptionalMarshaller render a record as an object of its components (Jackson annotations on the components are not applied) and Optional as its value, with their contents rendered by the converter.
  • HtmlSafeJsonWriter keeps Grails' </, U+2028 and U+2029 escaping for everything the generator writes.
  • Legacy mode, the default in 9:
    • It registers Grails 8's marshallers, and DomainClassMarshaller runs its Grails 8 body.
    • It formats map keys and indents as Grails 8 did, with grails.converters.json.pretty.print and from toString(true) and toString(false).
    • It writes with a default JsonMapper, so the application's Jackson settings and modules don't change the text.
    • Grails8JsonRenderingSpec compares it with Grails 8.0.x output (232bb34) for 111 values, both pretty print paths and 11 domain class scenarios, both with a default mapper and with one that indents, writes numbers as strings, uses another time zone and has a module serializer.
  • End to end: hibernate5/grails-hibernate opts in, with spring.jackson.time-zone, and JsonMapperConverterSpec renders a domain class instance whose lazy association is a Hibernate proxy, through the JsonMapper Spring Boot auto-configures, and checks that the proxy is still uninitialized after rendering.

Deprecated

Deprecated without a removal release, and still usable:

  • The JSON value marshallers no longer registered by default: DateMarshaller, CalendarMarshaller, ToStringBeanMarshaller, ByteArrayMarshaller, InstantMarshaller, LocalDateMarshaller, LocalDateTimeMarshaller, OffsetDateTimeMarshaller and ZonedDateTimeMarshaller.
  • PrettyPrintJSONWriter, which still indents as Grails 8 did.
  • The JavaCC org.grails.web.json.parser.JSONParser, which Grails does not use.

Unchanged

JSON.parse, request.JSON and JSONObject/JSONArray (JsonParsingSpec pins their syntax, number types and containers), JSON views, the XML converter and the HAL renderer.

Fixed

  • PathCapturingJSONWriterWrapper (circular.reference.behaviour: PATH) threw on an object with a null property, gave wrong paths after a number property, and failed on a single value.
  • A to-many association whose value is a Map failed to render; it renders as an object of references.
  • JsonMapperSupport no longer uses Jackson's test-only ObjectMapper._serializationContext().

Performance

render list as JSON for 3,000 domain instances (528 KB of JSON). Each figure is the median of 60 renders after 40 warm-up renders, in separate JVMs:

Median
9.0.x (f5bbe69) 4.6 ms
This branch, legacy: false 3.8–4.2 ms
This branch, legacy (the 9.x default) 3.5 ms

Limitations

  • A nested value whose class has its own @JsonSerialize(using = …) or converter is written by that serializer even inside a module serializer, because Jackson builds class-annotated serializers without serializer modifiers. A Grails marshaller registered for such a type applies everywhere else.
  • Class-level @JsonIgnoreProperties applies to the application's JsonMapper only, with the domain module enabled. GORM adds one to every domain class naming the version and tenantId, so the converter keeps rendering those as before.
  • With the domain module enabled, the mapper applies to domain classes only the Jackson annotations and settings the upgrade guide lists. For example, nulls are always written, and properties keep the converter's order.
  • JSON views keep their own date formatting.

grails.converters.JSON now writes JSON with the application's Jackson
JsonMapper, the one Spring Boot auto-configures, instead of Grails' own
JSON writer and a marshaller per value type. Values render as Spring
Boot renders them, and spring.jackson.* properties apply to
render ... as JSON.

- JacksonJSONWriter is a JSONWriter that writes to a JsonGenerator,
  keeping JSONWriter's state and value semantics, so existing object
  marshallers and the PathCapturingJSONWriterWrapper keep working
- JsonMapperValueMarshaller renders the values the mapper has a
  dedicated single-value serializer for (dates and times, Month, UUID,
  Locale, byte[], @jsonvalue and Jackson module types); beans, enums,
  records, Optional and containers stay with Grails marshallers, so the
  values they contain are rendered as any other value is
- RecordMarshaller and OptionalMarshaller render records and Optional
  as Spring Boot does
- HtmlSafeJsonWriter keeps escaping </ and U+2028/U+2029 in every string
- Map keys are written by the mapper's key serializers
- Enums keep rendering by name(); a Month renders as its number and
  binds back from it
- The per-type JSON marshallers for dates, times, Locale and byte
  arrays are no longer registered; the java.time ones are removed
- PathCapturingJSONWriterWrapper handles null and Number values and
  single values

The differences from Grails 8 are listed in the new 9.0 upgrade guide.
@codecov

codecov Bot commented Sep 26, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 82.21093% with 140 lines in your changes missing coverage. Please review.
✅ Project coverage is 61.4500%. Comparing base (ebf174e) to head (1d3bf77).
⚠️ Report is 36 commits behind head on 9.0.x.

Files with missing lines Patch % Lines
.../web/converters/jackson/DomainClassSerializer.java 77.0054% 19 Missing and 24 partials ⚠️
...s/web/converters/jackson/DomainClassRendering.java 63.7931% 7 Missing and 14 partials ⚠️
...rails/web/json/PathCapturingJSONWriterWrapper.java 61.7021% 12 Missing and 6 partials ⚠️
...ovy/org/grails/web/json/NestedValueSerializer.java 75.0000% 7 Missing and 6 partials ⚠️
...rc/main/groovy/org/grails/web/json/JSONWriter.java 88.6792% 6 Missing and 6 partials ⚠️
.../groovy/org/grails/web/json/JsonMapperSupport.java 76.9231% 6 Missing and 6 partials ⚠️
...erters/src/main/groovy/grails/converters/JSON.java 88.0000% 0 Missing and 3 partials ⚠️
...figuration/ConvertersConfigurationInitializer.java 94.5454% 1 Missing and 2 partials ⚠️
...b/converters/jackson/DomainClassJacksonModule.java 88.0000% 1 Missing and 2 partials ⚠️
...verters/marshaller/json/DomainClassMarshaller.java 89.6552% 0 Missing and 3 partials ⚠️
... and 5 more
Additional details and impacted files

Impacted file tree graph

@@                Coverage Diff                 @@
##                9.0.x     #16414        +/-   ##
==================================================
+ Coverage     61.0081%   61.4500%   +0.4419%     
- Complexity      26644      27010       +366     
==================================================
  Files            2249       2260        +11     
  Lines          111577     112192       +615     
  Branches        20224      20351       +127     
==================================================
+ Hits            68071      68942       +871     
+ Misses          34543      34190       -353     
- Partials         8963       9060        +97     
Files with missing lines Coverage Δ
...s/configuration/ConvertersConfigurationHolder.java 87.5000% <100.0000%> (+1.6026%) ⬆️
...onverters/marshaller/json/ByteArrayMarshaller.java 40.0000% <ø> (ø)
...converters/marshaller/json/CalendarMarshaller.java 86.6667% <ø> (ø)
...web/converters/marshaller/json/DateMarshaller.java 86.6667% <ø> (ø)
.../converters/marshaller/json/InstantMarshaller.java 75.0000% <ø> (ø)
...onverters/marshaller/json/LocalDateMarshaller.java 75.0000% <ø> (ø)
...rters/marshaller/json/LocalDateTimeMarshaller.java 75.0000% <ø> (ø)
.../web/converters/marshaller/json/MapMarshaller.java 92.3077% <100.0000%> (ø)
...ters/marshaller/json/OffsetDateTimeMarshaller.java 75.0000% <ø> (ø)
...converters/marshaller/json/OptionalMarshaller.java 100.0000% <100.0000%> (ø)
... and 22 more

... and 17 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

…shallers and parser

JSONWriter itself now writes through a Jackson JsonGenerator, in place of
the separate JacksonJSONWriter. It keeps its public methods and state
machine, so marshallers and PathCapturingJSONWriterWrapper are unchanged.
It never flushes or closes the caller's Writer, and closes the generator
once the JSON text is complete. JsonMapperSupport, JsonMapperValue and
HtmlSafeJsonWriter move to grails-web-common (org.grails.web.json).

Removed:
- PrettyPrintJSONWriter (use new JSONWriter(writer,
  new JsonMapperSupport(mapper), true))
- the JSON DateMarshaller, CalendarMarshaller, ToStringBeanMarshaller and
  ByteArrayMarshaller, which are no longer registered (XML ones stay)
- the unused JavaCC parser in org.grails.web.json.parser and
  jsonparser.jj; JSON.parse and JSONObject parse with JSONTokener

PathCapturingJSONWriterWrapper also passes flush() and append(Writable)
on, and a JsonMapperValue is written by its own mapper. The 9.0 upgrade
guide lists the removals and the JSONWriter changes.
@borinquenkid

Copy link
Copy Markdown
Member

Reviewed at head 95066b7b83. I compiled it and reran the claimed test counts myself (grails-web-common 113, grails-converters 205, grails-views-gson 185 — all green, matching the PR body), then tried to break the design rather than just confirm it. Two findings.

1. formatKey() depends on a Jackson method documented as test-only

JsonMapperSupport.formatKey() calls mapper._serializationContext(). In Jackson 3.1.6's own source (ObjectMapper.java):

// NOTE: only public to allow for testing
public SerializationContextExt _serializationContext() {

and SerializationContextExt's class Javadoc: "adds methods needed by ObjectMapper ... but that are not to be exposed as general context during serialization." That's Jackson's own maintainers saying this isn't meant to be built on. It works today, but Jackson doesn't consider changing or removing it a breaking change — there's no compatibility guarantee across even a patch release.

2. A custom Jackson serializer can silently bypass a registered Grails marshaller

JsonMapperSupport.rendersValue() decides Jackson "owns" a type by checking its serializer isn't instanceof one of six internal classes (BeanSerializerBase, EnumSerializer, ReferenceTypeSerializer, StdContainerSerializer, UnsupportedTypeSerializer, ToEmptyObjectSerializer). But Jackson's own docs recommend StdSerializer as the base class for a custom serializer — and it extends ValueSerializer directly, none of those six. So any textbook custom serializer an app or a plugin registers passes the check and is treated as fully owned by Jackson.

Repro, against this branch:

def module = new SimpleModule()
module.addSerializer(Wrapper, new StdSerializer<Wrapper>(Wrapper) {
    void serialize(Wrapper value, JsonGenerator gen, SerializationContext ctxt) {
        gen.writeStartObject()
        gen.writeName('inner')
        gen.writePOJO(value.inner)   // not converter.convertAnother()
        gen.writeEndObject()
    }
})
def mapper = JsonMapper.builder().addModule(module).build()
// ... registered as the app's JsonMapper bean ...

JSON.registerObjectMarshaller(Secret) { Secret s -> '***REDACTED***' }

new JSON([w: new Wrapper(inner: new Secret(value: 'super-secret'))]).toString()

Expected (what an app relying on registerObjectMarshaller to redact Secret would expect):

{"w":{"inner":"***REDACTED***"}}

Actual:

{"w":{"inner":{"value":"super-secret"}}}

Once JsonMapperValueMarshaller hands Wrapper to generator.writePOJO(), the custom serializer runs inside Jackson's own machinery. Its gen.writePOJO(value.inner) call never comes back through converter.convertAnother(), so Grails never gets the chance to apply the Secret marshaller — Jackson's default bean serializer renders it raw instead.

This is new exposure, not a pre-existing gap: before this PR a bare Jackson module had zero effect on grails.converters.JSON — registerObjectMarshaller was the only thing that controlled rendering. Now a plugin's or an app's own Jackson module can silently override an app's redaction/transform marshaller for a nested type it has no idea about, with no error or warning. It needs both ingredients (a custom serializer that embeds another value, and a separate marshaller registered for that embedded type) so it won't hit every app, but the failure mode is exactly the one that matters when it does.

A question on the overall approach

Given spring.jackson.* and the auto-configured JsonMapper are meant to be used directly (jsonMapper.writeValueAsString(value)), why keep this hybrid split — Grails marshals containers/beans/domain objects, Jackson marshals "simple values" it recognizes — instead of just handing the whole object graph to the JsonMapper and letting Jackson serialize it end to end, the standard documented pattern? I'd guess it's to keep ObjectMarshaller<JSON> / JSON.registerObjectMarshaller working, and Grails' GORM-aware domain-class rendering (proxies, circular references, includes/excludes, deep vs. shallow associations) — none of which map onto plain Jackson bean serialization. If that's the reasoning, it's worth saying explicitly in the PR description, since it's exactly the seam the bypass above lives in.

@borinquenkid

Copy link
Copy Markdown
Member

Following up on the custom-serializer gap above: this is 9.0.x, so the fix doesn't need to be bulletproofing `registerObjectMarshaller` against every nested custom Jackson serializer — that's a compatibility guarantee worth breaking on purpose here, not preserving by accident. The registered `JsonMapper` is the standard Spring pattern (that's what my repro used), and once an app hands Jackson a serializer, Jackson owns what's reachable from it — that's a reasonable 9.0 stance.

It just needs to be a stated decision, not a silent one: a line in the 9.0 upgrade guide that a value nested inside a custom Jackson serializer renders as Jackson renders it, not through Grails marshallers, so apps relying on `registerObjectMarshaller` for something like redaction know to also handle it as a Jackson serializer if it can show up under a custom one.

@borinquenkid

Copy link
Copy Markdown
Member

Proposal for the custom-serializer gap above: don't sandbox it, remove the reason it exists. Go fully through Spring Boot's own Jackson customization model instead of the hybrid "ask Jackson if it owns this type" approach, and `registerObjectMarshaller`/`rendersValue()`'s private-API dependency both disappear at the root.

Verified against the real `spring-boot-jackson-4.1.1` sources (Boot 4 split this out of `spring-boot-autoconfigure`). `JacksonAutoConfiguration` already does this:

@Bean
StandardJsonMapperBuilderCustomizer standardJsonMapperBuilderCustomizer(
        ObjectProvider<JacksonModule> modules, AutowireCapableBeanFactory beanFactory) {
    return new StandardJsonMapperBuilderCustomizer(this.jacksonProperties, modules.stream().toList(), beanFactory);
}

Any `@Bean JacksonModule` in the app context is auto-collected and folded into the one `JsonMapper` Boot builds. Three standard, already-documented ways an app registers a custom marshaller once `ConvertersConfigurationInitializer` is consuming that same mapper (which it already does, via `getBeanProvider(JsonMapper.class).getIfUnique()`):

  1. `@Bean JacksonModule myModule()` — a `SimpleModule` with `addSerializer(MyType.class, ...)`.
  2. `@JacksonComponent` (`org.springframework.boot.jackson.JacksonComponent`) — Boot's own convenience annotation; `JacksonComponentModule` wires it in.
  3. A `JsonMapperBuilderCustomizer` bean for lower-level control.

If `grails.converters.JSON` fully delegated instead of asking "does Jackson recognize this type," an app's custom marshaller becomes a plain `@Bean JacksonModule` or `@JacksonComponent` — no Grails API, and it composes correctly with everything else because there's exactly one serialization mechanism instead of two that have to interoperate. That removes the nested-custom-serializer bypass from above (nothing left to bypass) and the `_serializationContext()` dependency (no "does Jackson own this" question to answer — it owns everything).

The part that doesn't fall out for free: `DomainClassMarshaller` — proxies, circular references, `includes`/`excludes`, `deep` associations. No Jackson equivalent exists for that today. Going fully Spring Boot means it has to become a `JacksonModule` Grails ships (GORM-aware serializers registered the same way), not stay a separate `ObjectMarshaller` SPI bolted on the side. That's a bigger scope than this PR currently takes on, but it's the architecturally coherent version of the same goal, and 9.0.x is the right place to make that call rather than carry the hybrid forward.

Keep Jackson-backed converter rendering while retaining the upstream date-binding fixes and strict month validation. Reconcile date/time tests with the mapper output, share the upstream fixtures, and update the Grails 9 upgrade notes.
JsonMapperSupport called ObjectMapper._serializationContext(), which Jackson
documents as public only for testing. The mapper's acceptJsonFormatVisitor
passes the same context to the visitor before it visits a type, so take it
from there. The application mapper's date format now also has a map key test.
A serializer that a Jackson module provides writes the values nested in its
type through the mapper, so a Grails marshaller registered for a nested type
does not apply there. State this in the 9.0 upgrade guide and the converters
guide, and test it, along with a marshaller for the outer type taking
precedence over the module serializer.
A serializer that a Jackson module provides wrote the values nested in its
type through the mapper, so a marshaller registered for a nested type, such
as one that redacts it, did not apply there, and a cycle through it was not
detected.

The converter now writes with a copy of the application's JsonMapper whose
serializers offer each nested value back to the converter, which renders it
as any other value unless it would render it with the mapper anyway. The
application's own mapper is unchanged. JSONWriter.writeNested writes such a
value and returns the writer to its state, and the PATH writer records the
value's location in the mapper's output.
DomainClassSerializer renders a domain class instance as DomainClassMarshaller
did: the id, the version and class name when they are included, the persistent
properties, and associated instances as {"id": ...} references or, rendering
deep, in full. Grails registers it with the application's JsonMapper as the
domainClassJacksonModule bean, so writing a domain class with the mapper,
including returning one from a Spring MVC controller, renders it as
render ... as JSON does. grails.converters.json.domain.jackson.enabled: false
keeps Jackson's bean serialization.

DomainClassMarshaller and DeepDomainClassMarshaller hand the instance to the
serializer, with the converter's includes and excludes and their protected
hooks, and the converter renders the property values. They, and
ProxyUnwrappingMarshaller, are consulted before the mapper's value serializers.

A property annotated @JsonIgnore or write-only is no longer written. The
mapper also leaves out the properties @JsonIgnoreProperties names, as Jackson
does; the converter still renders those, because GORM adds that annotation,
naming the version and tenantId, to every domain class.

A to-many association whose value is a Map failed to render; it is now an
object of references.
@jdaugherty

Copy link
Copy Markdown
Contributor

The parser behaviors are very different between jackson / openjson / the vendored json. I'm ok supplementing it, but I'm not sold on replacing it wholesale. I've mentioned this before on #15958 - the behavior differences have real consequences. Can you make a case on why we should do this? The behaviors are very different and much more strict on jackson.

@jdaugherty

Copy link
Copy Markdown
Contributor

I asked AI to summarize some of the differences to help this discussion, it's included below (it was done against the 7.x branch, but I think this comparison brings to light several of the problems):

Grails JSON: OpenJSON versus Jackson

Jackson is the stronger long-term option, but neither replacement preserves Grails behavior automatically. OpenJSON is closer to the original API; Jackson offers more control over parsing, numeric precision, and resource limits.

I compared the Grails 7.0.x source and ran parsing probes against Grails 7.0.4, OpenJSON 1.0.13, Jackson 2.20.0, and Jackson 3.0.0. Jackson results below use untyped Object/Map/List deserialization, not POJO binding. These are compatibility findings for those versions, not a test of the particular proposal.

The syntax differences are substantial. Grails deliberately accepts several nonstandard forms, documented in its API. Jackson rejects most by default, with optional features for selected extensions. Sources: Grails JSONObject API and Jackson JsonReadFeature API.

Input or behavior Grails fork OpenJSON Jackson defaults
Single quotes: {'x':'a'} Accepts Accepts Rejects; configurable
Comments: //, /* */, # Accepts Accepts Rejects; configurable
Unquoted string value: {x:hello} Accepts Accepts Rejects; allowing unquoted field names does not permit bare string values
Uppercase literals: {"x":TRUE} Boolean true Boolean true Rejects
Alternate separators: {x=>1;y:2} Accepts Accepts Rejects
Object trailing comma: {"x":1,} Accepts Rejects Rejects; configurable
Array trailing comma: [1,] [1] [1,null] Rejects; configurable
Missing array element: [1,,2] [1,null,2] [1,null,2] Rejects; configurable
Octal number: {"x":010} Integer 8 Integer 8 Rejects; permitting leading zeros interprets this as decimal 10
Numeric object key: {12:3} Key becomes "12" Rejects Rejects by default
Hex string escape: {"x":"\x41"} String "A" String "x41" Rejects
Literal newline inside a quoted string Rejects Accepts Rejects; configurable
{"x":new Date(0)} Java Date Rejects Rejects
Duplicate keys: {"x":1,"x":2} Last value wins Last value wins Last value wins; rejection can be enabled
Valid object followed by garbage Ignores suffix Ignores suffix Jackson 2 ignores it by default; Jackson 3 rejects it

The trailing-content difference is a documented Jackson 3 default change: FAIL_ON_TRAILING_TOKENS became enabled. Source: Jackson 3 migration guide.

That exposes a weakness in the “OpenJSON is a compatible replacement” argument: it can both reject previously accepted input and silently change the resulting data.

Numeric handling is probably the most consequential difference—even for valid JSON.

These are results from the probes:

Numeric token Grails OpenJSON Jackson, untyped defaults
42 Integer Integer Integer
9223372036854775808 Exact BigInteger Rounded Double Exact BigInteger
0.123456789012345678901 Exact BigDecimal Rounded Double Rounded Double
1.00 BigDecimal, scale 2 Double 1.0 Double 1.0
1e3 BigDecimal Double Double

Grails uses a custom selection rule: integral numbers generally become Integer, Long, or BigInteger; other numbers become Double only when its BigDecimal comparison succeeds, otherwise they remain BigDecimal. Consequently, scale and notation can affect the Java type.

Jackson can preserve decimal precision with USE_BIG_DECIMAL_FOR_FLOATS, but that makes all untyped floating-point values BigDecimal. It does not reproduce Grails’ mixed Double/BigDecimal rule. Exact compatibility would need custom numeric conversion, ideally using the original token text. Source: Jackson DeserializationFeature API.

OpenJSON’s parser instead tries Integer, Long, then Double. Moving to it without adaptation would undo Grails’ large-number preservation. Source: OpenJSON JSONTokener source.

The returned object model is a separate compatibility problem.

  • Grails: JSONObject implements Map; JSONArray implements List. Parsed JSON null becomes Java null.
  • OpenJSON: similarly named containers, but they do not implement those collection interfaces. Parsed null uses JSONObject.NULL.
  • Jackson: untyped binding gives ordinary maps/lists and Java null; tree parsing gives ObjectNode, ArrayNode, and NullNode.

Grails’ collection interfaces are part of its public API. Replacing them affects casts, collection operations, nested access, and callers expecting the Grails classes—not merely imports. Sources: Grails JSONObject API and Grails JSONArray API.

My assessment of the tradeoffs:

Choice Pros Cons
Keep the Grails fork Best preservation of existing syntax, numeric rules, and public types Grails continues maintaining parser code, quirks, and defensive limits
OpenJSON Small standalone dependency; familiar API; Apache-licensed clean-room implementation Precision regressions; subtle syntax changes; null sentinel and collection-interface differences still require adaptation
Jackson Streaming support; configurable syntax, duplicate handling, precision, and processing limits; broader integration opportunities Greater migration scope; defaults differ; cannot reproduce every legacy extension through feature switches

OpenJSON explicitly describes itself as an Android-derived clean-room implementation, rather than the same source Grails forked. Its inspected parser also has a default nesting limit of 100. Jackson provides configurable limits for nesting and number/string lengths through StreamReadConstraints. These limits themselves need compatibility testing. Sources: OpenJSON project, OpenJSON JSONTokener source, and Jackson processing-limit documentation.

I would not assume a performance winner without measuring the actual Grails path. Jackson’s streaming advantage can disappear if the implementation constructs a Jackson tree and then copies it into Grails containers.

My recommendation is Jackson behind a Grails compatibility facade, with explicit decisions about which legacy behaviors to retain.

An alternative framing is to separate two proposals:

  1. Replace the parsing engine: preserve Grails’ public containers, null handling, numeric contract, and exception behavior.
  2. Modernize the JSON contract: deliberately reject legacy syntax or change returned types in a documented breaking release.

That separation makes the proposal much easier to evaluate. Switching parsers should not implicitly switch Grails databinding or JSON marshalling to Jackson POJO behavior.

Before accepting either replacement, I’d require a differential test suite covering the examples above, nested containers, missing versus explicit null, numeric precision and scale, errors, and size/depth limits. OpenJSON needs that suite just as much as Jackson does; API resemblance is insufficient evidence of compatibility.

@jdaugherty

Copy link
Copy Markdown
Contributor

Even AI recommends this, but acknowledges the scope of it. That's why I'm struggling to accept this.

@codeconsole

codeconsole commented Oct 2, 2026 •

Copy link
Copy Markdown
Contributor Author

@jdaugherty I think it summarized best by do we want to support sloppy syntax or go the same direction Spring Boot already went.

Open up your browser dev console, paste

{"x":TRUE}

hit enter
result: ReferenceError: Can't find variable: TRUE

@jdaugherty

Copy link
Copy Markdown
Contributor

If you can't articulate why we should change, I don't think we should make the change. The same argument could be made for groovy vs java - why don't we use semicolons everywhere?

At a minimum, this change is large enough that it should be proposed in a plan and it should be voted on after discussion. I'm a -1 on this for now as a result. I'm not against supporting both, but removing the functionality will break several existing apps with no off ramp / transition. We need to discuss the differences and plan on how to transition. This PR doesn't do either and I don't think this is something we should do on a PR alone.

@codeconsole

codeconsole commented Oct 2, 2026 •

Copy link
Copy Markdown
Contributor Author

@jdaugherty It's not a question of groovy vs java. It's a question of valid syntax. All your examples don't work inside a basic browser console. Why would you want to allow {"x":TRUE} ?

Of course I can articulate why we should change, I was just quickly commenting on your comparison. All I was mentioning was Spring Boot 4 went pure Jackson 3. If we are going to leverage and supplement Spring Boot, we probably shouldn't add inconsistencies that basically just introduce bugs downstream.

Why would you ever send {"x":TRUE}? what is going to read that? What is going to send that? I can only think of 1 thing, a bot you don't want scraping your site anyhow.

Why would you be -1 on something that

Even AI recommends this

?

You prefer unmaintained Grails json code written 10 years ago and all its "quirks" OR

Streaming support; configurable syntax, duplicate handling, precision, and processing limits; broader integration opportunities

var x = TRUE

doesn't work in Groovy, Java or Javascript

@codeconsole

Copy link
Copy Markdown
Contributor Author

Groovy PR for Jackson 2 -> Jackson 3
apache/groovy#2949

@jdaugherty

Copy link
Copy Markdown
Contributor

Like I said, I don't dispute it's probably the right direction, but I'm against telling our users: sorry, it's broken with no gradual migration path. Also, not everyone uses a Grails app with a browser only - https://github.com/openjson/openjson was built by the Android team and has all of the same syntax. This PR is proposing no migration window / timeline. It's just a hard cut off. For something so critical to the framework, that's a huge direction shift and it's not something I want to support. If we're going to do a gradual migration, I'm fine with it. Especially because people can opt into the change when they're ready.

JSON.parse, and so request.JSON, still parse with Grails' JSONTokener. The spec
pins what it accepts beyond strict JSON (single quotes, comments, unquoted
values, uppercase literals, trailing commas, octal numbers, new Date(...)),
the number types it returns, and its Map and List containers.
…, deprecated

Removing them broke code that uses them with no transition: the
grails-datastore-rest-client-legacy plugin's RestBuilder creates a
ByteArrayMarshaller, and applications import JSONParser directly. They are
restored as they were in Grails 8, deprecated for removal in Grails 10.

The value marshallers and Grails 8's SimpleTypeMarshaller entries are no longer
registered by default. PrettyPrintJSONWriter writes through the generator with
a pretty printer that indents as Grails 8's PrettyPrintJSONWriter did, which a
new protected JSONWriter constructor takes. JSONParser is not used by Grails;
JSON.parse and request.JSON parse with JSONTokener.
A transition to the Jackson rendering, deprecated for removal in Grails 10.
With grails.converters.json.legacy: true, the JSON converter registers the
marshallers of Grails 8 instead of the JsonMapper value marshallers, indents
pretty printed JSON as Grails 8 did, both when rendering with
grails.converters.json.pretty.print and from toString(true), and the domain
class Jackson module is not registered with the application's JsonMapper. A
warning is logged on startup.

Grails8JsonRenderingSpec compares the legacy output with text rendered by
Grails 8.0.x (232bb34) for 111 values and both pretty print paths. Comparing
that text with the Jackson rendering showed that Grails 8 already rendered
records as an object of their components, so the upgrade guide no longer lists
records as changed. The guide states that parsing is unchanged, and shows the
per-type marshallers that keep a Grails 8 format.
@codeconsole

Copy link
Copy Markdown
Contributor Author

@jdaugherty This PR doesn't change parsing. JSON.parse, request.JSON and new JSONObject(String) still go through Grails' JSONTokener. The only parser it touched was the JavaCC JSONParser, which nothing in Grails calls. Everything in the Grails column of your table still holds, and fea8b9b pins it in JsonParsingSpec:

  • single quotes, comments, unquoted values and TRUE
  • =>/; separators, trailing commas and [1,,2]
  • octal numbers, numeric keys, \x41 and new Date(0)
  • duplicate keys, trailing content, and rejecting a line break inside a string
  • the Integer/Long/BigInteger/Double/BigDecimal selection (1.00 keeps its scale)
  • the Map/List containers

For what the PR does change, which is writing:

  • 2493e1e: grails.converters.json.legacy: true renders JSON as Grails 8 did. It uses Grails 8's marshallers, both of Grails 8's pretty-print layouts, and no domain module on the application's JsonMapper. It's deprecated for removal in 10 and logs a warning. Grails8JsonRenderingSpec compares it byte for byte with text rendered by 8.0.x, for 111 values and both pretty-print paths.
  • 64e1733: nothing is deleted any more. The JSON value marshallers, PrettyPrintJSONWriter and JSONParser are back, deprecated for removal in 10. rest-client-legacy's RestBuilder creates a ByteArrayMarshaller, and apps import JSONParser.
  • The upgrade guide lists every output difference from 8.0.x, measured against that same 8.0.x text: OffsetTime, Month, char, byte[], UUID/URI/@JsonValue types, Locale, Optional and the pretty-print layout. It shows the registerObjectMarshaller line that keeps the old format of each.

So an app can move to 9 with one setting and drop it type by type before 10. If you'd still like this on dev@ before it merges, I'll start the thread.

@codeconsole

Copy link
Copy Markdown
Contributor Author

@borinquenkid Addressed:

  • 9b0c6c1: JsonMapperSupport no longer calls _serializationContext(). It takes the context from ObjectMapper.acceptJsonFormatVisitor, which creates it with _serializationContext() and passes it to the visitor, so output is unchanged.
  • 208977c: no bypass. The converter writes with a copy of the application's JsonMapper whose serializers hand each value they write back to the converter. registerObjectMarshaller, enum names and the circular-reference behaviours therefore apply inside a module serializer. The application's own mapper is unchanged. Your repro and the cycle cases are in JsonMapperRenderingSpec.
  • a2dc99d: the domain-class module you described. DomainClassSerializer renders domain classes as DomainClassMarshaller did, and Grails registers it with the application's JsonMapper as a JacksonModule bean. jsonMapper.writeValueAsString(book) and a @RestController returning a domain object now render as render ... as JSON does. DomainClassMarshaller hands instances to it with the converter's includes/excludes, and its protected hooks still apply. Beans, enums, collections and maps still go through Grails marshallers.

@codeconsole

Copy link
Copy Markdown
Contributor Author

@matrei Thanks for re-checking, and for confirming the sort order. The five threads have replies. Your smaller items are done:

  • EXISTING_PROPERTY: section 2 now says that with include = EXISTING_PROPERTY the type id is the property's value, so it's written only when that property is persistent, and a transient or computed one isn't (5536958).
  • Iterable: I dropped the Iterable clause from isContainer, as you suggested, together with the Path special case (26f1dda). Collections and bean-like iterables still go to the Grails marshallers through their container or bean serializers. Path and JsonNode now reach the mapper. JsonNode is in JsonMapperRenderingSpec's comparison with the mapper's text, and in the guide's table.
  • legacy with domain.jackson.enabled: the legacy section now says the converter still renders as Grails 8 did, and the application's JsonMapper renders domain classes with the module.

Verification: 3,772 tests, 0 failures, all run fresh, across the same 12 suites as before, including the full app1 and hibernate7/app1 functional suites. Grails8JsonRenderingSpec still matches the 8.0.x text. codeStyle passes, and the guide builds.

@codeconsole
codeconsole requested a review from matrei October 8, 2026 17:48

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

Re-reviewed at 27a86d8: the nine commits from 1b3a980 to 5536958; the merges after them only bring in 9.0.x. The implementation asks are closed, and I've replied in each thread with what I checked. Beyond the threads:

  • DomainClassMarshaller.marshalObjectAsGrails8 is the 8.0.x marshalObject body line for line, apart from the documented change to a to-many Map association (one object of its entries, identical text for one entry) and an unused local.
  • I ran Grails8DomainRendering on 8.0.x (232bb34) in a fresh checkout, in UTC: the 11 scenarios reproduce the GRAILS_8_DOMAIN text byte for byte. With that, the switch covers domain classes, map keys and toString(false) as the guide says, and the spec holds against a mapper that indents, writes numbers as strings, uses another time zone and has a module serializer.
  • matrei's threads: the enum constant with a body, Money extends Number, the cycle hook, the unwrapped type id and the reference naming behave as described. DomainClassJacksonModule creates its serializers per mapper in setupModule, so the per-type name cache cannot carry one mapper's naming strategy into another; I probed two mappers sharing one module instance.
  • grails-web-common (144) and grails-converters (412) pass locally from a clean run, and CI is green at this head.

One cosmetic note that needs no change: on the mapper path id and version follow the naming strategy (Id, Version under UPPER_CAMEL_CASE) while the class property written with include.class stays class, as Jackson does not introspect it.

On the 9.x default I am -1 until it is inverted; see that thread.

Grails marshallers still render domain classes, beans, enums, collections, maps and arrays, and now records (as an
object of their components) and `Optional` (as its value), so the values they contain are rendered as any other value
is. A marshaller that an application registers, with `JSON.registerObjectMarshaller(...)` or an
`ObjectMarshallerRegisterer`, still takes precedence over the mapper for the types it supports.

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.

Checked #16237 at 5ce132c against this: respond() stays on the converter in 9 and grails.web.rendering.json.spring flips in 10, the marshaller trigger is gone, and registerObjectMarshaller is not deprecated there either, so the two layers line up as you describe. Its guide still says render value as JSON "uses the legacy converter" and calls the converter "legacy" throughout; after this PR that converter writes through the mapper, so that wording needs the update you mention in whichever merges second.

Comment thread grails-web-common/src/main/groovy/org/grails/web/json/JSONWriter.java Outdated
Comment thread grails-doc/src/en/guide/upgrading/upgrading90x.adoc Outdated

Config grailsConfig = getGrailsConfig();
ProxyHandler proxyHandler = getProxyHandler();
boolean legacy = grailsConfig.getProperty(SETTING_CONVERTERS_JSON_LEGACY, Boolean.class, false);

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.

I'm still asking for the inversion, and I'm -1 until the default changes.

The Grails 8 precedent doesn't carry here. 9.0 is being accelerated, with few milestones, so the new output gets little pre-release exposure before it ships, and this change is not being backported to 8.1.x, so there is no release in which an application can opt into the mapper-backed output before it becomes the default. The first release that has the new output is also the one that turns it on for everyone. For a part of the framework this central, where the text goes to clients outside the application, that is changing a default, not offering a migration.

So for 9.x: default to the Grails 8 marshallers and layout, let an application opt into the mapper with the setting, and flip the default in 10. The switch being complete and undeprecated is good, and the inversion doesn't lose any of that work.

…er as an opt-in

grails.converters.json.legacy now defaults to true: the JSON converter
renders JSON as Grails 8 did unless an application sets it to false to
write through the JsonMapper, which Grails 10 makes the default. The
setting stays undeprecated, and nothing is logged.

- The initializer, ConvertersConfigurationHolder and the configuration
  metadata default to the Grails 8 rendering.
- The specs of the JsonMapper path opt in, and Grails8JsonRenderingSpec
  checks that, without the setting, every value, both pretty print paths
  and the 11 domain class scenarios render as Grails 8.0.x rendered them.
- Tests this change had edited only because the default had changed are
  back to their 9.0.x versions: JsonDateTimeRenderingSpec, the app1 and
  hibernate7 app1 date marshaller controllers and specs, and the comment
  in MonthBindingSpec.
- The upgrade guide and the REST guide describe the JsonMapper as an
  opt-in that Grails 10 makes the default.
@codeconsole

codeconsole commented Oct 12, 2026 •

Copy link
Copy Markdown
Contributor Author

inversion done, although I agree with @matrei I think it would be better the other way since it is so easy to flip legacy behavior back on via legacy: true, but now that's the default and if you want to use Jackson 3, you need to specifically set legacy: false

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

@matrei I am good with this not being the default for 9. This is the same approach we took with the hibernate 7 work. I realize this is different from many past decisions, but in this case we're wanting to do 9 in less than a month. I would be supportive of flipping this default in 9.1 as part of the Spring 4.2 upgrade, but I do not think we should flip this with so little milestones / rc's given it's size. If you agree, I'm good to merge this.

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

Re-reviewed at 8ce0e14: the four commits since my last round (26f1dda, 4a20e8d, 5536958 and 6e9b680). The merges after them only bring in 9.0.x, and the one that touches upgrading90x.adoc only changes section 1.

The fixes in my threads hold as described: an enum constant with a body renders by name on the mapper path and as a bean under legacy, a Number with a module serializer reaches the mapper, the deep cycle goes through writeReference, the unwrapped type id fails as Jackson's own unwrapping does, and association and cycle references use the same names. I'll resolve those threads. grails-web-common (144) and grails-converters (409) pass locally on this head, run fresh.

I can live with the inversion. With the switch now complete, either default is a safe one to ship, and the comments below come from it. Most of the text was written when the mapper was the default, and a few places still describe it that way.

1. The REST guide's date and time section describes legacy: false as the default. defaultRenderers.adoc:172-210 starts with "With the default mapper configuration" and then gives the mapper's output: OffsetTime as "03:00-03:00", Month as a number, and "Map keys use the mapper's key serializers". With the 9 default, a Month renders "SEPTEMBER", an OffsetTime keeps its seconds, and map keys are formatted as in Grails 8. The paragraphs this PR removed there were the ones that describe the 9 default. Either restore them and add a legacy: false note, or open the section with the setting.

2. Two paragraphs in the upgrade guide read as if they apply whatever the setting. At upgrading90x.adoc:170 ("Map keys use the mapper's key serializers…") and :175 ("JSON#toString(false) does not indent…"), both behaviours apply only with legacy: false. Under the default, keys are formatted as in Grails 8, and toString(false) indents when pretty.print is true (Grails8JsonRenderingSpec pins that). Each needs a "With the setting false" like the table above it.

3. With the PATH circular reference behaviour, the default no longer matches Grails 8's text. The PathCapturingJSONWriterWrapper fix applies whatever the setting, so the default path writes different references than 8.0.x. In this example a cycle follows a number property, and the probe ran with no legacy setting:

class Node { int n; Node other }
def child = new Node(n: 2); child.other = child
new JSON([x: new Node(n: 1, other: child)]).toString()
new JSON([a: 5, x: [7, child]]).toString()
8.0.x (60f75ee) This head
after a number property "ref":"root.x.n" "ref":"root.x.other"
after a number in the map and in the list "ref":"root.a.x" "ref":"root.x[1]"

The new paths are the right ones, and I wouldn't keep the old ones. But the legacy section (upgrading90x.adoc:213) says the converter returns "the same text as in Grails 8", apart from what Grails 8 failed to render or rendered as invalid JSON, and a wrong path is neither. Please list it there with the other exceptions, or say that the PATH paragraph at :204 applies whatever the setting. Grails8JsonRenderingSpec has no PATH case, which is why nothing caught it.

4. Nothing runs the opt-in end to end any more. Before 6e9b680, the app1 and hibernate7/app1 functional suites ran the mapper path, because it was the default. With those edits reverted, legacy: false is covered only by the unit specs, which use a JsonMapper they build themselves. None of them uses the one Boot auto-configures from spring.jackson.* with real Hibernate proxies. A small functional spec in one of the apps that opts in would catch an integration break, for example @SpringBootTest(properties = ['grails.converters.json.legacy=false', 'spring.jackson.time-zone=America/New_York']) rendering a domain instance with a lazy association and a date.

Smaller items:

  • The guide, the initializer javadoc, the metadata description and the nine marshaller javadocs all say that Grails 10 makes false the default (15 places). jdaugherty's approval suggests flipping it in 9.1, with the Spring 4.2 upgrade. Whichever release it is, it would be easier to settle it before merge, or to word it as "a later release" for now, so the javadocs don't go out naming the wrong version.

@matrei

matrei commented Oct 15, 2026

Copy link
Copy Markdown
Contributor

One more thing I noticed after posting: nothing at runtime tells an application that its JSON output will change in Grails 10.

With grails.converters.json.legacy unset, the only log line is the DEBUG one in ConvertersConfigurationInitializer ("Using the JSON marshallers of Grails 8 ..."). The configuration metadata entry has no deprecation block either. An application that upgrades to 9 and never reads the upgrade guide gets no signal before the default flips in 10, and that flip changes the text of every render ... as JSON response.

Could the initializer log a WARN once at startup, only when the property is not set? For example:

grails.converters.json.legacy is not set, so the JSON converter renders JSON as Grails 8 did. Grails 10 renders
through the application's JsonMapper by default. Set it to true to keep the Grails 8 rendering, or to false to
switch now; see "Rendering JSON As Grails 8 Did" in the upgrade guide.
  • Checking getGrailsConfig().containsProperty(SETTING_CONVERTERS_JSON_LEGACY) keeps it quiet for applications that chose true deliberately, so it nudges only the ones that haven't decided.
  • A static AtomicBoolean, as GparsPromiseFactory uses for its deprecation warning, keeps it to one line when the initializer runs more than once in a JVM, as it does in tests.
  • A test for the three cases (unset warns, true and false don't) would pin it down.

This isn't blocking for me, but it's cheap and it's the only way most users will find out before 10.

… in the guides

While grails.converters.json.legacy is not set, the initializer logs one
warning, once per JVM, that a later release renders JSON through the
JsonMapper by default, and how to keep or switch the rendering.

- ConvertersConfigurationInitializerSpec checks that the unset setting
  warns once, and that true and false do not.
- The REST guide's date and time section describes the default again,
  with a paragraph for the setting false.
- The upgrade guide's map key and toString(false) paragraphs apply to
  the setting false, and the PATH reference fix, which applies whatever
  the setting, is listed with the exceptions to the Grails 8 text.
  Grails8JsonRenderingSpec pins the corrected paths.
- hibernate5/grails-hibernate opts in, with spring.jackson.time-zone,
  and JsonMapperConverterSpec renders a domain class instance whose lazy
  association is a Hibernate proxy.
- The guides, the javadoc and the configuration metadata say that a
  later release, rather than Grails 10, makes false the default.
@codeconsole

Copy link
Copy Markdown
Contributor Author

@matrei thanks, all addressed in 2ec2017, including the startup warning.

1. REST guide dates. The section is back to the 9.0.x text, which describes the default: OffsetTime keeps its seconds, Month renders by name, map keys are formatted as before, and the Month marshaller example is back. A closing paragraph covers legacy: false: OffsetTime from toString(), Month as a number, and map keys through the mapper's key serializers.

2. Upgrade guide. The map key and toString(false) paragraphs now start with "With the setting false".

3. PATH. The fix stays in both modes. The PATH paragraph now starts with "Whatever the setting" and gives your first example, and the legacy section lists it with the other exceptions to "the same text as in Grails 8". Grails8JsonRenderingSpec has a feature with your two examples. I ran them on 8.0.x (1435057) and got root.x.n and root.a.x, as you did.

4. End to end. hibernate5/grails-hibernate now opts in through its application.yml (legacy: false, spring.jackson.time-zone: America/New_York). JsonMapperConverterSpec renders a Shipment whose carrier is a lazy association, through the mapper Spring Boot auto-configures:

{"id":1,"shipped":"1969-12-31T19:00:00.000-05:00","carrier":{"id":1}}

I checked that the carrier is still an uninitialized HibernateProxy after rendering, and that the spec fails with legacy: true. I put it in that app rather than app1 because ConvertersConfigurationHolder is static. A second app1 context with legacy=false would leave its configuration in place for the app1 specs that run after it in the same JVM. None of that app's other specs render JSON.

5. Version. All 15 places now say "a later release" instead of Grails 10.

Startup warning. Done as you suggested. While grails.converters.json.legacy is not set, the initializer logs one WARN per JVM, guarded by a static AtomicBoolean:

grails.converters.json.legacy is not set, so the JSON converter renders JSON as Grails 8 did. A later release renders JSON through the application's JsonMapper by default. Set it to true to keep the Grails 8 rendering, or to false to render through the JsonMapper now; see "Rendering JSON As Grails 8 Did" in the upgrade guide.

ConvertersConfigurationInitializerSpec covers the three cases: unset warns once across two initializations, and true and false don't. The upgrade guide mentions the warning and says that either value turns it off.

Tests run on this head:

  • grails-converters: 413 tests pass.
  • grails-web-common: 144 tests pass.
  • codeStyle and publishGuide succeed.
  • hibernate5/grails-hibernate integration tests: all pass except BookControllerSpec. That spec couldn't start locally because Docker ran out of disk pulling the Selenium image; CI runs it.

@testlens-app

This comment has been minimized.

@codeconsole
codeconsole requested a review from matrei October 16, 2026 00:59

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

Re-reviewed at 2ec2017, the only commit since 8ce0e14.

Everything from my last round is addressed as described:

  1. The REST guide's date and time section is back to the 9.0.x text, apart from the javascript sentence, which is now in the general rendering section. The legacy: false paragraph after it is accurate.
  2. The map key and toString(false) paragraphs in the upgrade guide now say they apply with the setting false.
  3. The PATH paragraph applies whatever the setting, and the legacy section lists it with the other exceptions. The new Grails8JsonRenderingSpec feature pins both of my examples.
  4. JsonMapperConverterSpec runs the opt-in end to end through the mapper that Boot auto-configures. I checked that no other spec in hibernate5/grails-hibernate renders JSON, so opting the whole app in doesn't change their coverage. Your reason for not using app1, the static ConvertersConfigurationHolder, makes sense.
  5. The 15 places now say "a later release".

The startup warning is what I had in mind: it fires only when the property is unset, once per JVM, and the upgrade guide mentions it.

On this head, run fresh: grails-converters (413), grails-web-common (144) and JsonMapperConverterSpec all pass. I also confirmed the claim that the carrier stays an uninitialized proxy. A probe spec loaded the Shipment in a new session, rendered it as JSON, and called Hibernate.isInitialized on the Carrier$HibernateProxy before and after rendering: false both times.

One optional suggestion: JsonMapperConverterSpec checks only the response body, and {"carrier":{"id":1}} would look the same if rendering had initialized the proxy. If you want the spec to pin the laziness too, an assertion like the one in my probe would do it:

Shipment.withNewSession {
    def shipment = Shipment.get(shipmentId)
    (shipment as JSON).toString()
    assert !Hibernate.isInitialized(shipment.@carrier)
}

That's not blocking. LGTM.

The response body is the same whether or not rendering initializes the
carrier's proxy, so JsonMapperConverterSpec now also loads the shipment
in a new session, renders it as JSON, and checks that the carrier is an
uninitialized HibernateProxy before and after rendering.
@codeconsole

Copy link
Copy Markdown
Contributor Author

@matrei thanks, good catch. The body alone can't show the laziness, so I added it in 1d3bf77.

JsonMapperConverterSpec has a second feature based on your probe. It loads the Shipment in a new session, reads shipment.@carrier, renders the shipment as JSON, and checks:

  • the carrier is a HibernateProxy,
  • it is uninitialized before rendering, so the check after rendering means something,
  • the JSON is the same reference text as the request feature,
  • it is still uninitialized after rendering.

To check that the assertion can fail, I made DomainClassRendering.referenceId unwrap the proxy first, which initializes it. The new feature then failed on !Hibernate.isInitialized(carrier), while the body-only feature still passed. I reverted the change and the spec passes again.

The branch also has a merge of the latest 9.0.x (4e69821). Its only conflict was the upgrade guide: the new invokedynamic section is §2, so the grails.converters.JSON and domain Jackson sections are now §3 and §4, with their text unchanged. On the merge, grails-converters (413), grails-web-common (144), codeStyle and JsonMapperConverterSpec pass.

@codeconsole
codeconsole merged commit d0ecc35 into apache:9.0.x Oct 16, 2026
88 checks passed
jdaugherty added a commit that referenced this pull request Oct 16, 2026
Since #16414 the writer hands its output to a Jackson generator: a bare
top-level value is a complete JSON text rather than a sequence error, the
generator writes the separators itself so comma() has nothing to add, and an
output failure surfaces when the buffer is flushed instead of on the call
that produced it. JSONWriterSpec covers each of those, so the 7.x features
that pinned the synchronous failures and the top-level sequence error are
dropped and the raw append coverage asserts the no-op comma.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

4 participants