Building Localised Exception Translation in Grails Applications

A Grails application usually starts with a small collection of domain classes, controllers and GSP views. As features grow, failures become part of the user experience: a duplicate email address, an expired booking, a rejected payment or an unauthorised request all need a message that people can understand. Returning raw exception names or English-only text makes an otherwise polished application feel unfinished.

Custom exception translation separates technical failures from the language shown to a customer. In Grails, a practical design combines custom exception classes, centralised handling, message bundles and content negotiation for HTML and JSON responses. The same approach supports an Australian-facing site with English defaults, while leaving room for languages used across Sydney, Melbourne, Brisbane and other multilingual communities.

Why Exception Translation Belongs In The Application Layer

An exception describes what went wrong for the program. A translated message explains what the person using the program can do next. Those are different responsibilities, so exposing IllegalArgumentException, SQL details or a stack trace directly from a controller creates both usability and security problems.

A useful translation layer converts known application exceptions into stable error codes. For example, BookingConflictException can become booking.conflict, while PaymentDeclinedException can become payment.declined. The code remains consistent even when the wording changes, and translators can work in message files rather than editing Groovy classes.

This structure also gives developers a predictable boundary for unexpected failures. A known business error may receive a friendly 409 response, while an unknown exception is logged with a correlation identifier and returned as a generic 500 message. Customers get appropriate feedback without receiving implementation details such as database table names or cloud provider errors.

Designing A Useful Exception Taxonomy

Begin with exceptions that represent business decisions rather than every possible technical failure. A small hierarchy is easier to maintain than dozens of classes with overlapping meanings. Typical categories include validation, authentication, authorisation, resource lookup, conflicts and external service failures.

class ResourceMissingException extends RuntimeException {
    final String resource
    final Object identifier

    ResourceMissingException(String resource, Object identifier) {
        super("${resource} ${identifier} was not found")
        this.resource = resource
        this.identifier = identifier
    }
}

class BookingConflictException extends RuntimeException {
    final String bookingReference

    BookingConflictException(String bookingReference) {
        super("Booking ${bookingReference} conflicts with another booking")
        this.bookingReference = bookingReference
    }
}

The exception message in this example is useful for logs, but it should not automatically become the public response. A translator should select a message code and arguments separately. That prevents an identifier, internal query text or provider response from leaking into the browser.

Keep low-level exceptions inside the service boundary where possible. A repository can throw a persistence exception, but the service should decide whether it means “booking already exists” or “the database is unavailable”. That distinction determines the correct status code, translation key and retry behaviour.

Mapping Exceptions To Message Codes

Grails applications commonly use grails-app/i18n/messages.properties for localised text. Add a default message and language-specific variants, using message arguments for values that are safe to display.

# grails-app/i18n/messages.properties
error.booking.conflict=This booking is no longer available.
error.resource.missing={0} could not be found.
error.request.invalid=Some details need to be corrected.
error.unexpected=We could not complete your request. Please try again.

A translation service can map exception types to codes without coupling controllers to individual failures. The service may return a small object containing the code, arguments, HTTP status and logging level. Inject Grails’ message source or the relevant message service into that component, then resolve the text using the request locale.

class ExceptionTranslationService {

    def messageSource

    Map translate(Throwable error, Locale locale) {
        if (error instanceof BookingConflictException) {
            return response('error.booking.conflict', [], 409, 'INFO', locale)
        }

        if (error instanceof ResourceMissingException) {
            return response(
                'error.resource.missing',
                [error.resource],
                404,
                'INFO',
                locale
            )
        }

        response('error.unexpected', [], 500, 'ERROR', locale)
    }

    private Map response(String code, List args, int status,
                         String level, Locale locale) {
        [
            code  : code,
            message: messageSource.getMessage(code, args as Object[], locale),
            status: status,
            level : level
        ]
    }
}

The fallback behaviour matters. If a regional translation is missing, the application should use the base language or default bundle rather than returning an empty label. Test message keys as part of the build, since a typo in error.booking.conflict can otherwise reach production unnoticed.

Translating Web And API Responses

There are several places to intercept exceptions in Grails, including controller actions, filters and global error handling. A central mechanism is usually preferable because every endpoint then follows the same rules. The exact implementation depends on the Grails version and whether the application uses standard controller rendering, REST support or a dedicated error handler.

For an HTML request, render a view or redirect with a flash message when the user can correct the problem. For an API request, return a consistent JSON document such as:

{
  "error": {
    "code": "booking.conflict",
    "message": "This booking is no longer available."
  }
}

Do not infer the locale only from an account profile. A request can carry an Accept-Language header, and a user may switch language temporarily on a shared device. Establish a clear precedence rule: an explicit application preference, then a supported request locale, then the configured default. Restrict accepted locales to those your bundles actually support.

For a public Australian service, use Australian English as the default where that matches the product’s audience. Wording such as “authorised” and “organisation” will feel natural locally, while dates and currency should be formatted through locale-aware tools rather than assembled manually. A Melbourne customer and an overseas visitor may both use the same API, so response language should remain independent of server region.

Handling Validation And Domain Errors

Validation errors need a different shape from system failures because several fields may be wrong at once. Grails constraints already provide a foundation for field-level messages, but a custom API mapper should preserve the property name, message code and safe arguments.

[
    error: [
        code  : 'request.invalid',
        fields: [
            email: [
                [code: 'email.invalid',
                 message: 'Enter a valid email address.']
            ]
        ]
    ]
]

Avoid translating a validation message twice. Either resolve the message at the boundary and return text, or return stable codes for a client that owns presentation. Mixing both approaches can cause a mobile application to display an English server message even after the user selects another language.

Domain errors should remain meaningful after translation. “The booking overlaps an existing booking” helps a person understand the action required; “constraint violation” does not. If an error needs an internal reference, include a short support identifier rather than an exception trace. Logging can retain the full diagnostic context while the response remains concise.

For applications selling goods or services in Australia, error wording can affect customer expectations under the Australian Consumer Law. A failed payment, unavailable item or cancelled service should not imply that a customer has waived a right merely because an operation failed. Product and legal teams should review high-impact messages, particularly around refunds, subscriptions and cancellations.

Testing Locale, Security And Observability

Test translation at three levels. Unit tests should verify that each exception maps to the intended code and HTTP status. Message tests should confirm that every code has a default value and that supported locale files contain the important variants. Integration tests should send requests with different locale headers and check both response shape and rendered language.

Security testing is equally important. Error responses must not expose stack traces, SQL statements, access tokens, email addresses belonging to other users or raw responses from payment gateways. Logging should redact personal information and include a request or correlation ID. In Australia, this supports sensible handling of incidents under the Privacy Act 1988 and the Australian Privacy Principles.

Logging translated text alone can make support investigations difficult because wording varies by locale. Record the stable error code, exception class, endpoint, status and correlation ID as structured fields. Keep the user-facing message separate from those diagnostic properties, and choose log levels deliberately so an expected conflict does not fill an operations dashboard with false alarms.

A practical workflow also benefits from repeatable data access patterns. If an error depends on finding records with reusable filters, named query examples can keep service methods readable and reduce duplicated query logic. The exception translator can then handle a clear “missing” or “conflict” outcome instead of interpreting scattered persistence code.

Supporting Background Jobs And External Services

HTTP request handling is only part of a Grails system. Scheduled jobs, message consumers and asynchronous tasks can raise the same business exceptions without having a browser locale available. Pass a locale or language preference with a user-initiated job where appropriate, then store the preferred locale with the notification task.

An email, SMS or push notification should be translated when it is created, not when a worker happens to process it hours later. Store the message code and safe arguments if templates must be rendered later. This avoids sending a different language because the worker’s operating system locale changed or because a deployment moved from an Australian region to another data centre.

External services require a translation boundary of their own. A payment provider’s “do not honour” response should map to a product-level code such as payment.declined, while a timeout should map to a retryable service error. Never send provider wording directly to customers; it may contain jargon, unstable codes or sensitive information.

Consider time zones for scheduled notices. Australian systems may operate across Australian Eastern, Central and Western time zones, with daylight saving affecting some states but not others. Store times consistently, format them for the customer’s locale and avoid promising “tomorrow” unless the application knows which local date applies.

Practical Translation Checklist

Before releasing a custom exception translation layer, verify the contract shared by services, controllers, clients and support staff.

Use the following review points during development and deployment:

A good implementation remains small at the edge of the application. Controllers should call services and render the translated result, rather than containing long instanceof chains. Keep the mapping in one tested component, document the error contract for frontend developers and treat message files as versioned application code.

When the application adds a new exception, require a code, default translation, status decision, logging policy and test in the same change. This habit prevents a new failure path from quietly falling back to an unhelpful generic message. It also makes future translation work predictable as the product enters new markets.

Build the exception hierarchy around real business outcomes, centralise the translation boundary and test every supported locale through the same request paths your customers use. With that foundation in place, Grails can present clear, secure and locally appropriate feedback while preserving the technical detail your development and operations teams need.