Building Grails async controllers with promises

Grails async controllers let an action wait for slow work without occupying a request thread for the whole wait. That matters when a controller combines local records with a partner API, payment lookup or cloud service. The action can return a promise that settles later with a value or failure, allowing Grails to complete the HTTP response at the right time.

A promise is best treated as a future value with an explicit contract. Groovy’s closure syntax makes these continuations compact, but the controller still retains validation, authorisation and response mapping while a service owns the slow operation. This boundary keeps request handling readable, makes timeout behaviour explicit and gives tests a stable result to await.

When a promise belongs in an action

Not every slow method needs a promise. Wrapping a CPU-heavy transformation does not make it non-blocking, and calling Future.get() inside an action recreates the original bottleneck. Promises fit operations that genuinely involve waiting: HTTP calls, message brokers, remote storage or database drivers with an asynchronous API.

The request thread can then validate input and attach continuations before returning control to the container. When the operation completes, the promise runs the next step with the result; when it fails, the rejection travels through the same chain. The action should never spin in a loop while waiting for that notification.

The example uses grails.async.Promise, available in current Grails applications. Older installations can expose the class through a version-specific package, so check the API shipped with your project rather than copying an obsolete import. Promise implementations commonly provide then, catch, complete and fail, although exact signatures can vary between Grails lines.

Keeping controller and service boundaries clear

A controller should translate an HTTP request into a service call and translate the service result into a view, JSON document or redirect. It should not contain the mechanics of retrying a remote API or parsing a vendor response. This keeps the action small enough to review and lets one service support controllers, scheduled jobs and command-line tools.

The service can adapt a Java CompletableFuture to Grails’ promise contract. The following example assumes client.fetchAsync(city) returns a future containing already mapped event records:

import grails.async.Promise

Promise<List<Event>> findEvents(String city) {
    Promise<List<Event>> result = new Promise<>()
    client.fetchAsync(city).whenComplete { events, failure ->
        if (failure != null) {
            result.fail(failure)
        } else {
            result.complete(events)
        }
    }
    return result
}

The action attaches rendering to fulfilment and error handling to rejection. If the client already returns a Grails promise, the service can return it directly and remove the adapter:

def index() {
    Promise<List<Event>> lookup = eventService.findEvents(params.city)

    lookup.then { List<Event> events ->
        render(view: 'index', model: [events: events])
    }.catch { Throwable failure ->
        log.error('Event lookup failed', failure)
        response.status = 500
        render(view: 'error')
    }
}

The sample targets independent workshops, performances and venue sessions in Sydney, Melbourne, Brisbane, Perth and Adelaide. For drivers in Sydney and Melbourne, optional parking guidance reflects the common use of pay-by-phone parking apps; any paid link needs clear consent and commercial terms.

Composing dependent and parallel work

Promise chains are clearest when each step performs one transformation. The result of then becomes the input to the next step, which removes deeply nested closures and makes the data flow visible. Keep request objects and mutable session state out of these transformations; pass plain values such as identifiers, filters and locale settings.

A page of event results might first enrich each item with travel advice and then create the view model:

Promise<EventPage> page = eventService.findEvents(city)
    .then { List<Event> events -> enrichWithTravelAdvice(events) }
    .then { List<Event> events -> new EventPage(city: city, events: events) }

Independent remote calls can run concurrently with the promise combiner available in the installed Grails version. Resolve the combined future only after every required branch succeeds, and define explicitly what happens when one optional branch fails. Never use a blocking get() call merely to make parallel branches look simpler; it removes the advantage of the asynchronous controller.

Because Australia spans several time zones, store event instants in UTC and convert them for display. New South Wales, Victoria and South Australia observe daylight saving, while Queensland does not, so a single server offset will produce incorrect local start times even when the underlying query is correct.

Handling rejection and deadlines

Every remote operation needs a deadline. A client-level timeout limits one connection attempt, while an overall request deadline prevents repeated retries from extending the user’s wait indefinitely. Apply the timeout before converting the future into a Grails promise so a stalled upstream cannot leave the action pending forever.

Translate failures into useful HTTP outcomes at the controller boundary. Invalid location data can produce a 400 response, an unavailable partner service can produce 503, and an exhausted upstream deadline can produce 504. Keep vendor messages and stack traces in logs, not in the browser, and give operators a request identifier for tracing the failed branch.

Retries suit idempotent reads such as fetching event availability, but each retry needs a limit and backoff. Do not automatically repeat a payment or booking operation unless the provider supplies an idempotency key. For a useful fallback, serve a short-lived cached event list with its age clearly displayed rather than showing a blank page after the provider fails.

Protecting data and access controls

Security checks must finish before work leaves the request path, or the worker must receive an explicit, validated authorisation decision. A callback running on another thread may not have the same request, session or security context available. Avoid reading the logged-in user from ambient thread state inside the promise; carry the required identity and apply service-level access rules to the fetched records.

Keep upstream URLs fixed or restricted to trusted hosts. If city input influences a URL, validate it against an allowlist and prevent redirects to internal addresses. Parameterise database filters, avoid string-built queries and keep vendor credentials in deployment configuration rather than source control. These controls matter because asynchronous work continues after the original browser request has disconnected.

Centralising persistence rules in reusable named queries prevents different controller paths from applying different filters. Named queries remain valuable for consistency and parameter safety, but they do not automatically make GORM non-blocking. Under a conventional synchronous GORM setup, the query still consumes a thread; use an appropriate executor or an asynchronous database driver, and do not assume transaction context transfers to a new worker.

For an Australian launch, personal details in attendee records should be handled in line with the Privacy Act 1988 and the Australian Privacy Principles where they apply. If the application sends booking reminders or promotional messages by email or SMS, account for the Spam Act 2003, including consent, identification and unsubscribe requirements. Legal obligations and business rules can change, so confirm the current requirements before launch.

Testing promise-based actions

Test the service contract by awaiting its promise and asserting both the fulfilled value and any failure. A CountDownLatch can provide a bounded wait in a Spock unit test when the installed promise implementation does not expose a familiar future-style get method. Give the test a short deadline and fail clearly when it expires instead of waiting indefinitely.

Inject a fake client that completes immediately, delays beyond the timeout, completes with malformed data and rejects with a representative exception. Each case should verify whether the service resolves or rejects and whether the controller maps that state to the intended status and body. Integration tests can call the action through Grails’ test client and assert the eventual HTTP response without inspecting private callbacks.

Promise-based code benefits from the same deliberate practice as algorithmic exercises. A lesson on learning sorting algorithms illustrates how small, observable steps make behaviour easier to reason about; the same discipline applies when arranging service data, ordering results and proving that every continuation runs in the expected order.

Avoid sleeps and arbitrary polling delays. Use explicit completion signals, controllable clocks and deterministic fakes so a slow CI worker does not create flaky results. Keep the test timeout shorter than the build timeout, which makes a stalled promise visible as a useful failure rather than a mysterious job timeout.

Packaging and operating async workflows

Deploying the controller is only part of the job. Configure outbound HTTP connection pools, maximum connections per route, worker counts and request deadlines for the expected traffic. A pool that is too small serialises calls; an unbounded pool can exhaust memory and upstream capacity. Set these values in environment-specific configuration and document safe limits for operators.

Track promise age, fulfilment and rejection counts, plus latency and timeout rates for each remote dependency. Correlate logs with a request identifier, and monitor the percentage of requests waiting beyond the user-facing budget. These measurements reveal whether a slow application is caused by Grails thread pressure, a partner API or a database connection pool, rather than leaving the team to guess from a generic 500 response.

Australian customers and government buyers may expect workloads to remain in the country or a clearly approved region. AWS Sydney, Azure Australia East and Google Cloud Melbourne provide Australian hosting choices, but the selected database, logs and object storage must also follow the required data-residency policy. For a consumer event catalogue, show prices in Australian dollars and apply the 10 per cent GST where each ticket is taxable, while retaining the merchant’s local market context.

Roll out new promise chains with metrics and a feature flag. Compare latency and error rates with the previous synchronous path, keep a rapid rollback path, and avoid deploying an untested combination of Grails, Groovy and library versions to every instance at once. Observability and controlled release protect the asynchronous workflow after it leaves the tutorial.


Build this pattern in a small Grails Example project before adding a live partner feed. Start with one controller action, one promise-returning service and one fake client, then add a timeout, a rejected-path test and a latency metric. That sequence keeps the first implementation observable and gives the team a reliable base for the next feature.

Follow Grails Example for step-by-step lessons on Grails controllers, Groovy services, secure data access and deployment, and turn the scenario into your own promise-based action today.