Grails domain constraints with i18n messages
When building Grails applications that reach users beyond a single language community, validation messages need to speak the same language as the form filling them in. A shipping form in Sydney expects error prompts in Australian English, while a similar workflow targeting European customers might require German feedback for invalid postal codes. Grails domain classes already ship with a robust constraints API, and coupling those constraints with i18n message bundles produces applications that feel native to every user. This approach becomes especially valuable for teams serving customers across multiple time zones, from Perth to Brisbane and beyond.
The mechanics involve declarative constraints on domain properties, message keys resolved through Spring's MessageSource, and locale-aware resource bundles stored under grails-app/i18n. Once wired correctly, the framework handles locale resolution automatically through Grails' locale-aware resolver, and developers can swap message files without redeploying core business logic. The patterns below walk through setup, customisation, and the debugging habits that save hours when a translation simply refuses to surface.
Understanding Grails domain constraints
Grails domain constraints live inside a static block, expressed through methods like nullable, blank, min, max, size, matches, inList, and validator. Each constraint accepts an optional message argument that defaults to a property name combined with the constraint type. When validation fails, Grails looks up the message through the active MessageSource to resolve a localised string from the relevant bundle. The relationship between declarative constraints and i18n keys is direct: the framework generates compound keys such as user.email.blank.email.blank when no custom message is supplied.
A typical Australian business might register users with a mobile number that must conform to the local 04XX XXX XXX pattern. The constraint block can enforce that format while the message bundle supplies the localised text. Consider a domain class representing a customer order where the delivery postcode must match Australian state ranges. The constraint defines the rule, and the message bundle delivers the explanation in the user's preferred dialect.
Developers familiar with Java's Bean Validation often wonder how Grails compares. Grails constraints wrap the underlying Hibernate validator while adding Groovy-flavoured conveniences such as closure-based validator blocks and the nullable shorthand. This structure simplifies maintenance for small teams working across Melbourne, Adelaide, and regional Queensland offices, where shared defaults live in one file and regional tweaks happen elsewhere.
Setting up i18n message bundles
Message bundles in Grails live under grails-app/i18n and follow the standard Java properties format with optional YAML variants. Each locale gets a suffix: messages.properties serves the default fallback, messages_en_AU.properties targets Australian English, messages_de.properties covers German, and so on. The framework's org.springframework.context.MessageSource bean resolves keys against these files at request time.
The naming convention matters when several regional variants share a base language. messages_en_AU.properties overrides messages_en.properties, which in turn overrides messages.properties. This layering lets a global team maintain shared defaults in the base file while regional developers in Brisbane or Hobart tweak spellings and idioms. Currency formatting, date patterns, and number separators often diverge between en_AU and en_US, and the same hierarchy applies to validation messages.
A common pitfall involves keys generated from constraints. Grails joins the property name, the constraint class simple name, and the constraint name to form compound keys. For a User domain with a password property and a minSize(8) constraint, the key becomes user.password.minSize.minSize.minSize if not careful. Override these with explicit message arguments in the constraint declaration, then supply the translated string in the bundle file. Using explicit message keys prevents accidental collisions when two domains share a property name.
Helpful conventions when wiring message files:
- Keep keys lowercase and dot-separated, mirroring the Grails convention for nested objects.
- Use English source strings in the default messages.properties so non-translators can review the fallback path.
- Commit locale-specific files alongside source code so reviewers spot untranslated strings during pull requests.
- Avoid machine-translating currency strings; AUD amounts use $ or A$ and require human review.
Applying constraints with localized messages
Writing a constraint with a custom message is straightforward. The message parameter accepts a string key that Grails resolves through the active MessageSource. For example, a product catalogue might require a stock-keeping unit that starts with the letter assigned to the warehouse: M for Melbourne, S for Sydney, B for Brisbane. The constraint enforces the prefix, and the i18n bundle explains the rule in the user's language.
class Product {
String sku
String name
BigDecimal price
static constraints = {
sku(matches: /^[MSB]\d{4}$/, message: 'product.sku.format')
name(blank: false, maxSize: 100, message: 'product.name.required')
price(min: 0.01, message: 'product.price.positive')
}
}
The corresponding messages.properties file then defines each key:
product.sku.format = SKU must start with M, S, or B followed by four digits
product.name.required = Please enter the product name (max 100 characters)
product.price.positive = Price must be greater than zero
Translators handle messages_de.properties, messages_fr.properties, and other locale files without touching the domain class. When a German customer submits an invalid SKU, Grails picks the German version automatically. The locale resolver consults the Accept-Language header, the user's session preference, or a cookie stored after a previous visit.
For complex validation involving closure-based validator blocks, the message key needs to match the constraint declaration. A validator checking that a delivery date falls within an Australian public holiday window might look like this:
static constraints = {
deliveryDate(validator: { val, obj ->
if (HolidayCalendar.isBlocked(val)) return ['blocked']
})
}
The validator returns a code, and Grails looks up product.deliveryDate.validator.blocked in the message bundle. Localisation flows naturally from this convention, and teams in Perth working late AEST hours can update a single properties file without touching Groovy code.
Handling locale detection and fallbacks
Grails exposes locale resolution through grails-app/conf/application.yml under the grails.locale and grails.i18n settings. The default resolver inspects the Accept-Language header, but production applications often override this with a session-based resolver after the user explicitly chooses a language. A shopping portal targeting both Sydney customers and international buyers benefits from a persistent language preference stored in the user profile.
Fallback behaviour deserves attention. When a key exists in messages_de.properties but is missing from messages_en_AU.properties, Grails walks the locale hierarchy until it finds a match. The chain moves from en_AU to en to the default messages.properties. This cascading lookup lets translators fill gaps progressively, and teams shipping a new Australian dialect can prioritise the most visible fields first. Documents that demand absolute accuracy, such as legal disclaimers, should remain in the default file until a qualified translator reviews them.
Time zones present a related concern. Australian operations span AEST, ACST, and AWST, and a validation message referencing "today" can confuse users near midnight UTC. Localise date format strings alongside the validation text using java.time with the appropriate ZoneId. When a user in Perth submits a date that has already passed in their zone but not in Sydney, the error message should reference the user's local clock rather than the server's. Reading about photographing crowded festivals reminds us that capturing the right moment requires understanding local context, and the same principle applies to validation messages that need to align with the user's frame of reference.
Developers occasionally need to override the active locale programmatically, such as during a background job that sends emails to customers in different regions. Inject the MessageSource bean and call getMessage with an explicit Locale argument. This pattern proves useful when sending transactional emails from a scheduled task, where the request scope does not apply.
Testing and debugging localized validation
Unit tests in Grails can validate domain constraints directly through the validate() call, and Spock specifications make it easy to assert specific error codes. When testing i18n behaviour, mock the MessageSource or simply verify that the expected error code is attached to the field. Integration tests using the Locale parameter confirm end-to-end behaviour including fallback resolution.
Common debugging steps include:
- Enabling grails.i18n.debug in application.yml to log message resolution paths during development.
- Inspecting the generated error code through fieldError.getCode() in a debugger.
- Verifying that properties files are bundled into the WAR by checking the compiled artefact.
When constraints reference messages that do not appear at runtime, the typical culprit is a missing file in the compiled WAR. Run grails war and inspect the resulting archive's WEB-INF/classes directory. Files placed under grails-app/i18n should appear in the build output, and a quick unzip -l command reveals any omissions. For deeper customisation of the error pages that display these messages, the building custom error pages walkthrough covers status code mapping and template selection.
A practical testing approach involves creating a base specification that exercises every constraint with both the default locale and at least one secondary locale. This ensures that key coverage remains consistent as new fields are added. Pair this with a translation coverage report that flags properties files missing keys present in the default bundle.
Ready to wire up validation that speaks your users' language? Visit our aboutcontact page to share your i18n setup, request a topic, or send feedback on this article. Whether you are tuning messages for Australian English quirks or rolling out support for new markets, the Grails Example team welcomes your notes and questions.