Building a Reusable Grails Plugin for Shared Domain Logic
Most Grails projects start small: a single team sketching a web app, a handful of GORM domain classes, services, and a controller layer wired by convention. The moment a second application needs the same Customer, Invoice, or business rule for Australian GST, duplication creeps in. A plugin offers a clean way out, packaging the shared code once and letting downstream apps depend on it like any other library.
Across Sydney, Melbourne and Brisbane, Grails has a quieter footprint than it did a decade ago, yet it persists in long-running enterprise backends and government-adjacent systems that favour the JVM. Atlassian's Sydney engineering culture has normalised extracting shared concerns into reusable artefacts, and many Australian teams now follow the same playbook. The framework's plugin system was built precisely for this: instead of copying src/groovy snippets between repositories, publish one artifact and let Gradle resolve it.
This walkthrough covers the full path from a blank plugin skeleton to a published artifact consumed by a real application, touching on GORM modelling, lifecycle hooks, packaging conventions and testing strategies with patterns that hold up across multiple consumers.
Anatomy of a Grails Plugin
A Grails plugin is a normal project with one extra descriptor. At the root sits plugin.groovy, a small Groovy script declaring the plugin's title, version, supported Grails versions, and required dependencies. Beneath it the standard Maven-style layout applies: grails-app/domain, grails-app/services, grails-app/controllers, plus src/main/groovy for shared utilities. Anything that belongs in a regular application can live inside a plugin, which is what makes the mechanism so flexible.
The descriptor also doubles as the place to register authors and supply a short description. For teams split between Adelaide and Perth, this metadata becomes useful when somebody new joins and needs to discover which internal plugin owns which shared entity. A well-formed descriptor reads like a tiny README and travels with the artifact to every Maven repository or local ~/.m2 cache.
What separates a plugin from a copy-paste module is dependency inversion. A plugin declares what it needs and consumers declare what they pull in. Resist the temptation to introduce reverse dependencies back into the consuming app, because once those creep in the plugin stops being a black box. If a consumer needs additional behaviour, the right move is an event hook or a service override rather than editing the plugin.
Modelling Domain Classes that Travel Well
The hardest decision when extracting shared domain logic is what belongs in base classes and what stays application-specific. A Customer with firstName, lastName, emailAddress and dateOfBirth is a strong candidate for a shared plugin, because those attributes rarely change between Australian consumer-facing apps. A ShoppingCart tied to a specific UI workflow is not, because checkout rules diverge from project to project.
Practical extraction usually starts with abstract base classes. You write abstract class BaseCustomer inside the plugin, mark it abstract, and let each consumer subclass it with app-specific associations such as hasMany relationships to local domain classes. GORM supports this pattern cleanly, because persistence metadata on the base class is inherited by subclasses. The course-outline walks through real domain refactors from monolithic Grails apps.
Another useful technique is the trait or mixin. Groovy traits can carry instance methods, abstract methods and properties with default values, making them ideal for behaviours such as auditable, softDelete or addressable. Drop a trait into a shared plugin and any consumer applies it with a single implements Auditable clause. Traits play well with GORM as long as you avoid static mapping = blocks inside them; mappings belong on the concrete class.
Equally important is being deliberate about database vendor assumptions. A plugin that uses Postgres-specific column types will trip up any consumer on Oracle or SQL Server, and that mismatch is painful to diagnose later. Stick to portable GORM features and reserve dialect-specific tweaks for the consuming application, where the database choice is already known.
Hooks and Lifecycle Integration
Plugins expose a small but powerful set of lifecycle hooks through plugin.groovy. The most common are doWithWebDescriptor, doWithSpring, and doWithApplicationContext. Each fires at a well-defined moment during application startup, letting a plugin contribute beans, URL mappings, or service overrides without the consuming app lifting a finger. For shared domain logic, doWithSpring is usually where the action lives: register extra Hibernate event listeners, custom validators, or domain decorators.
Event-based extension goes further. Grails publishes events such as onConfigChange, onStartup and onShutdown, and a plugin can subscribe to any of them. This pattern is invaluable when the shared domain layer needs to react to configuration updates from the host, for instance when a multi-tenant SaaS running in the AWS Sydney region swaps its tenant context at runtime. A clean listener wired through the plugin's event bus keeps the consumer unaware of the implementation detail.
AST transformations deserve a separate mention. Groovy's compile-time metaprogramming lets a plugin inject methods, properties, or annotations into domain classes at build time. A plugin can offer a @Auditable annotation that, when applied to a domain class, automatically generates lastModified, createdBy and createdDate properties plus the corresponding Hibernate event wiring. The consumer annotates a class and the rest is magic. Be careful with ordering: AST transformations run during compilation, and consumers sometimes need to clear Gradle caches after a plugin upgrade before everything resolves cleanly.
When something inside the plugin consumes more startup time than expected, profiling the application reveals whether the bottleneck sits inside a hook or in GORM bootstrap itself.
Packaging, Versioning and Publishing
Once the plugin code stabilises, packaging is largely a matter of running grails package-plugin or the equivalent Gradle task. The build produces a .zip archive plus a POM file describing coordinates, dependencies, and the Grails version range. Pick a sensible groupId, for example au.com.yourorg.grails, because that prefix travels with the artifact forever and changing it later means renaming across every consumer.
Semantic versioning is the safest discipline for shared domain logic, because downstream teams need a predictable signal about breaking changes. A bump from 1.4.2 to 1.5.0 should mean new features; a bump to 2.0.0 should mean that domain classes have been renamed or their constraints have shifted. Australian teams running continuous delivery pipelines usually wire these bumps into branch protection rules, so a major version change requires explicit approval before reaching a shared snapshot repository.
Publishing destinations vary. A small in-house setup can rely on a local Artifactory or Nexus hosted in a Sydney data centre, which is often enough for plugin distribution between a handful of internal apps. Larger organisations push to Maven Central via Sonatype's OSSRH, which requires a Jira ticket and a few days of manual review the first time around. Either way, signing artifacts with GPG and keeping the private key offline is non-negotiable, especially for plugins consumed by systems where the Australian Signals Directorate's Essential Eight baseline is taken seriously.
Do not forget to publish sources and javadoc alongside the binary jar. When a developer in Melbourne hits a stack trace originating from a plugin's internal class, having the sources attached in their IDE saves a full round trip to a separate documentation site.
Testing and Consuming the Shared Plugin
A plugin without tests is a liability. Grails provides a grails test-app mode that runs the plugin in isolation, with an in-memory H2 database and a minimal Spring context. Spock specifications written against this mode exercise GORM mappings, custom validators, and lifecycle hooks without spinning up an entire application. Aim for high coverage on the domain classes themselves, because those tend to be reused more heavily than any service code.
Integration tests are where shared plugins earn their keep. Spin up a tiny stub application inside the test sources, install the plugin, and assert that the domain classes round-trip correctly against the configured dataSource. This pattern catches subtle issues such as misconfigured cascade settings or a missing lazy: false fetch mode that only becomes obvious when a real Hibernate session is involved.
On the consumer side, adding the plugin to a downstream Grails application means editing build.gradle to include the new dependency and refreshing the project. Gradle resolves the artifact from the configured repositories, copies it into the application's classpath, and runs the plugin's hooks during boot. If the plugin ships with template scaffolding or new tag libraries, those appear automatically. A clean separation between plugin code and consumer code makes upgrades trivial: the consumer bumps the version number and reruns the build.
For Australian teams in regulated industries, such as fintechs in Melbourne or health platforms tied to My Health Record integrations, this separation also simplifies audit trails. Reviewers can isolate changes to the shared plugin in their own pull request, separate from the consumer application's diff, which keeps compliance sign-off focused.
A reusable Grails plugin is one of the most underrated productivity tools in the JVM ecosystem. Start small, pick a domain concept that genuinely repeats across your portfolio, and treat the plugin's first release as a contract you intend to honour. Once it is in place, every new Grails application that needs Customer, Invoice, or whatever shared entity you have extracted starts with a much shorter onboarding path. Subscribe to the Grails Example newsletter or enrol in the structured lessons to keep building on these foundations as the framework continues to evolve.