Mapping composite keys in Grails GORM

A Grails domain class often carries a single Long id, even when the business identifies a record through several fields. Mapping composite keys in Grails GORM changes that convention: the selected properties become one database primary key rather than separate columns that merely happen to look unique. Hibernate then treats the group as the persistent identity used by inserts, updates and deletes.

This is useful when the database already has a trusted key, such as a tenant code plus invoice number. It avoids inventing another identifier and translating between the application’s key and a synthetic column. The trade-off is that every query and data operation must respect the full key, and database constraints become part of the application’s correctness story.

The mapping DSL is compact, but the design deserves more care than copying a snippet. A composite key should describe identity, not a convenient filter. The following sections cover declaration, validation, querying, migration and the decisions worth testing before a Grails application reaches an Australian production environment.

How a multi-column key changes identity

A composite, or compound, primary key consists of two or more columns that jointly distinguish a row. A GORM domain class using one should implement Serializable, omit the usual Long id, and list the key properties in its mapping. The relationship is much closer to a compound primary key in SQL than to several ordinary fields that happen to be unique.

Australian examples make the distinction concrete. An invoice may be numbered within a tenant, while a .com.au customer portal may use a tenant code plus a public support reference. An AFL or NRL fixture can be identified by competition, season and round. None of these examples proves that a compound key is correct; the database rules and business guarantees decide.

Keep a surrogate key when the business key is mutable, optional, reused after deletion, or likely to change. A compound key is stronger when every component is mandatory and remains stable for the life of the record. A broader guide can serve as a companion when you compare this approach with identity strategies used outside Grails.

Declaring and validating the identifier

The standard GORM mapping DSL uses compositeKey. Here is a small domain class for invoices scoped to an Australian tenant:

class CustomerInvoice implements Serializable {

    private static final long serialVersionUID = 1L

    String tenantCode
    String invoiceNumber
    BigDecimal total
    Date issuedAt

    static mapping = {
        table 'customer_invoice'
        compositeKey ['tenantCode', 'invoiceNumber']
    }

    static constraints = {
        tenantCode(nullable: false, blank: false, maxSize: 32)
        invoiceNumber(nullable: false, blank: false, maxSize: 40)
        total(nullable: false, min: 0.0G)
        issuedAt(nullable: false)
    }
}

That declaration tells Hibernate to use both columns in the primary key. tenantCode is not a relation and invoiceNumber is not merely an ordinary unique field; together they form the identifier. The table name is explicit, so a later migration has a stable target. For a Hibernate-first design, an embeddable identifier using @EmbeddedId or @IdClass expresses the same database concept, but the annotation package must match your Grails and Hibernate dependencies.

The same reasoning applies to a wagering-free games catalogue, where a provider and catalogue identifier would need a deliberate database key instead of an assumed browser-generated value. The subject matter is not important. The important question is whether the proposed columns can always and uniquely identify a row.

Key properties still need normal GORM constraints. Add nullable: false and blank: false to every component, set sensible sizes, and align those limits with the DDL. Database primary-key columns reject nulls, but application validation gives callers clearer errors before a transaction reaches the database. Do not add an automatic id alongside compositeKey; that creates two competing notions of identity.

Saving and retrieving composite records

Creating a record follows the familiar GORM path. Construct the domain object with its key properties, call save, and flush when you need the database constraint checked immediately. The key values identify the row, but only the primary-key constraint proves that they are unique.

def invoice = new CustomerInvoice(
    tenantCode: 'ATLAS',
    invoiceNumber: 'INV-2026-00417',
    total: 1250.00G,
    issuedAt: new Date()
).save(flush: true)

Check the validation result before continuing. A duplicate key normally reaches the database as a constraint violation rather than an ordinary validation error, so error handling should distinguish invalid input from an attempted duplicate insert.

Use a criteria query when you need to locate a row by the full key:

def matches = CustomerInvoice.createCriteria().list {
    eq('tenantCode', 'ATLAS')
    eq('invoiceNumber', 'INV-2026-00417')
}

A simple id lookup has no equivalent here because there is no single identifier property. The query must constrain every component that defines identity. Results should normally be zero or one for a complete primary-key lookup; more than one indicates a schema or data problem rather than a reason to select whichever row arrived first.

Updates and deletes must retain the same key. Treat key fields as immutable after insertion: changing one is a change of identity, not an ordinary property update. If the business must re-key a record, use an explicit migration or controlled data operation. Ordinary save, update and remove calls can otherwise appear to work while missing the intended row.

Keep timestamp handling unambiguous for Australian users. Store instants in UTC or an explicit IANA zone such as Australia/Sydney, and render dates for people in DD/MM/YYYY form where that is the local convention. Daylight-saving changes in New South Wales, Victoria, South Australia and Western Australia make a hard-coded offset a poor choice for recurring billing schedules.

A practical checklist for Australian deployments

An auto-export is useful for a fresh database, but an established system needs a reviewed migration. With the database migration tooling installed, grails dbmigrate can apply a generated change, provided the change and rollback assumptions are checked before execution. Index the key in the intended order and keep its total width modest. MySQL and PostgreSQL have different limits and behaviours around long character keys, while tenant-prefixed queries benefit from an index that matches their access pattern. The compound primary key already creates a unique index; a separate index may still help a frequently searched second column.

Plan for the Australian market rather than copying a generic SaaS example. Australian financial years end on 30 June, so EOFY reporting can turn a simple invoice number into a tenant-scoped sequence. Amounts are in AUD and often need 10% GST recorded separately. Hosting adds another local decision: teams commonly work from Sydney or Melbourne in hybrid roles, with an occasional “arvo” handover, and may deploy into the ap-southeast-2 region. That location does not remove cross-region latency for users in Adelaide or Perth. Release automation can enforce the same checks: the guide on application lifecycle scripts shows how custom lifecycle tasks fit around a Grails build.

Before merging a compound-key change, check the design itself:

During testing, focus on behaviour that can silently corrupt data:

These checks are especially useful around EOFY, when invoice sequences and reporting boundaries can expose assumptions that ordinary sample data hides.

Put the key into practice

Choose one table in your current backlog whose business identity is already split across columns, and model it as a small Grails domain class. Start with the compositeKey mapping, add strict constraints, and write a criteria query that retrieves a known row. Then attempt to insert the same key twice in a test database and confirm that the database rejects it.

Make that exercise the first concrete step in your next deployment cycle: agree on the key with the people who own the data, capture it in a migration, and rehearse the lookup, update and failure paths against a production-like dataset before the release reaches customers.