Building Grails custom constraints for unique field validation

When developers first reach for a constraint to stop duplicate email addresses, the standard unique: true setting in a Grails domain class feels like the obvious choice. It handles a lot of everyday cases, particularly when a single column must stay free of duplicates and the validation only needs to fire on save. As soon as a real application starts running in production though, that built-in behaviour can quietly let the wrong data through, especially under concurrent writes or when the uniqueness rule depends on more than one field. That is where a hand-rolled validator starts to make sense.

A custom constraint gives you the kind of control the framework cannot infer from a domain declaration alone. You can encode business rules that span several properties, query with HQL or criteria, and surface validation messages that actually mean something to the end user. Many teams in Melbourne and Sydney that maintain Grails apps for government agencies have moved this kind of logic out of the controller and into reusable validator classes, partly so the same rule can be applied across dozens of domains without copy-pasting code.

When the built-in unique constraint is not enough

The default validator works well for simple cases such as a username column on a User domain class, where a query along the lines of User.findByUsername(fieldValue) is enough to confirm uniqueness. Once uniqueness has to consider the active state of the record, an accountId from another service, or a soft-delete flag, the built-in version runs out of room. It only sees one field at a time and does not let you express relationships such as "email must be unique for the currently active subscription, but a cancelled subscription can keep the same email".

Another common source of pain in Australian projects is data residency. Several banks in Sydney and Brisbane run Grails back-ends that must keep customer records inside specific regions, and the uniqueness check has to take that into account. If two records look identical to a simple findBy query but live in different data centres, the framework will still flag the second one as a duplicate. Custom constraints let you incorporate the residency column directly into the lookup so the validation matches the business intent.

There is also a subtle timing problem. The built-in constraint runs inside the transaction that is about to write the record, which means two simultaneous requests can both pass validation and only one will succeed at flush time. Surfacing that as a normal validation error to the user is messy, and it is one of the reasons teams reach for a more deliberate implementation.

Designing a reusable custom constraint class

A custom constraint in Grails is just a class that follows a small contract. It exposes a property of type Boolean supportsClass, and a method with the signature Boolean validate(Object target, Object fieldValue, ConstraintErrorHandler errorHandler). The framework discovers these classes during application bootstrap and registers them automatically, so there is no XML configuration to worry about. Once the class is on the classpath, it can be referenced from any domain class by its simple name.

The natural place to put validators like this is inside the grails-app/utils directory, where Groovy will pick them up as part of the default package scan. For a rule that checks whether a value already exists in the database for a specific tenant, the class can accept a constructor parameter or read the field name from a constraint parameter block. Keeping the lookup query inside the validator itself, rather than passing a service into the domain class, keeps the domain layer free of injected collaborators and makes the constraint easier to unit test in isolation.

One pattern that has worked well across teams in Adelaide and Perth is to expose the property being validated through a small DSL, so the constraint can be applied like emailAddress(nullable: true, uniquePerTenant: true) instead of inventing a brand new annotation for every variant. That keeps the call sites tidy and reads naturally during code review, even when the rule itself is fairly involved.

Wiring the constraint into a domain class

With the validator class compiled and on the classpath, attaching it to a property is a matter of declaring it inside the static constraints block. The key matches the simple class name, and any parameters you want to pass go alongside. A typical declaration looks like abn uniquePerTenant: ['tenantIdField': 'organisationId'], where the array or map describes the supporting column that needs to be part of the lookup. Because the constraint is registered globally, the same domain class can also reuse the framework's built-ins without conflict.

When you need to take the rule further, such as ignoring the current record during an update so a user can save their own profile without tripping the validator, the class can read the identifier from the target argument and exclude it from the query. This is a frequent ask from teams building internal tools for Australian universities, where staff records are often edited in place and would otherwise keep blocking themselves. Handling the self-check inside the validator avoids leaking persistence concerns into the domain class itself.

Domain classes can stack multiple custom constraints on the same property if the rules overlap. It is common to see one validator that checks the database for duplicates and another that confirms the value matches a national format, both firing in sequence. The order in the constraints block is the order they run, and the validation phase halts on the first failure unless validate: is set explicitly to keep going.

Handling concurrent writes and database-level checks

Even a well-written custom constraint can be defeated when two requests arrive at the same moment. The first transaction reads the table, sees no duplicate, and proceeds to insert, while the second transaction does the same and only finds out about the conflict when the database raises a uniqueness violation. Catching that exception and turning it into a validation error keeps the user experience consistent and avoids raw stack traces leaking into form responses.

For high-volume tables, such as the customer ledgers maintained by fintechs around Barangaroo, an extra database-level guard is worth adding. A unique index on the relevant columns gives the engine a chance to reject duplicates before the application ever sees them, and the validator can be relaxed slightly to handle the rare race. Logging the conflict, returning a friendly message, and letting the user retry is usually enough to keep the support team out of the loop.

Some teams also schedule a nightly reconciliation job that scans the table for duplicates the validator may have let through. AEST or AEDT timezones can complicate the schedule depending on daylight saving, so it is worth picking a fixed UTC offset for the cron expression and only translating back to local time for the log lines. That way the job runs at the same wall-clock minute every day of the year, which makes auditing easier for compliance staff.

Testing custom constraints with Spock

Spock specifications are a natural fit for this kind of code because the validator behaves as a pure function over its inputs. A typical specification creates an instance with def validator = new UniquePerTenantConstraint(), sets any constructor parameters, and then walks through the cases that matter: a clean record passes, a duplicate record fails, a record belonging to a different tenant is treated as unique, and updating the same record does not flag itself. Each example reads almost like a sentence, which makes the suite easy to maintain as the rules evolve.

When the validator talks to the database, the test should use TestSupport or an in-memory H2 instance rather than mocking the data access layer. Mocking tends to drift away from the real query the validator will execute, which is exactly the behaviour you are trying to pin down. A real round-trip with a known dataset gives a much higher signal and catches issues such as case sensitivity on a String column, which can be a surprise in PostgreSQL compared with H2.

For the sprinkled-in integration tests, the Grails docs and a maintained set of code examples walk through the full constraint-to-domain wiring, including the moment the framework discovers a validator class. Reading through those keeps the test setup consistent with what production code will actually run.

Recommendations for clean custom constraint design

The constraint approach described above slots neatly into any Grails application that already uses standard validators, and it scales from small admin tools to enterprise platforms. Once the pattern is in place, the same techniques carry over to other rules such as cross-property uniqueness, conditional requirements, and external lookups. The Grails Example site includes a deeper walk-through of building a complete auth flow with stateless authentication with JWT for teams ready to keep going.