Summary
Spring Data JDBC does not automatically map composite ID components to their own table columns. When a composite key record (ServiceId) is used as the @Id, Spring Data JDBC expects the table to have a single column named after the ID property (or a custom column mapping). It does not introspect the composite record’s fields and map them individually unless you provide explicit column mappings.
The generated SQL shows:
INSERT INTO "service" ("id", "name", "version") VALUES (?, ?, ?)
But the expected SQL should be:
INSERT INTO "service" ("tenant_id", "id", "name", "version") VALUES (?, ?, ?, ?)
Root Cause
The root cause is that Spring Data JDBC’s entity mapping does not natively support composite identifiers without explicit column mapping declarations. When you define:
@Id
private final ServiceId id;
Spring Data JDBC treats ServiceId as a single identifier value. It generates the column name based on the @Id property name (id), not the fields inside ServiceId. There is no default mechanism to decompose the composite record into its constituent columns.
- The
ServiceIdrecord implementsIdentifier, which is correct for JMolecules metadata. - Spring Data JDBC 4.x still requires explicit
@Columnor naming strategy configuration for composite key fields. - Without mapping directives, the framework serializes the entire
ServiceIdobject into a single column bind.
Why This Happens in Real Systems
In real production systems, composite keys are common — especially when modeling tenant-scoped data where tenant_id is part of the primary key. Teams often reach for composite IDs because:
- Multi-tenant architectures require tenant isolation at the row level.
- Legacy database schemas use composite primary keys.
- Distributed ID strategies embed tenant context in the entity identity.
Spring Data JDBC’s mapping model, inherited from its design philosophy, favors simple value objects for IDs. It does not perform reflection-based decomposition of composite records into column mappings the way JPA does with @EmbeddedId or @AttributeOverride.
Key takeaway: Spring Data JDBC expects either a single-column ID or an explicit mapping strategy for each component of a composite key.
Real-World Impact
- Data corruption or silent failures: Inserts silently drop composite key components, writing only the object reference (or failing if the column type doesn’t match).
- Hard-to-debug production incidents: The INSERT appears successful but rows lack tenant context, leading to cross-tenant data leakage.
- Massive rework: Teams discover this late in migration, forcing schema changes or rewriting repositories.
Symptoms in production:
INSERTstatements missing expected columnsDuplicate entryerrors because tenant context is lost- Data appearing under wrong tenant after queries by
ServiceId
Example or Code (if necessary and relevant)
Here is the minimal reproduction showing the incorrect SQL generation:
// Entity with composite ID
@Getter
@AggregateRoot
@AllArgsConstructor
public class Service {
@Id
private final ServiceId id;
private String name;
@Version
private Integer version;
}
record ServiceId(UUID tenantId, UUID id) implements Identifier {
}
// Repository
public interface ServiceRepo extends ListCrudRepository {
}
// Save call - generates INSERT with single "id" column
repo.save(new Service(
new ServiceId(UUID.randomUUID(), UUID.randomUUID()),
"My Service",
null
));
How Senior Engineers Fix It
There are three approaches, each with trade-offs:
-
Use a custom
SqlGeneratedValuewith column mappings — Map each component explicitly. -
Override the
WriteStatementBuilderto decompose the composite ID. -
Switch to Spring Data JDBC’s
@Mappedpattern or write a customDomainClassConverter.
The most common and clean fix is to decompose the composite ID into explicit @Column-mapped fields on the entity and treat the composite as a value object separately:
@Getter
@AggregateRoot
@AllArgsConstructor
public class Service {
@Id
@Column("tenant_id")
private final UUID tenantId;
@Id
@Column("id")
private final UUID id;
private String name;
@Version
private Integer version;
}
And keep a separate DTO/record for domain-level composite identity:
public record ServiceId(UUID tenantId, UUID id) implements Identifier {
}
Alternatively, use Spring Data JDBC’s @Id on each field and configure a CompositeId strategy in the repository metadata. Senior engineers also write integration tests that assert the generated SQL before deploying schema changes.
Why Juniors Miss It
- Assumption that
Identifierimplementation is enough — ImplementingIdentifiersignals intent but does not configure mapping. - No compile-time error — The code compiles and runs; the bug is in the generated SQL.
- JPA mental model — Juniors coming from JPA expect
@EmbeddedIdor@AttributeOverrideto work the same way in JDBC. - Tutorials rarely cover composite IDs — Most Spring Data JDBC examples use single-column IDs, so the pattern is unfamiliar.
- The error appears in logs, not exceptions — The wrong SQL is logged; if logs aren’t monitored, the issue silently persists.