Using Grails application events for decoupled services
Direct calls between Grails services are easy to follow, but they also spread coupling. A checkout service that synchronously reserves stock, applies loyalty points and sends a receipt can become hard to test and fragile when any dependency changes. Application events let that service announce a completed action while separate subscribers decide how to respond. The goal remains consistent business rules, with those rules placed behind clear ownership boundaries.
This pattern is valuable inside a single Grails application, where it creates a publish/subscribe boundary without forcing every listener to know its publisher. It is not, by itself, a message broker between deployed services. The workflow below uses immutable domain events, a thin publisher, focused handlers and deliberate failure policies, with practical choices for an Australian development team.
Separate business actions with in-process events
An application event should state a completed business fact, such as OrderConfirmed, rather than issue a command such as ReserveStock. The producer publishes that statement; inventory, loyalty and notification subscribers react if they care. Because the producer no longer imports those services, you can replace a handler or add another consumer without rewriting the checkout flow. The resulting dependency points from subscribers to a contract, not to the publisher's implementation.
Grails application events travel within one application process. That makes them a clean way to separate modules in a modular monolith, or to isolate work inside one Grails instance. For teams distributed across Sydney, Melbourne and Brisbane, it is still not a substitute for a shared broker when services run on different hosts or need independent scaling. Asynchronous handling can shorten a request, but it does not create a cross-process transport.
When explaining the design to colleagues who use domain-driven terminology, a broader event perspective offers another comparison between notifications, domain events and integration messages.
Model event contracts around business facts
Keep the contract small, immutable and independent of persistence. A Grails domain class or service object can carry too much behaviour; an event should contain only the data a consumer legitimately needs. This Groovy class uses a generated immutable constructor:
import groovy.transform.Immutable
import java.time.Instant
import java.util.UUID
@Immutable
class OrderConfirmed {
UUID eventId
String orderId
BigDecimal total
String currency
Instant occurredAt
int schemaVersion
}
Use a fresh UUID so a retry can be recognised, and preserve occurredAt as UTC. Australian operational views may display it in Australia/Brisbane, while Sydney and Melbourne can move between standard and daylight time; conversion belongs at the display boundary, not inside the contract. total is an Australian-dollar amount that includes the 10% GST commonly shown in Australian consumer pricing, so its meaning should be documented rather than inferred from the number.
Treat event names and fields as a published schema. Past-tense names communicate what happened, while additive fields and schemaVersion allow consumers to evolve. Avoid putting customer profiles, addresses or payment credentials in the payload. Where the Privacy Act 1988 and Australian Privacy Principles apply, collect and retain only necessary personal information and keep identifying details separate from event bodies.
Publish events after the business transaction
The producer should publish only after the order transition is valid, but publication timing affects correctness. This simplified service shows the boundary:
@Transactional
Order confirm(String orderId) {
Order order = Order.findById(orderId)
order.status = 'CONFIRMED'
order.save()
publisher.publishEvent(new OrderConfirmed(
UUID.randomUUID(), order.id, order.total,
'AUD', Instant.now(), 1
))
order
}
The injected publisher is Spring's ApplicationEventPublisher, a standard publication seam for Grails applications with event support enabled. A new subscriber therefore needs no change here, and checkout no longer imports inventory or loyalty services. Keep this class responsible for the order transition, not for branching on every downstream consumer. Some Grails versions also expose a publishEvent helper, so match the mechanism and imports to the project's event library.
Publication inside a transaction can expose a listener to data that has not committed, or let a listener failure affect the caller. For critical side effects, write an outbox record in the same database transaction and let a dispatcher publish it after commit; make both the dispatcher and consumers idempotent. Projects still being set up can use the first-time Grails tips when establishing a clean service and test layout.
Implement focused event handlers
A subscriber should represent one reaction to a business fact. Keep persistence and business rules in the service that owns them; the handler should translate an event into one service call and translate failures into useful telemetry.
import grails.events.EventHandler
import grails.events.Service
@Service
class InventoryEventHandler {
@EventHandler
void onOrderConfirmed(OrderConfirmed event) {
inventory.reserve(event.orderId, event.eventId)
}
}
Normal in-process publication may run handlers synchronously, so a slow database call can lengthen the original request. A fast handler can remain synchronous, while email, reporting or other slow work can run asynchronously when ordering and failure handling are understood. Do not use a handler as a hidden workflow engine: one event triggering a chain of many events makes traces and cause-and-effect analysis harder.
Consumer behaviour must suit local channel rules. A notification handler can update fulfilment information for an Australian shopper, but commercial email is governed by the Spam Act 2003, including consent requirements; a domain event is not permission to contact someone. Give handlers narrow scopes, avoid catching and swallowing exceptions, and let the platform's retry policy see genuine transient failures.
Practices for reliable event delivery
For production delivery, make the operating rules explicit before adding more event types:
- Store critical events in a transactional outbox so a committed order cannot lose its follow-up message.
- Make handlers idempotent with the event UUID, allowing safe retries without duplicate stock changes or emails.
- Version event schemas, publish compatibility rules and alert consumers before an older contract is retired.
- Bound retries, quarantine repeatedly failing messages and monitor publication, handling, latency and failure counts.
- Keep payloads minimal, encrypt sensitive fields where necessary and exclude personal data from routine logs.
These practices are a service-level commitment, not decoration around the event bus. Record a correlation ID with each publication, use structured logs that omit personal data, and expose metrics that distinguish an unpublished event from a handler that ran unsuccessfully. If a broker later replaces in-process delivery, the same contract and idempotency rules remain useful.
Test and document event behaviour
Test event producers and subscribers separately. A unit test can verify that confirming an order creates the expected immutable event, while a handler test can prove that a duplicated event does not reserve stock twice. An integration test should publish through the real Grails event infrastructure, proving that handler discovery and transaction behaviour work together. Tests that mock the publisher alone cannot reveal those registration problems.
Contract tests are valuable when several Grails applications consume the same messages. Agree on required fields, enum meanings and compatibility rules, then run those checks in each build. Keep test event factories that always populate IDs, timestamps and versions; otherwise fixtures can conceal missing production data. Include cases for an unknown future schema version and a handler that throws a transient exception.
Document public APIs and event schemas in different places. An HTTP-facing order service can use OpenAPI and Swagger guidance to describe request and response models, while AsyncAPI or another schema registry is usually better for broker contracts. A short ownership page should still identify the producer, consumers, retention period, retry policy and responsible team.
Build a small event-driven workflow
Start with one bounded workflow rather than converting an entire application. A practical sequence is to identify a committed business fact, introduce its event contract, move one existing side effect into a handler and add tests for success, duplicate delivery and failure. This proves the publishing boundary while retaining a straightforward rollback path.
Create a CommentAdded event in your next Grails application, publish it from a service and verify that two independent handlers react without direct calls. Once that small path is observable and repeatable, extend the pattern to stock, billing and notifications.