Skip to content

Make domain properties nullable by default (Grails 8)#15721

Open
codeconsole wants to merge 5 commits into
apache:8.0.xfrom
codeconsole:nullable-by-default
Open

Make domain properties nullable by default (Grails 8)#15721
codeconsole wants to merge 5 commits into
apache:8.0.xfrom
codeconsole:nullable-by-default

Conversation

@codeconsole

@codeconsole codeconsole commented Jun 8, 2026

Copy link
Copy Markdown
Contributor

Summary

Change GORM's default constraint so an unconstrained persistent (domain) property is nullable (optional) rather than required. Today Grails applies an implicit nullable: false to every persistent property; this PR flips that default to nullable: true.

The default lives in DefaultConstraintEvaluator and is controlled by a new YAML‑friendly boolean that defaults to nullable‑by‑default:

# application.yml — set false to restore the legacy required-by-default behaviour
grails:
  gorm:
    default:
      nullable: false

Scope is the validation layer only. Command‑object validation (Validateable.defaultNullable()) is intentionally left required‑by‑default and unchanged in this PR.

Why this should be the Grails 8 default

Grails is the only mainstream JVM data framework that is required‑by‑default. Every comparable layer treats an unconstrained property as nullable and makes you opt into "required":

Framework Default for an unconstrained reference property
JPA / Hibernate nullable (@Column(nullable=false) / @NotNull to require)
Spring Data JPA nullable
Spring Data MongoDB nullable (schemaless)
Micronaut Data nullable (@NonNull / Kotlin non‑null type to require)
Jakarta Bean Validation (JSR‑380) nullable — no constraint = valid; @NotNull is the opt‑in
Grails / GORM (today) required (nullable: true to allow null)

The case for flipping it:

  1. Principle of least surprise / ecosystem consistency. Developers arriving from Spring Boot, Micronaut, JPA, or plain Bean Validation expect "nullable unless I say otherwise." Grails inverts that — one of the most common first‑week surprises ("why does saving fail when I didn't mark anything required?").
  2. It contradicts the storage layer. SQL columns are nullable by default; MongoDB is schemaless. Grails' validation default is stricter than the database it maps to, for no structural reason.
  3. It inverts the JSR‑380 contract. Bean Validation defines "absence of a constraint ⇒ the value is valid (including null)"; @NotNull is the explicit opt‑in. Grails' implicit nullable:false is a silent, framework‑specific reversal of the spec it otherwise embraces.
  4. It breaks composition. Fields contributed by traits, base classes, or shared modules — or simply a new field added to an existing domain — silently become required, surfacing only as a runtime ValidationException on the first save (often in an unrelated path, e.g. a BootStrap seed). Mixing in reusable field sets is exactly the modular design Grails 8 should encourage; required‑by‑default punishes it.
  5. A major version is the right time. Defaults that no longer match the ecosystem should be corrected at a major boundary. Grails 8 is that boundary.

Backward compatibility

Deliberate behavior change, with a one‑line opt‑out to restore the legacy behavior per application. Either set the boolean flag (YAML or groovy):

# grails-app/conf/application.yml
grails:
  gorm:
    default:
      nullable: false
// grails-app/conf/application.groovy
grails.gorm.default.nullable = false

…or use the existing wildcard‑constraint form, which also still works:

grails.gorm.default.constraints = {
    '*'(nullable: false)
}

The grails.gorm.default.constraints machinery applies '*' constraints before the framework default and skips the default when one is set (canApplyNullableConstraint), so either opt‑out composes cleanly with per‑property overrides.

Command objects are unaffected (still required‑by‑default). If you additionally want command objects to be nullable‑by‑default, that remains an explicit per‑class opt‑in via static boolean defaultNullable() { true }.

Test suite

The affected test suite has been swept and the full ./gradlew test is green. Rather than disabling the new default wholesale, each spec that asserts required‑by‑default semantics now declares the specific field(s) it depends on explicitly (nullable: false), reproducing the prior baseline for just those fields. A module‑wide grails.gorm.default.constraints opt‑out was deliberately not used as the general mechanism because that closure is also evaluated against the GORM mapping builder and can perturb per‑property mapping (e.g. formula:/derived‑property detection used by f:fields/f:all), and because several specs define their own grails.gorm.default.constraints that a module‑wide file would clobber.

A new NullableByDefaultSpec demonstrates the change without any opt‑out (an unconstrained property validates while null, while a property with an explicit nullable: false is still required), and verifies that grails.gorm.default.nullable = false restores required‑by‑default through DefaultValidatorRegistry.

Notes / scope

  • Validation layer only. Column/DDL nullability is governed separately by the mapping layer (Property.nullable / GrailsDomainBinder) and is not changed here; aligning the DDL default is a documented follow‑up for maintainers.
  • Opening against 8.0.x.

@codeconsole codeconsole marked this pull request as draft June 8, 2026 02:07
@codeconsole codeconsole force-pushed the nullable-by-default branch from 45ef5e2 to f3b9d66 Compare June 8, 2026 04:24
@codeconsole codeconsole changed the title Make properties nullable by default (Grails 8) Make domain properties nullable by default (Grails 8) Jun 8, 2026
@codeconsole codeconsole force-pushed the nullable-by-default branch 2 times, most recently from 56e5745 to 7f1ad1f Compare June 8, 2026 15:07
Flip GORM's validation default so an unconstrained persistent (domain) property is nullable
unless explicitly constrained, aligning Grails with the rest of the JVM persistence/validation
ecosystem (JPA/Hibernate, Spring Data JPA & MongoDB, Micronaut Data, Jakarta Bean Validation),
all of which treat an unconstrained property as valid-when-null.

The default is controlled by a new YAML-friendly boolean, defaulting to nullable-by-default:

    grails.gorm.default.nullable = false   # restore legacy required-by-default

This is wired through DefaultConstraintEvaluator (new defaultNullable, default true), surfaced as
ConnectionSourceSettings.DefaultSettings.nullable, and read on both validator-construction paths
(DefaultValidatorRegistry for the GORM datastore and DefaultConstraintEvaluatorFactoryBean for
Grails domain classes). The existing closure form also still works:

    grails.gorm.default.constraints = { '*'(nullable: false) }

Scope notes:
- Validation layer only. Command-object validation (Validateable.defaultNullable()) is intentionally
  left required-by-default and unchanged.
- Column/DDL nullability is governed separately by the mapping layer (Property.nullable /
  GrailsDomainBinder) and is not changed here; aligning the DDL default is a documented follow-up.

Tests: existing specs that assert required-by-default semantics are updated to declare the field(s)
they depend on explicitly (nullable: false), reproducing the prior baseline for just those fields
rather than disabling the new default wholesale. Notes:
- grails-test-suite-uber DomainConstraintGettersSpec restores required-by-default for its own
  context via a per-spec doWithConfig override, since it exists to verify default-constraint
  enumeration via the nullable error.
- FindOrCreateWhereSpec (mongodb) was using the wrong Person class (a same-package collision); it
  now imports grails.gorm.tests.Person to match Pet.owner.
- New NullableByDefaultSpec demonstrates the new default without any opt-out, and verifies that
  grails.gorm.default.nullable = false restores required-by-default through DefaultValidatorRegistry.
- The example-app functional/integration suites assert required-by-default app-wide, so each opts out
  with grails.gorm.default.nullable = false in its application.yml (the flag's intended use).

Docs: the nullable constraint reference now documents the new default and the
grails.gorm.default.nullable flag (YAML and groovy forms).
@codeconsole codeconsole force-pushed the nullable-by-default branch from 7f1ad1f to c27b930 Compare June 8, 2026 19:03
@codeconsole codeconsole marked this pull request as ready for review June 9, 2026 00:59
@codeconsole codeconsole requested review from jdaugherty and matrei and removed request for jdaugherty June 9, 2026 00:59
@codecov

codecov Bot commented Jun 9, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 48.4341%. Comparing base (3be255b) to head (b112a97).
⚠️ Report is 26 commits behind head on 8.0.x.

Additional details and impacted files

Impacted file tree graph

@@                Coverage Diff                 @@
##                8.0.x     #15721        +/-   ##
==================================================
+ Coverage     48.3185%   48.4341%   +0.1155%     
- Complexity      15212      15266        +54     
==================================================
  Files            1875       1875                
  Lines           85818      85859        +41     
  Branches        14969      14972         +3     
==================================================
+ Hits            41466      41585       +119     
+ Misses          37980      37865       -115     
- Partials         6372       6409        +37     
Files with missing lines Coverage Δ
...core/src/main/groovy/grails/config/Settings.groovy 100.0000% <ø> (ø)
...y/org/grails/validation/ConstraintEvalUtils.groovy 85.7143% <100.0000%> (+1.0989%) ⬆️
.../tck/domains/ClassWithListArgBeforeValidate.groovy 100.0000% <100.0000%> (ø)
...ng/tck/domains/ClassWithNoArgBeforeValidate.groovy 100.0000% <100.0000%> (ø)
...k/domains/ClassWithOverloadedBeforeValidate.groovy 100.0000% <100.0000%> (ø)
...n/constraints/eval/DefaultConstraintEvaluator.java 81.0811% <100.0000%> (+0.3914%) ⬆️
...nstraints/registry/DefaultValidatorRegistry.groovy 87.5000% <100.0000%> (+0.5435%) ⬆️
...g/core/connections/ConnectionSourceSettings.groovy 33.3333% <ø> (ø)
...pport/DefaultConstraintEvaluatorFactoryBean.groovy 100.0000% <100.0000%> (ø)

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

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

@jamesfredley created a documentation file for our default values and you're now changing that default, but not changing those files

jamesfredley and others added 2 commits June 10, 2026 20:20
…etadata

Follow-up docs/metadata for the nullable-by-default change:

- Upgrade guide (upgrading80x.adoc): new "GORM Properties Are Nullable by Default" section
  describing the breaking default and the grails.gorm.default.nullable opt-out (YAML and groovy).
- Validation constraints guide (constraints.adoc): corrected the now-stale note that claimed all
  domain properties are not-nullable by default.
- grails-core spring-configuration-metadata.json: register grails.gorm.default.nullable so IDEs
  (IntelliJ) recognise, document and autocomplete it in application.yml.
@jamesfredley

Copy link
Copy Markdown
Contributor

Updated the default-values metadata JSON: grails-core/src/main/resources/META-INF/spring-configuration-metadata.json now includes grails.gorm.default.nullable with defaultValue: true, matching the Grails 8 nullable-by-default behavior.

Resolve upgrade-guide conflict: take 8.0.x's renumbered sections and append the
'GORM Properties Are Nullable by Default' section as #26 (no renumbering).
"description": "A closure applied as the default constraints for all domain classes.",
"defaultValue": {}
},
{

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.

You added this to grails-core instead of grails-datastore-core.

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.

@jamesfredley why are we not adding a specific configuration file for the datastore project? That's where the constraint is actually used.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

@jdaugherty there is no similar file in grails-datastore-core. I put it where all the other grails.gorm.* keys are. It wouldn't make sense to put it a location different from where the other keys are.

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 agree, and we shouldn't let this block this change. But I think we've made a larger mistake here - the config keys were supposed to exist in the projects where they're used/defined. It's my understanding that hasn't happened based on this.

@bito-code-review

Copy link
Copy Markdown

The configuration metadata for grails.gorm.default.nullable was added to grails-core because it defines a core framework property that affects how Grails domain classes handle nullability by default. While GORM-specific logic resides in grails-datastore-core, the spring-configuration-metadata.json file in grails-core is the standard location for documenting framework-level configuration properties that are exposed to Spring Boot and IDEs for auto-completion.

- Encapsulate the config read: add ConstraintEvalUtils.getDefaultNullable(Config) (new
  grails.config.Settings.GORM_DEFAULT_NULLABLE), mirroring getDefaultConstraints, instead of
  reading the property inline in the factory bean.
- DefaultConstraintEvaluatorFactoryBean: pass cacheAutoTimestampAnnotations=true literally again
  (drop the unrelated config read that changed behaviour) and use the new helper; remove the
  now-unused Settings import.
- Remove the unused datastore Settings.SETTING_DEFAULT_NULLABLE constant.
- Drop the redundant per-app comment from the example-app application.yml opt-outs.
@testlens-app

testlens-app Bot commented Jun 12, 2026

Copy link
Copy Markdown

✅ All tests passed ✅

🏷️ Commit: b112a97
▶️ Tests: 41860 executed
⚪️ Checks: 43/43 completed


Learn more about TestLens at testlens.app.

@codeconsole codeconsole requested a review from jdaugherty June 12, 2026 16:54
@jdaugherty

Copy link
Copy Markdown
Contributor

I think this can be merged after we discuss in the weekly. This PR identified a configuration issue that I believe we need to discuss before merging. I'd suggest we merge this after Wednesday's meeting.

@codeconsole

Copy link
Copy Markdown
Contributor Author

@jdaugherty but if the hold up is just the json file, why not just approve and I can do a separate PR to address all the data mapping json changes. Unless you want just my 1 change by itself in a json file which I can do here if you want.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

4 participants