
Spring Data Jdbc
- 183 installs
- 105 repo stars
- Updated July 27, 2026
- amplicode/spring-skills
spring-data-jdbc is an agent skill that detects and explains Spring Data JDBC aggregate-root and owned-entity conventions via MCP entity inspection.
About
spring-data-jdbc is an agent skill for solo builders maintaining Spring Data JDBC domains aligned with DDD aggregates. It walks a fixed multi-step ritual: inventory entities, classify aggregate roots versus owned children using MCP tooling, and reconcile @MappedCollection, @Embedded, and AggregateReference patterns against what the database mapping actually expects. The skill treats get_jdbc_entity_details output as authoritative—roots have null aggregateRootFqn, children point to a named root, and referencedBy surfaces incoming links. Install it when you are extending a Java API or modular monolith and need the agent to stop guessing repository boundaries or flattening embeddables into the wrong lifecycle. It suits intermediate-to-advanced backends where JDBC (not JPA) is the persistence choice and Amplicode MCP is available in the workspace.
- Ordered substeps 1.1 → 1.2 → 1.3 → 1.4 → 1.5 for detecting aggregate conventions without skipping
- MCP get_jdbc_entity_details as source of truth for root vs owned child vs AggregateReference
- Distinguishes @Embedded value objects from owned entities with separate tables
- list_all_domain_entities inventory step for aggregate discovery
- Cross-aggregate links documented only via AggregateReference, never direct object graphs
Spring Data Jdbc by the numbers
- 183 all-time installs (skills.sh)
- Ranked #31 of 89 Java & JVM skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/amplicode/spring-skills --skill spring-data-jdbcAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 183 |
|---|---|
| repo stars | ★ 105 |
| Last updated | July 27, 2026 |
| Repository | amplicode/spring-skills ↗ |
What it does
Audit and document Spring Data JDBC DDD aggregate roots, owned children, and AggregateReference links using MCP entity details before refactoring repositories.
Who is it for?
Best when you use Amplicode MCP on Spring Data JDBC codebases and need consistent aggregate documentation before merges.
Skip if: Greenfield projects without JDBC entities, JPA-only stacks, or repos with no MCP Spring domain tools wired up.
When should I use this skill?
Working on Spring Data JDBC domains, aggregate boundaries, @MappedCollection/@Embedded, or when MCP JDBC entity detail tools are in play.
What you get
You get an ordered aggregate inventory and role classification per entity aligned with MCP details, ready for repository and schema changes.
- Aggregate inventory and root/child classification
- Documented cross-aggregate AggregateReference map
By the numbers
- 5 ordered substeps starting at 1.1 through 1.5
Files
Detection guard
Before applying any rule from this skill, confirm the target file imports from org.springframework.data.relational.core.mapping / org.springframework.data.annotation. If you see jakarta.persistence.* imports, stop and switch to the spring-data-jpa skill — the two stacks are not interchangeable and patterns from JPA (HibernateProxy, @ManyToOne, @OneToMany, @JoinColumn, FetchType) do not apply here.
Harness compatibility
This skill is designed to work across multiple agent runtimes (Claude Code, Codex, OpenCode). Two harness-specific primitives are referenced by name in this skill; treat them as preferred-but-optional and degrade gracefully:
- `AskUserQuestion` (Claude Code structured prompt with multiple-choice options). When the runtime supports it, use it for Step 1.4 of every conventions file — the JSON examples in those files map to the tool's expected payload. When the runtime does not support it (Codex, OpenCode, plain CLI), ask exactly the same questions inline in the conversation: render each question as a short paragraph followed by a numbered or bulleted list of options, mark the recommended option with
(Recommended), and accept either the option label or its number in the user's reply. The decision tree is identical; only the rendering changes. - "Memory" — Claude Code's persistent file-based auto-memory. References like "check memory for previously saved conventions" mean: if you have access to Claude Code's auto-memory, look there first. In Codex/OpenCode (and any runtime without persistent memory), substitute "scan earlier turns of this conversation" — if conventions were resolved in the same session, reuse them; if the session is fresh, just run Step 1 from scratch.
Do not refuse a task because one of these primitives is missing. Substitute the inline equivalent and announce the substitution once at the start of the task ("AskUserQuestion not available in this runtime — asking inline" / "no persistent memory in this runtime — detecting conventions from scratch").
---
Preflight: Spring MCP
This skill is part of the Spring Agent Toolkit and is designed to work with the Spring MCP server (provided by the Amplicode IntelliJ plugin). Before doing anything else, check your tool list for any Spring MCP tool — they are exposed under the amplicode MCP server (e.g. get_jdbc_entity_details, list_all_domain_entities, list_entity_repositories); harnesses that flatten MCP tools into the tool list use the mcp__amplicode__ prefix on the same names.
- If at least one Amplicode tool is available — MCP is connected. Proceed with the skill below.
- If none are available — stop and invoke the `amplicode-install` skill (bundled with the Spring Agent Toolkit). It installs the Amplicode plugin and walks the user through the «Настроить Spring Agent» welcome-screen button + MCP-client restart. After it completes, the MCP tools become available — resume this skill.
- If
amplicode-installis not registered in your skill list or the user declines to install (e.g. they are running in a harness without MCP support such as a CI sandbox), continue with the file-read fallbacks described in the next section so the task is not blocked.
---
MCP availability and fallbacks
This skill prefers the Spring MCP tools (get_jdbc_entity_details, list_all_domain_entities, list_entity_repositories) because they return resolved, project-wide answers in one call. If the Spring MCP server is unreachable (connection error, tool not registered, harness without MCP support) and the user has chosen not to install the plugin via the Preflight above, do not refuse the task — fall back to direct file reads / grep:
This project is Kotlin-first (Kotlin 2.2.20 primary, Java for some modules) — every fallback grep must hit both *.kt and *.java. Do not pass -t java to rg; either omit the type filter or use -t kotlin -t java.
- Instead of
list_all_domain_entities— grep the project fororg.springframework.data.relational.core.mapping.Tableimports (or@Tableannotations whose import resolves there) to enumerate JDBC entities. Works the same in.javaand.kt. - Instead of
list_entity_repositories— grep for the repository-declaration keyword (extendsin Java,:in Kotlin; Kotlin may omit the space before the colon, so use\s*). Spell out every base interface explicitly rather than relying on optional prefixes —(List)?(...|...)parses ambiguously to a human reader:
(extends|:)\s*(ListCrudRepository|CrudRepository|ListPagingAndSortingRepository|PagingAndSortingRepository)\b- Instead of
get_jdbc_entity_details— see the "Withoutget_jdbc_entity_details" subsection inreferences/aggregate-rules-impl.md. The manual procedure there yields the sameidField.type/aggregateRootFqn/aggregates/referencedByinformation by reading source files (both Java and Kotlin).
State once at the start of the task that you are operating in fallback mode and why (e.g. "Spring MCP not reachable — using file-read fallback"). Do not silently switch.
Working with JDBC Entities
When the task involves creating or modifying a Spring Data JDBC entity:
1. If entity conventions have not been detected yet in this conversation — check memory for previously saved conventions first (or earlier conversation turns in runtimes without persistent memory — see "Harness compatibility"). If found, reuse them. Otherwise read references/entity-conventions.md and follow all substeps there to detect project conventions. 2. Read references/entity-rules-impl.md and follow the rules there when writing or modifying the entity. 3. If the entities involve any relationship between each other or to existing entities — a collection field, a reference field, an FK column, or a relationship described in the user's request ("X belongs to Y", "Y has many X") — also read references/aggregate-rules-impl.md before deciding the shape of any link. The entity rules cover field syntax; the aggregate rules decide which relationship shapes are legal and in which direction the link may be held. Skipping this step is how illegal shapes (raw FK to a member of another aggregate, references to non-roots) get generated.
Tool-vs-source policy — when you are going to edit the entity, read the source file directly. get_jdbc_entity_details is documented as a read-only analysis tool and explicitly says "you plan to modify the entity class afterward — read the file directly instead." Use the MCP tool only for cross-aggregate context that is not visible from one file:
- The target's role in its aggregate (
aggregateRootFqn). - Other aggregates pointing here (
referencedBy). - The id type of a different aggregate you need to link to via
AggregateReference<Other, IdType>(when readingOther's source is overkill).
For everything in the file you are editing — id type, current fields, current @MappedCollection / @Embedded declarations — read the file.
Reviewing JDBC Patterns
When the user asks to review JDBC patterns, conventions, or code quality in the project:
1. Detect current conventions by following references/entity-conventions.md (steps 1.1–1.5). 2. Compare the detected conventions against the best practices defined in references/entity-rules-impl.md. For each deviation, output a recommendation in the format:
### JDBC Review
**[Convention or pattern name]**
- Current: <what the project does>
- Recommended: <what the best practice says>
- Reason: <why this matters>If no deviations are found — state that the project follows best practices.
---
Working with JDBC Repositories
When the task involves adding or modifying a Spring Data JDBC repository:
1. If repository conventions have not been detected yet in this conversation — check memory (or earlier conversation turns — see "Harness compatibility") first. Otherwise read references/repository-conventions.md and follow all substeps there. 2. Read references/repository-rules-impl.md and follow the rules there.
Before creating a new repository, call list_entity_repositories with entityFqn = <target> to confirm no repository already exists for this entity, and call get_jdbc_entity_details for the target — a repository may only be created for an aggregate root (aggregateRootFqn == null). Note that list_entity_repositories has no JDBC filter; filter results manually if you called it without entityFqn.
---
Working with Aggregates
When the task involves aggregate boundaries — adding AggregateReference fields, converting an owned @MappedCollection into a cross-aggregate link, splitting an aggregate, or answering "who references X?" / "what's inside aggregate Y?":
1. If aggregate conventions have not been detected yet in this conversation — check memory (or earlier conversation turns — see "Harness compatibility") first. Otherwise read references/aggregate-conventions.md and follow all substeps there. 2. Read references/aggregate-rules-impl.md and follow the rules there.
The MCP tool get_jdbc_entity_details is the source of truth for aggregate membership. Its response carries aggregateRootFqn (null if this entity is itself a root), aggregates (owned children, recursive — only populated for roots), and referencedBy (other aggregates linking here via AggregateReference). Read these before making any aggregate-boundary decision.
Detect Aggregate Conventions
Follow substeps 1.1 → 1.2 → 1.3 → 1.4 → 1.5 in order. Do not skip or reorder them.
Spring Data JDBC is built around the DDD aggregate concept:
- An aggregate root is the entrypoint for a cluster of related entities. It has a repository, a transactional lifecycle, and a database table.
- Owned children are inner entities reached via
@MappedCollection(or a plain entity-typed field whose value type is itself@Table-annotated). They share the root's lifecycle, get their own table, and have no repository of their own. - Embedded value objects (
@Embedded.Empty/@Embedded.Nullable/@Embedded(...)) are not owned children — they have no identity, no separate table, and are flattened into the parent's row. They do not appear in the aggregate's owned-entity tree. - Cross-aggregate links are always expressed as
AggregateReference<TargetRoot, IdType>— never as direct object references.
The MCP tool get_jdbc_entity_details is the source of truth for the role of each entity:
aggregateRootFqn == null→ the entity is an aggregate root.aggregateRootFqn != null→ the entity is an owned child of the named root.aggregates(only populated for roots) lists every owned child transitively, withcardinalityand the field name on the owner.referencedBylists other aggregates that link to this entity viaAggregateReference.
---
Step 1.1: Inventory aggregates
Call list_all_domain_entities and pick 3–5 representative Spring Data JDBC entities — those whose class is annotated with @Table from org.springframework.data.relational.core.mapping (the tool returns all domain entities including JPA; filter manually). Aim for a mix of roots and owned children. For each, call get_jdbc_entity_details and record:
aggregateRootFqn(root vs. child).aggregates(the root's owned children — what is "inside" the aggregate).referencedBy(who points at this aggregate viaAggregateReference).relationshipscontaining entries withrelationType = AGGREGATE_REFERENCE(this aggregate's outbound cross-links).
This map of roots, children, and cross-aggregate edges is the basis for every later decision.
---
Step 1.2: Score each convention
- Package layout — are aggregate roots and their owned children co-located in one package, in nested packages (
com.example.orderfor root,com.example.order.itemfor children), or scattered? Default: same package as the root. - Repository-per-root — does the project consistently expose a repository only for aggregate roots (never for owned children)? Default: yes. If owned children have repositories, that is a violation to surface in Step 1.4.
- Cross-aggregate link shape — are cross-aggregate references always
AggregateReference<T, ID>, or does the project also use raw foreign-key fields (e.g.Long customerId)? Default: alwaysAggregateReference. - AggregateReference id type — what id types are used for
AggregateReference<T, ID>(must equal the target root's@Idtype)? Recordable per-target rather than globally. Default: matches target's id type exactly. - Aggregate size — are aggregates deliberately kept small (root + 1–2 child collections), or do they grow large with deep nesting? This shows up as
aggregates.sizeinget_jdbc_entity_details. Default: small (≤ 2 levels of nesting; ≤ 3 owned collections per root). Large aggregates often signal a missing aggregate split. - Cycle handling — are there bidirectional
AggregateReferencecycles (Order → Customer and Customer → Order)? Default: avoided; prefer querying via repository on one side.
---
Step 1.3: Collect uncertain conventions
Score confidence only where the code contains relevant examples but the pattern is ambiguous (e.g. some aggregates use AggregateReference, others use raw Long customerId). If a convention is absent — confidence is high, use the default.
Collect all conventions where confidence < 80.
---
Step 1.4: Ask developer
If there are any uncertain conventions, ask the developer. In Claude Code, combine all questions into a single AskUserQuestion call. In runtimes without that primitive (Codex / OpenCode / plain CLI), render the same questions inline — see the "Harness compatibility" section in SKILL.md. Example payload:
{
"questions": [
{
"header": "Cross-aggregate links",
"question": "How should references to entities in other aggregates be modeled?",
"multiSelect": false,
"options": [
{ "label": "AggregateReference<T, ID> (Recommended)", "description": "Typed reference; documents intent; survives refactors" },
{ "label": "Raw foreign-key field", "description": "e.g. private Long customerId; — terser but loses type information" }
]
},
{
"header": "Package layout",
"question": "Where do owned child entities live relative to their aggregate root?",
"multiSelect": false,
"options": [
{ "label": "Same package as root (Recommended)", "description": "Order and OrderItem in the same package" },
{ "label": "Nested package", "description": "Order in com.example.order; OrderItem in com.example.order.item" }
]
}
]
}If Step 1.1 surfaced a repository for an owned child or a raw FK field used for cross-aggregate links, flag this as a deviation rather than silently asking — note it explicitly when answering the user.
---
Step 1.5: Summarize resolved conventions
Output a consolidated list of every convention from Step 1.2 with its resolved value, plus the inventory of aggregates discovered in Step 1.1:
- Package layout: same package as root
- Repository-per-root: yes — only roots have repositories
- Cross-aggregate link shape: AggregateReference<T, ID>
- AggregateReference id type: matches target's @Id type
- Aggregate size: small (≤ 2 levels nesting, ≤ 3 owned collections)
- Cycle handling: avoided
Inventory:
- com.example.order.Order — root; aggregates: OrderItem (ONE_TO_MANY); referencedBy: Invoice
- com.example.order.OrderItem — child of Order
- com.example.customer.Customer — root; aggregates: ContactInfo (ONE_TO_ONE); referencedBy: OrderThis list is your working contract for all aggregate-touching code written in this session.
Aggregate Implementation Rules
Apply these rules when reasoning about or modifying aggregate boundaries in Spring Data JDBC. All rules assume the conventions resolved in Step 1 of aggregate-conventions.md are already applied.
AggregateReference is imported from org.springframework.data.jdbc.core.mapping.AggregateReference.
---
Identifying the role of an entity
Use get_jdbc_entity_details to read three cross-aggregate facts that are not obvious from a single file:
aggregateRootFqn— null → this is itself a root; non-null → this is an owned child of the named root.aggregates— only populated for roots; lists owned children recursively, each withcardinalityandfieldName.referencedBy— every other aggregate that links here viaAggregateReference, with theownerEntityFqn(the entity holding the field) and the root of the holder's aggregate.
relationships carries every outbound edge with a relationType of ONE_TO_ONE, ONE_TO_MANY, AGGREGATE_REFERENCE, or EMBEDDED. Only AGGREGATE_REFERENCE crosses an aggregate boundary; ONE_TO_ONE/ONE_TO_MANY are owned children inside the same aggregate; EMBEDDED is a value object flattened into the current row — not a member of the aggregate's owned-entity tree.
Note: the tool itself recommends reading the file directly when you are about to modify the entity. The tool is best used for the three cross-aggregate facts above — for in-file structure (field list, id type, current annotations) read the source.
---
Without get_jdbc_entity_details (MCP fallback)
If the MCP tool is unreachable, derive the same four facts by reading source files. Announce up front that you are in fallback mode.
The project is Kotlin-first (Kotlin 2.2.20 primary; some Java). Every grep must scan both *.kt and *.java — do not restrict with -t java. Annotations also frequently sit on their own line above the field/constructor parameter they decorate, so a single-line regex over annotation-and-target will produce false negatives. Use a two-step pattern: (1) find files that contain the annotation; (2) open each file and read the surrounding declarations.
`idField.type` — find the field annotated with @Id (import org.springframework.data.annotation.Id) and read its declared type.
1. Open the target entity source file (both .java and .kt are valid). 2. Look for @Id. In Java/Kotlin classes it usually sits on a field; in records, on a record component; in Kotlin data classes, on a constructor parameter (often as @field:Id or @property:Id). 3. If @Id is not in the target file, follow the inheritance chain. In Java: class MyTable extends BaseTable. In Kotlin: class MyTable : BaseTable(). Open the parent source and repeat — the project's test fixtures use this pattern (MyTable extends BaseTable where BaseTable holds the @Id). Walk further up if the parent itself inherits. 4. The type declared next to @Id is the id type.
`aggregateRootFqn` — is the target a root or an owned child? Owned-child status comes only from @MappedCollection (or a plain entity-typed field that resolves to a @Table class) on a parent. @Embedded does NOT make the embedded type an aggregate member — it is a value object flattened into the parent's row.
Two-step search (works for Java and Kotlin):
1. List candidate parents — files containing @MappedCollection or a field whose declared type is Target (or a collection of Target):
rg --files-with-matches "@MappedCollection|\\bTarget\\b"2. Open each candidate file and inspect its field/property declarations. Read across line breaks — the annotation and the field/property are typically on separate lines:
@MappedCollection(idColumn = "order_id")
private Set<OrderItem> items; // Java @MappedCollection(idColumn = "order_id")
var items: MutableSet<OrderItem> = mutableSetOf() // KotlinA field is an inbound link to Target if the declared type is Target, Collection<Target>, Set<Target>, List<Target>, or Map<_, Target> AND it carries @MappedCollection (or no annotation at all, with the type being itself @Table-annotated).
The first inbound match is the parent; recurse up the @MappedCollection chain until you find a @Table entity with no inbound @MappedCollection — that is the aggregate root. If no inbound exists, the target is itself the root.
Note: an @Embedded.Empty / @Embedded.Nullable / @Embedded(...) field pointing at the target does not make the target a child of the holder's aggregate. Embedded classes are value objects; they typically should not even be @Table-annotated — see the "Embedded objects do not have identity" rule below. If you find a @Table entity that is @Embedded from another entity, that is itself a code smell to flag, not a clue about aggregate membership.
`aggregates` (only meaningful for roots) — open the root's source file and list every field/property annotated with @MappedCollection (or any plain entity-typed field whose declared type is itself @Table-annotated). Each such value type is an owned child entity. Recurse into each child to find nested owned children. Cardinality: a collection / Map / List / Set ⇒ ONE_TO_MANY; a scalar reference ⇒ ONE_TO_ONE. Skip @Embedded fields here — they are value objects, not members of the aggregate's entity tree.
`referencedBy` — find inbound AggregateReference<Target, ...> fields. A single-line regex over the whole generic is unreliable — Kotlin (and occasionally Java) wraps long generics across lines, e.g.:
var ref: AggregateReference<
Target,
Long
>? = nullUse a two-step search that does not assume same-line layout:
1. Find candidate files — those that contain both AggregateReference and the target type's simple name. Intersect two file lists:
comm -12 \
<(rg --files-with-matches "\bAggregateReference\b" | sort) \
<(rg --files-with-matches "\bTarget\b" | sort)(or run the second rg only over the first list — equivalent and avoids the temp ordering.) 2. Open each candidate file and confirm by eye that the target appears in the first generic slot of an AggregateReference<…, …> field declaration (not as the IdType in slot two: AggregateReference<Other, Target> would be Target used as an id type — different relationship and a sign of a different bug, not an inbound link).
Each confirmed match is an inbound cross-aggregate link. For each match's owner class, walk back up the @MappedCollection chain (per the aggregateRootFqn procedure) to find the aggregate root that ultimately holds the reference.
The manual procedure is slower and less reliable than one MCP call — treat it as a last resort, not the default.
---
Rule: Repositories only for aggregate roots
A Spring Data JDBC repository may exist only for an aggregate root.
When asked to create a repository, run get_jdbc_entity_details for the target. If aggregateRootFqn != null — refuse with:
"<target> is an owned child of aggregate <aggregateRootFqn>. Owned children must be reached through the root's repository. To work with this entity in isolation, either (a) load the root and navigate to the child, or (b) split this child off into a separate aggregate root — but that requires replacing the parent's@MappedCollectionwithAggregateReference<<target>, <id>>and adding a@Tableannotation+repository for the child."
Do not generate the repository anyway. Do not silently extend an inappropriate base interface.
---
Rule: Owned children are loaded and saved with the root
To create / update / delete an owned child, mutate the parent and call rootRepository.save(root). The framework re-inserts the child collection on every update.
// CORRECT — mutate child via root
Order order = orderRepository.findById(orderId).orElseThrow();
order.getItems().add(new OrderItem(/* ... */));
orderRepository.save(order);// WRONG — direct repository call on an owned child
orderItemRepository.save(item); // owned child must not have a repositoryTo query owned children in isolation (e.g. "all items with status = PICKED across orders"), write a custom @Query on the root's repository that joins to the child table and returns a projection — do not introduce an OrderItemRepository.
---
Rule: Cross-aggregate references use AggregateReference<Target, IdType>
When this aggregate must point at another aggregate root, declare the field as AggregateReference<Target, IdType>. The IdType must equal the target's @Id type — fetch it via get_jdbc_entity_details on the target (idField.type). Never widen it to Number/Serializable.
// CORRECT
@Column(value = "customer_id")
private AggregateReference<Customer, Long> customer;To resolve the target, the service layer dereferences via the target's repository:
public CustomerView resolve(Order order) {
Long customerId = order.getCustomer().getId();
Customer c = customerRepository.findById(customerId).orElseThrow();
return toView(c);
}AggregateReference.to(id) is the only public factory. It rejects null — the contract is id must not be null, and calling to(null) throws IllegalArgumentException at runtime.
Nullable cross-aggregate links are expressed by storing null directly in the field, never by wrapping a null id:
// CORRECT — assigning the field for a non-null id
order.setCustomer(AggregateReference.to(customer.getId()));// CORRECT — nullable link: store null in the field directly
order.setCustomer(null);// CORRECT — guard before wrapping (this is the pattern the project's
// MapStruct mapper generator emits)
order.setCustomer(customerId == null ? null : AggregateReference.to(customerId));// WRONG — AggregateReference.to(null) throws at runtime
order.setCustomer(AggregateReference.to(null));// WRONG — raw FK on an entity that belongs in a different aggregate
@Column(value = "customer_id")
private Long customerId;// WRONG — @MappedCollection used to cross an aggregate boundary
@MappedCollection(idColumn = "customer_id")
private Set<Customer> customers; // Customer is itself a root---
Rule: External references may only target aggregate roots
AggregateReference<Target, IdType> is only valid when Target is an aggregate root (aggregateRootFqn == null). When another aggregate needs to point at an owned child — e.g. Visit (root) must reference Pet, which is owned by Owner — that constraint does not license a fallback to a raw FK column. There is no legal shape for referencing a member of another aggregate; a raw Long petId is not a "canonical pattern for non-roots", it is the same raw-FK violation flagged above, and it is worse than invisible:
referencedByinget_jdbc_entity_detailsonly tracksAggregateReferencefields, so every boundary check in this skill (including the "do not demote whilereferencedByis non-empty" guard) silently stops seeing the link;- nothing ties the row to the aggregate contract — the owner root can be deleted (cascading its children away) while outside rows still point at the vanished member.
Wanting to reference a non-root means one of three things is true. Pick one explicitly — and if the domain intent is unclear, ask the developer (AskUserQuestion in Claude Code; inline otherwise) instead of silently picking a shape:
1. The direction is framed wrong. A "many-to-one from my root to their member" is usually a one-to-many from their member to my root read from the FK side (a JPA habit — in JPA the many side always holds the FK; here it must not). Re-frame and apply the one-to-many rule below with the member as the holder: e.g. Pet owns Set<PetVisitRef> via @MappedCollection, each entry holding AggregateReference<Visit, Long>. 2. The member is a root in disguise. Being referenced from outside the aggregate is the classic signal of an independent lifecycle — the @Embedded rule below already states this for value objects, and it applies equally to owned children. Promote it via "Converting an owned child into a separate aggregate", then link with a plain AggregateReference<Pet, Long>. 3. The root is what you really mean. If the outside aggregate needs the cluster rather than the specific member, reference the owning root (AggregateReference<Owner, Long>) and resolve member-level detail through the root at the service layer.
// WRONG — raw FK to an owned child of another aggregate
@Column(value = "pet_id")
private Long petId; // Pet is owned by Owner: link invisible to tooling, dangles on Owner deletion// WRONG — AggregateReference to a non-root
@Column(value = "pet_id")
private AggregateReference<Pet, Long> pet; // Pet has no repository; nothing can resolve this---
Rule: One-to-many between two aggregates goes through a link entity
When a one-to-many relationship must connect two different aggregates — the "many" side is another aggregate root — model it with an additional link entity backed by its own table, combining @MappedCollection and AggregateReference. This applies regardless of where the holding side sits:
- root → root — an aggregate root must point at many roots of another aggregate (e.g.
Order→ manyProduct); - member → root — an owned child inside one aggregate must point at many roots of another aggregate (e.g.
OrderItem, owned byOrder, → manyWarehouse).
The shape: the holding side owns a collection of link entities via @MappedCollection; each link entity carries an AggregateReference<Target, IdType> to the other aggregate root. The link entity is an owned child of the holding aggregate — no repository of its own, saved and deleted with the holder's root. This keeps both aggregate boundaries intact: the target root stays an independent aggregate, and the relationship itself lives inside the holder.
This rule also applies when the request is phrased from the other side — "each Visit (root) is for one Pet (member of Owner)" is the same Pet → many Visits relationship read from the FK side. Do not flip the direction so that the root points at the member: that produces an illegal reference to a non-root (see "External references may only target aggregate roots" above). The collection of links always sits on the side that may legally hold it, even when the natural-language phrasing puts the FK on the other side.
// Link entity — owned child of the Order aggregate, its own table
@Table("order_product_ref")
public class OrderProductRef {
@Column(value = "product_id")
private AggregateReference<Product, Long> product;
// attributes of the relationship (quantity, addedAt, ...) live here if needed
}// root → root: Order (root) → many Product (root)
public class Order {
@Id
private Long id;
@MappedCollection(idColumn = "order_id")
private Set<OrderProductRef> products;
}// member → root: OrderItem is an owned child of Order and needs many Warehouse roots.
// The link entity becomes a nested owned child of the Order aggregate.
public class OrderItem {
@MappedCollection(idColumn = "order_item_id")
private Set<OrderItemWarehouseRef> warehouses; // each holds AggregateReference<Warehouse, Long>
}// WRONG — @MappedCollection pointing directly at another aggregate root:
// makes Product an owned child of Order, so saving an Order would
// re-insert/delete Product rows and destroy Product's independent lifecycle
@MappedCollection(idColumn = "order_id")
private Set<Product> products;// WRONG — a bag of raw FK ids: loses the typed link and the schema's FK intent
private Set<Long> productIds;If the relationship carries no attributes at all, a bare Set<AggregateReference<Product, Long>> under @MappedCollection (link table without a dedicated class) is an acceptable compact variant — but the explicit link entity is the default, because it survives the moment the relationship grows attributes without a schema-shape change.
To query in the opposite direction ("which orders contain product X?"), write a @Query on the holder root's repository joining through the link table — do not add a repository for the link entity.
---
Rule: Many-to-many between two aggregates goes through a link entity
A many-to-many relationship between two aggregates follows the same pattern as the one-to-many rule above: an additional link entity with its own table, owned via @MappedCollection, carrying an AggregateReference<Target, IdType>. As with one-to-many, this applies regardless of whether the holding side is a root (root → root) or an owned member inside an aggregate (member → root).
The Java shape is identical to the one-to-many link — what makes the relationship many-to-many is only that the same target root may appear in link rows of many holders. The one decision many-to-many adds is which side owns the link collection. Exactly one side does — pick the side from which the relationship is naturally created and modified in the domain (the side whose save should rewrite the link rows). The other side never gets a mirror collection; it reads the relationship through a query.
// Link entity — owned child of the Student aggregate, its own table
@Table("student_course_ref")
public class StudentCourseRef {
@Column(value = "course_id")
private AggregateReference<Course, Long> course;
// attributes of the relationship (enrolledAt, grade, ...) live here if needed
}// Student (root) ↔ Course (root), owned from the Student side:
// enrolling/unenrolling is done by mutating student.courses and saving the Student
public class Student {
@Id
private Long id;
@MappedCollection(idColumn = "student_id")
private Set<StudentCourseRef> courses;
}// Course stays an independent root with NO collection back to Student.
// "Who is enrolled in course X?" is a query on the owning side's repository:
public interface StudentRepository extends CrudRepository<Student, Long> {
@Query("""
SELECT s.* FROM student s
JOIN student_course_ref scr ON scr.student_id = s.id
WHERE scr.course_id = :courseId
""")
List<Student> findAllByCourseId(Long courseId);
}// WRONG — mirror collections on both sides: two aggregates would both own
// the same link table and overwrite each other's rows on save
public class Course {
@MappedCollection(idColumn = "course_id")
private Set<CourseStudentRef> students; // the Student side already owns the link
}// WRONG — @MappedCollection pointing directly at the other root
@MappedCollection(idColumn = "student_id")
private Set<Course> courses; // would make every Course an owned child of one StudentWhen the relationship itself has an independent lifecycle — it is created/queried/modified on its own, from both sides, or carries substantial data (Enrollment with status, history, payments) — promote the link entity to its own aggregate root with two AggregateReference fields (AggregateReference<Student, …> + AggregateReference<Course, …>) and its own repository, and drop the @MappedCollection from both sides. At that point it is no longer a link table but a domain concept in its own right.
---
Rule: Converting an owned child into a separate aggregate
When the project decides that an owned child must become its own aggregate (independent lifecycle, its own repository), the migration has four mandatory steps:
1. Add @Table("...") (or @Table(name = "...", schema = "...")) to the child if missing, and ensure its @Id is in place — declared directly on the child, or inherited from a base class. The table likely already exists; the change is only on the Java side. 2. Replace the parent's @MappedCollection field. The right shape depends on cardinality:
- Parent had a single child —
AggregateReference<Child, IdType>field on the parent. Straightforward. - Parent had a collection of children — this is now a one-to-many between two aggregates; apply the "One-to-many between two aggregates goes through a link entity" rule above: introduce a link entity owned by the parent via
@MappedCollection, each entry holdingAggregateReference<Child, IdType>. Inverting the direction instead (dropping the field on the parent and addingAggregateReference<Parent, IdType>to the now-independent child, with the "list" becoming a query on the child's repository) is acceptable only when the domain genuinely treats the child as pointing at the parent — confirm with the developer before choosing it.
3. Create a ChildRepository extending the conventional base interface (Step 1.5 of repository conventions). 4. Update every read/write path in the codebase: places that previously navigated parent.getChildren() now go through childRepository.
Do not stop after step 1 or 2 — partial migrations leave the codebase in an inconsistent state.
---
Rule: Converting a separate aggregate into an owned child
The reverse migration is symmetrical:
1. Replace the parent's AggregateReference<Child, IdType> field with @MappedCollection (the child stays an entity with its own @Id). Do not convert to @Embedded — that would mean dropping the child's identity entirely and merging its columns into the parent row, which is a different and much bigger refactor. 2. Delete the ChildRepository interface. 3. Remove direct references to childRepository everywhere; navigate from the parent instead.
If get_jdbc_entity_details shows referencedBy is non-empty for the child, do not demote it to an owned child without first removing every external AggregateReference to it — those references would dangle.
---
Rule: Embedded objects do not have identity
@Embedded value objects share the parent row, have no @Id, and cannot be referenced from elsewhere. Use @Embedded only for value semantics (Address, Money, DateRange). If a thing has its own lifecycle or is referenced from another aggregate, it is not embeddable.
// CORRECT — Address is a value object
@Embedded.Empty(prefix = "ship_")
private Address shippingAddress;// WRONG — using @Embedded for an entity that other aggregates reference
@Embedded.Empty
private Customer customer; // Customer has identity and a repository---
Answering "what is in aggregate X?" / "who references X?"
These questions are answered directly from get_jdbc_entity_details:
- "What is inside aggregate Foo?" — call the tool for
Fooand reportaggregates(each entry hasentityFqn,fieldName,ownerEntityFqn,cardinality). - "Who references Foo?" — call the tool for
Fooand reportreferencedBy(each entry hasownerEntityFqn,fieldName,aggregateRootFqn). - "Is Foo a root?" —
aggregateRootFqn == null⇒ yes.
Do not crawl the codebase by hand for these answers when the tool returns the same information in one call.
---
Rule: Aggregate size
Per the Aggregate size convention from Step 1.5 (default: ≤ 2 levels of nesting, ≤ 3 owned collections), flag oversized aggregates during reviews. A deeply nested aggregate is a sign that an inner collection should be split off into its own root and reached via AggregateReference.
This applies to review flows — do not unilaterally split aggregates the user has not asked you to change.
Detect Entity Conventions
Follow substeps 1.1 → 1.2 → 1.3 → 1.4 → 1.5 in order. Do not skip or reorder them.
---
Step 1.1: Find existing entities
Call list_all_domain_entities to get a list of entities in the project. Pick 2–3 representative Spring Data JDBC entities (annotated with @Table from org.springframework.data.relational.core.mapping) and read their source files. Skip JPA entities (jakarta.persistence.@Entity) — they belong to a different skill.
For at least one of the chosen entities, also call get_jdbc_entity_details to see the aggregate shape (root vs. owned child, embedded objects, AggregateReference links). This grounds later convention scoring in real structure, not just text patterns.
---
Step 1.2: Score each convention
For each convention below, determine the answer from the code and assign a confidence score (1–100):
General conventions:
- Class shape — are entity classes mutable (default constructor + setters), immutable plain classes (final fields +
@PersistenceCreatorconstructor), or Java records? Default: mutable class. If both patterns coexist, pick the dominant one. - Annotation placement — are
@Id,@Column,@MappedCollectionplaced on fields or on record components / constructor parameters? Default: on fields (for classes), on components (for records). - Field access modifier — are fields
privateorprotected? Default:private. - Naming strategy — does the project rely on Spring Data JDBC's implicit naming (names omitted from
@Table/@Column), or are all names explicit? Default: explicit — always name DB objects explicitly so that no naming strategy can silently change mappings. - `@Table` form — when the table name alone is set, does the project write
@Table("orders")(shorthand) or@Table(name = "orders")/@Table(value = "orders")(named attribute)? Note thatnameandvalueare@AliasForsiblings on Spring Data Relational's@Table— they mean the same thing; the IDE's JDBC template emits the shorthand when only a name is set. Default: shorthand@Table("...")when only name;@Table(name = "...", schema = "...")when schema is also set (matches the project'sJdbcTable.java.fttemplate output). - `schema` usage — do any
@Tableannotations carryschema = "..."? If yes, what is the schema name convention (lower/upper case, underscore, fixed value across the codebase)? Default: no schema unless the project clearly uses one — many monoliths put everything in the default schema. - Table name template — check the value passed to
@Table: case (lower/upper/as-is), prefix, postfix, underscores, pluralized? Default: lower case, underscore, no prefix/postfix, not pluralized. - Column name template — check
@Column(value=...)/@Column("...")values: case, underscores, prefix/postfix? Default: lower case, underscore, no prefix/postfix. - Entity class name convention — is the Java class name transformed in any way (prefix/postfix)? Default: as-is.
Id conventions (score separately):
- Id type preset — what type is used for
@Idfields:Long,Integer,UUID, orString? (These four are the presets surfaced by the IDE's JDBC entity creator.) Default:Long. - Id generation — is the id (a) generated by the database via an auto-increment column (no
@GeneratedValue— JDBC does not have one; the column isIDENTITY/SERIALin DDL and the value comes back via theBeforeConvertCallback/ driver-returned key), (b) generated by the application (UUID assigned in constructor or callback), or (c) supplied by the caller (natural String/long key)? Default: database auto-increment forLong/Integer, application-generated forUUID, caller-supplied forString. - Id annotation import — confirm
@Idis imported fromorg.springframework.data.annotation.Id, not fromjakarta.persistence. Default:org.springframework.data.annotation.Id. If a JDBC entity imports@Idfromjakarta.persistence— that is a bug to flag.
Versioning conventions (score separately):
- `@Version` usage — do any entities declare a field annotated with
@Version(fromorg.springframework.data.annotation) for optimistic locking? Default: no, unless project has it on at least one root. - `@Version` field type — if used, is it
Long,Integer, orShort? Default:Long.
Embedded conventions (score separately):
- Embedded annotation form — does the project use the meta-annotation shortcuts (
@Embedded.Empty/@Embedded.Nullable) or the verbose form (@Embedded(onEmpty = Embedded.OnEmpty.USE_EMPTY)/@Embedded(onEmpty = Embedded.OnEmpty.USE_NULL))? Both compile to the same thing. Default: meta-annotation shortcuts — matches what the IDE's JDBC entity generator emits. - Embedded prefix — when an embedded object is used, is a
prefix = "..."typically set to disambiguate column names? Default: yes — always set a prefix. - `onEmpty` strategy —
USE_EMPTY(all-null columns yield a non-null empty object — meta-annotation@Embedded.Empty) orUSE_NULL(all-null columns yieldnull— meta-annotation@Embedded.Nullable)? The annotation has no default; one of the two must be chosen. Default:USE_EMPTY.
Collection conventions (score separately, for owned @MappedCollection associations):
- Default collection type — is
Set<T>,List<T>,Map<K, T>, orCollection<T>the default for owned children? Default:Set<T>. - `idColumn` naming — what is the FK-back column name template? Default:
<owner_table_singular>_id(e.g. anorderstable's children getorder_id). - `keyColumn` naming (only meaningful for
ListandMap) — what is the order/key column template? Default:<owner_table_singular>_key.
AggregateReference conventions (score separately):
- Cross-aggregate links — are references to entities outside this aggregate expressed as
AggregateReference<Target, IdType>, or as a raw foreign-key field (e.g.Long customerId)? Default:AggregateReference<Target, IdType>— it survives refactors and documents intent.
Lombok conventions (score separately — only relevant for mutable classes; records and @PersistenceCreator classes typically avoid Lombok):
- Lombok used? — are any Lombok annotations present on mutable JDBC entities? Default: no.
- `@Getter` and `@Setter` — on class level? Default: yes if Lombok is used.
- `@Builder` — used? Default: no.
- `@AllArgsConstructor` — used? Default: no.
- `@NoArgsConstructor` — used? Default: no.
- `@ToString` — used? Default: no.
equals & hashCode conventions (score separately):
- equals/hashCode style — check existing implementations: is it (a) manual on
idonly (plain — no proxy handling, because Spring Data JDBC has no lazy proxies), (b) Lombok@EqualsAndHashCode(onlyExplicitlyIncluded = true)with@EqualsAndHashCode.Includeonid, (c) record auto-generated (when class shape = record), or (d) none/defaultObjectidentity? Default: manual onidfor mutable classes; record auto-generated for records. If noequals/hashCodeexist and class shape ≠ record — confidence is high (90), use the default without asking. Do not apply the JPAHibernateProxypattern here — there are no Hibernate proxies in Spring Data JDBC. - `@EqualsAndHashCode` fields — if Lombok is used, which fields are included via
@EqualsAndHashCode.Include? Default:idonly.
toString conventions (score separately):
- toString style — check existing
toString()implementations: manual / Lombok@ToString/ record auto-generated / none. Default: manual for mutable classes, record auto-generated for records. If notoStringexists and class shape ≠ record — confidence is high (90), use the default without asking. - toString fields — if manual, which fields are included? Spring Data JDBC has no lazy loading, so all local scalar fields and
AggregateReferencefields (which are just ID holders) are safe to include. Owned@MappedCollectioncollections are loaded eagerly with the aggregate and are safe too, but typically excluded to keep output short. Default: all local non-collection fields plusAggregateReferencefields.
Constants Generation conventions (score separately):
- Constants generated? — does any entity have
public static final Stringconstants for entity/table/column names? Default: no. - What is generated — entity name constant, table name constant, column name constants? Default: all three, if constants are used.
- Where constants are placed — same class, nested class, or separate class? Default: same class.
---
Step 1.3: Collect uncertain conventions
Score confidence only for conventions where the code contains relevant examples but the pattern is ambiguous or inconsistent. If a convention is simply absent from the code (e.g. no @Version anywhere, no embedded objects, no Lombok) — confidence is high, use the default without asking.
Collect all conventions where confidence < 80. For each, formulate a question with explicit answer options; put the default value first (marked as "Recommended").
---
Step 1.4: Ask developer
If there are any uncertain conventions from Step 1.3, ask the developer. In Claude Code, use AskUserQuestion and combine all questions into a single tool call. In runtimes without that primitive (Codex / OpenCode / plain CLI), render the same questions inline as plain text — see the "Harness compatibility" section in SKILL.md for the rendering rules. The JSON payloads below double as both: the AskUserQuestion payload, and a template for the inline questions and option lists.
Example call — class shape + ID preset + ID generation (JDBC-specific blocks):
{
"questions": [
{
"header": "Class shape",
"question": "How are Spring Data JDBC entity classes shaped?",
"multiSelect": false,
"options": [
{ "label": "Mutable class (Recommended)", "description": "Default constructor + setters; getters expose state" },
{ "label": "Immutable class", "description": "Final fields + @PersistenceCreator constructor" },
{ "label": "Java record", "description": "Java records; @Id and @Column on components" }
]
},
{
"header": "Id type",
"question": "What type is used for entity primary keys?",
"multiSelect": false,
"options": [
{ "label": "Long (Recommended)", "description": "Database-generated auto-increment / IDENTITY column" },
{ "label": "Integer", "description": "Database-generated auto-increment / IDENTITY column" },
{ "label": "UUID", "description": "Application-generated UUID (set in constructor or BeforeConvertCallback)" },
{ "label": "String", "description": "Caller-supplied natural key" }
]
},
{
"header": "Id generation",
"question": "How is the id value populated for new entities?",
"multiSelect": false,
"options": [
{ "label": "Database IDENTITY (Recommended)", "description": "DB auto-increment column; Spring Data returns the generated key after insert" },
{ "label": "Application UUID", "description": "@PersistenceCreator constructor or BeforeConvertCallback assigns UUID.randomUUID()" },
{ "label": "Caller-supplied", "description": "Service layer assigns the id before save()" }
]
}
]
}Example call — embedded onEmpty + default collection type:
{
"questions": [
{
"header": "@Embedded onEmpty",
"question": "How should @Embedded fields treat rows where all embedded columns are null?",
"multiSelect": false,
"options": [
{ "label": "USE_EMPTY / @Embedded.Empty (Recommended)", "description": "An empty (non-null) object is created — written as @Embedded.Empty(prefix=...) or @Embedded(onEmpty = Embedded.OnEmpty.USE_EMPTY, ...)" },
{ "label": "USE_NULL / @Embedded.Nullable", "description": "The field is set to null — written as @Embedded.Nullable(prefix=...) or @Embedded(onEmpty = Embedded.OnEmpty.USE_NULL, ...)" }
]
},
{
"header": "Default collection",
"question": "Which collection type is the default for @MappedCollection?",
"multiSelect": false,
"options": [
{ "label": "Set (Recommended)", "description": "java.util.Set<T> — unordered, deduplicated" },
{ "label": "List", "description": "java.util.List<T> — ordered; requires keyColumn for ordering" },
{ "label": "Map", "description": "java.util.Map<K, T> — keyed; requires keyColumn and key type" }
]
}
]
}Example call — equals/hashCode + Lombok (mutable class branch):
{
"questions": [
{
"header": "equals/hashCode",
"question": "How is equals/hashCode implemented on mutable JDBC entity classes?",
"multiSelect": false,
"options": [
{ "label": "Manual on id (Recommended)", "description": "Override equals/hashCode using id only — plain pattern, no HibernateProxy logic (JDBC has no lazy proxies)" },
{ "label": "Lombok @EqualsAndHashCode", "description": "@EqualsAndHashCode(onlyExplicitlyIncluded = true) with @EqualsAndHashCode.Include on id" },
{ "label": "None", "description": "Use default Object identity" }
]
},
{
"header": "Lombok used?",
"question": "Is Lombok used on mutable JDBC entity classes?",
"multiSelect": false,
"options": [
{ "label": "No (Recommended)", "description": "Explicit getters/setters/constructors only" },
{ "label": "Yes", "description": "Lombok annotations are present on entities" }
]
}
]
}Conditional rules:
- Ask "Lombok features" only if "Lombok used?" = Yes.
- Ask "@EqualsAndHashCode fields" only if equals/hashCode style = Lombok.
- Ask "toString fields" only if toString style = manual.
- Ask "Constants what" / "Constants where" only if "Constants used?" = Yes.
- If class shape = record, do not ask about Lombok or about manual equals/hashCode/toString — records cover all three.
---
Step 1.5: Summarize resolved conventions
Before writing any code, output a single consolidated list of all conventions from Step 1.2. Each entry must be tagged with its provenance so a reader can tell what was actually observed in the project versus what was assumed:
[observed]— at least one concrete example exists in the project code; the value reflects that example.[confirmed]— the developer answered this convention in Step 1.4.[default-assumed]— no examples exist in the project code; the default from Step 1.2 is being used as a working assumption only. Be honest about this — do not present a default as if it were observed.
Every convention from Step 1.2 must appear in the list, including Embedded, AggregateReference, Lombok, and Constants Generation items. Do not copy the example below — construct your own list based on the real project.
Example format (values are illustrative only — replace with what you actually discovered):
- Class shape: mutable class [observed]
- Annotation placement: on fields [observed]
- Field access modifier: private [observed]
- Naming strategy: explicit [observed]
- @Table form: shorthand @Table("...") for name-only [observed]
- schema usage: no schema in this codebase [observed: no schema= attribute in any @Table]
- Table name template: lower case, underscore, not pluralized [observed]
- Column name template: lower case, underscore [observed]
- Entity class name convention: as-is [observed]
- Id type preset: Long [observed]
- Id generation: database IDENTITY [observed]
- Id annotation import: org.springframework.data.annotation.Id [observed]
- @Version usage: no [default-assumed: no @Version found anywhere]
- Embedded annotation form: meta-annotations [default-assumed: no @Embedded found anywhere]
- Embedded prefix: yes — always set [default-assumed]
- Embedded onEmpty: USE_EMPTY [default-assumed]
- Default collection type: Set [observed]
- idColumn naming: <owner_singular>_id [observed]
- keyColumn naming: <owner_singular>_key [default-assumed: no List/Map @MappedCollection found]
- Cross-aggregate links: AggregateReference<T, ID> [observed]
- Lombok used: no [observed: no Lombok imports in entity files]
- equals/hashCode style: manual on id (plain, no HibernateProxy) [confirmed: Step 1.4]
- toString style: manual — all local non-collection fields + AggregateReference fields [confirmed: Step 1.4]
- Constants generated: no [observed]This list is your working contract for all code written in this session. For [default-assumed] items: if during the task you encounter a real example that contradicts the default, update the entry and notify the developer rather than silently switching.
Entity Implementation Rules
Apply these rules when writing or modifying any Spring Data JDBC entity. All rules assume the conventions resolved in Step 1 of entity-conventions.md are already applied.
All annotation imports in this document come from Spring Data, not from jakarta.persistence:
org.springframework.data.relational.core.mapping.Tableorg.springframework.data.relational.core.mapping.Columnorg.springframework.data.relational.core.mapping.MappedCollectionorg.springframework.data.relational.core.mapping.Embeddedorg.springframework.data.relational.core.mapping.Embedded.Nullable/Embedded.Emptyorg.springframework.data.annotation.Idorg.springframework.data.annotation.Versionorg.springframework.data.annotation.Transientorg.springframework.data.annotation.PersistenceCreatororg.springframework.data.jdbc.core.mapping.AggregateReference
If you find yourself reaching for @ManyToOne, @OneToMany, @JoinColumn, FetchType, @SequenceGenerator, @GeneratedValue, or HibernateProxy — stop. Those belong to the JPA skill; Spring Data JDBC does not have them.
---
Class — creating a new entity class
Branch on the class shape convention from Step 1.5.
Mutable class (default constructor + setters)
- Annotate with
@Tableper the project's @Table form convention. Forms (all valid;nameandvalueare@AliasForsiblings): @Table("orders")— shorthand when only the table name is set. This is what the IDE's JDBC template emits.@Table(name = "orders", schema = "sales")— named attributes whenschemais also set.@Table(no value) — only when naming strategy = implicit.- Provide a public no-args constructor (explicit or default).
- Generate setters and getters for all fields (or use Lombok per resolved Lombok conventions).
- If Lombok = yes — add
@Getter/@Setteretc. on the class according to the resolved Lombok features. Lombok@Builderrequires both@NoArgsConstructorand@AllArgsConstructor.
// CORRECT — mutable class, explicit naming, no schema
@Table("orders")
public class Order {
@Id
private Long id;
@Column("placed_at")
private Instant placedAt;
// getters + setters
}// CORRECT — when project uses a non-default schema
@Table(name = "orders", schema = "sales")
public class Order { ... }Immutable class (@PersistenceCreator)
- Final fields, all-args constructor annotated with
@PersistenceCreator. - No setters; expose state via getters.
- Suitable when entities are conceptually values with identity.
// CORRECT — immutable class
@Table("orders")
public final class Order {
@Id private final Long id;
@Column(value = "placed_at") private final Instant placedAt;
@PersistenceCreator
public Order(Long id, Instant placedAt) {
this.id = id;
this.placedAt = placedAt;
}
// getters only
}Java record
- Annotate the record type with
@Table(...). - Place
@Id,@Column,@MappedCollection, etc. on the record components. - Records get
equals/hashCode/toStringfor free — do not override them unless there is a strong reason.
// CORRECT — record entity
@Table("orders")
public record Order(
@Id Long id,
@Column("placed_at") Instant placedAt
) {}WRONG — JPA leakage
// WRONG — jakarta.persistence on a JDBC entity
@jakarta.persistence.Entity
@jakarta.persistence.Table(name = "orders")
public class Order { ... }---
Id
Every entity must have an @Id field that Spring Data can resolve. The id may be:
- Declared on the entity itself — the common case.
- Inherited from a base class — Spring Data JDBC reads
@Idthrough the inheritance chain. The project's test fixtures use this pattern (e.g.MyTable extends BaseTablewhereBaseTableholds@Id private String id;). If the entity has aextends ...clause, open the parent source file (and walk further up if needed) before adding a new@Id— declaring a second@Idin a subclass is a bug.
The import for @Id is always org.springframework.data.annotation.Id.
Branch on the Id type preset and Id generation conventions from Step 1.5.
Database IDENTITY — Long or Integer (recommended default)
The column is IDENTITY / SERIAL at the DB level. Spring Data JDBC returns the generated key after save(). No @GeneratedValue, no @SequenceGenerator — those do not exist in Spring Data JDBC.
// CORRECT — database IDENTITY
@Id
@Column(value = "id")
private Long id;// WRONG — JPA annotation on a JDBC entity
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "id")
private Long id;Application-generated UUID
Generate the UUID in the @PersistenceCreator constructor (immutable / record) or in a BeforeConvertCallback<T> bean (mutable class) — Spring Data JDBC does not auto-generate UUIDs.
// CORRECT — UUID assigned by application
@Table("orders")
public final class Order {
@Id @Column("id") private final UUID id;
// other fields
@PersistenceCreator
public Order(UUID id, /* ... */) {
this.id = id != null ? id : UUID.randomUUID();
// ...
}
}Alternative — a callback bean (use this when class shape = mutable):
@Component
public class OrderIdAssigner implements BeforeConvertCallback<Order> {
@Override
public Order onBeforeConvert(Order entity) {
if (entity.getId() == null) entity.setId(UUID.randomUUID());
return entity;
}
}Caller-supplied String natural key
// CORRECT — caller supplies the id
@Id
@Column(value = "id")
private String id;The service layer is responsible for assigning a unique value before save(). Spring Data JDBC distinguishes new vs. existing aggregates by id-nullness — for a non-nullable String id you must implement Persistable<String> so the framework knows when to INSERT vs UPDATE, or use a BeforeConvertCallback to track new aggregates.
---
Version (optimistic locking)
Apply only when `@Version` usage = yes in Step 1.5.
// CORRECT
@Version
private Long version;Spring Data JDBC increments version on every update; a stale value causes an OptimisticLockingFailureException. Note the import: org.springframework.data.annotation.Version, not jakarta.persistence.Version.
---
Field Annotation Rules
Every persistent field must have @Column with an explicit name when naming strategy = explicit (Step 1.5):
// CORRECT
@Column(value = "birth_date")
private LocalDate birthDate;
// WRONG — missing explicit column name (relies on naming strategy)
private LocalDate birthDate;For non-persistent fields, annotate with @Transient from org.springframework.data.annotation:
// CORRECT
@Transient
private transient String cachedDisplayName;// WRONG — JPA import
@jakarta.persistence.Transient
private String cachedDisplayName;Validation — use Jakarta Validation (@NotNull, @Size, etc.) for field constraints. Validation is orthogonal to persistence and is not stack-specific.
---
Field Type Rules
BigDecimal
Always declare BigDecimal columns with explicit DDL precision/scale in your migration. Spring Data JDBC itself does not carry precision/scale metadata on @Column (unlike JPA), so the DDL is the source of truth. In Java, the field is just:
// CORRECT
@Column(value = "price")
private BigDecimal price;Make sure the Flyway/Liquibase migration declares the column as e.g. NUMERIC(10, 2) — otherwise the database default rounds silently.
Enum
Map enums by their String name unless the project explicitly stores ordinals. Register a ReadingConverter / WritingConverter if a custom mapping is needed.
// CORRECT — String storage (DDL: status VARCHAR NOT NULL)
@Column(value = "status")
private OrderStatus status;---
Embedded
Apply when the entity has a value-object component that should be flattened into the same row.
Spring Data Relational's @Embedded annotation requires an onEmpty value — there is no default. The idiomatic forms are the meta-annotations @Embedded.Empty and @Embedded.Nullable (these are what the IDE's JDBC entity generator emits):
// CORRECT — meta-annotation, the form the IDE generates
@Embedded.Empty(prefix = "ship_")
private Address shippingAddress;// CORRECT — equivalent verbose form
@Embedded(onEmpty = Embedded.OnEmpty.USE_EMPTY, prefix = "ship_")
private Address shippingAddress;// CORRECT — nullable variant; field is set to null when all embedded columns are null
@Embedded.Nullable(prefix = "ship_")
private Address shippingAddress;@Embedded.Empty≡@Embedded(onEmpty = Embedded.OnEmpty.USE_EMPTY)— all-null columns yield a non-null empty object.@Embedded.Nullable≡@Embedded(onEmpty = Embedded.OnEmpty.USE_NULL)— all-null columns yieldnull.prefixfollows the Embedded prefix convention. Without a prefix, two embedded fields of the same value type collide on column names.- The embedded class itself is not annotated with
@Table— it is a plain POJO.
Pick the form (meta-annotation vs. verbose) consistent with what the project already uses; prefer the meta-annotation form for new code — it is shorter and matches the IDE generator.
// WRONG — @Embedded without onEmpty does not compile (onEmpty has no default)
@Embedded(prefix = "ship_")
private Address shippingAddress;// WRONG — JPA's @Embeddable / @AttributeOverrides
@Embedded
@AttributeOverrides({ @AttributeOverride(name = "city", column = @Column(name = "ship_city")) })
private Address shippingAddress;---
Associations — owned children (same aggregate)
Use @MappedCollection for children that belong to this aggregate and are loaded/saved with it. Default collection type is Set unless the project's convention says otherwise.
// CORRECT — Set of owned children
@MappedCollection(idColumn = "order_id")
private Set<OrderItem> items = new LinkedHashSet<>();For ordered collections, use List and supply keyColumn:
// CORRECT — ordered List
@MappedCollection(idColumn = "order_id", keyColumn = "order_key")
private List<OrderItem> items = new ArrayList<>();For keyed collections, use Map and supply keyColumn:
// CORRECT — Map keyed by a column
@MappedCollection(idColumn = "order_id", keyColumn = "item_sku")
private Map<String, OrderItem> itemsBySku = new HashMap<>();Column-name defaults (when the project does not override):
idColumn=<owner_table_singular>_id(e.g.order_idfor anorderstable).keyColumn=<owner_table_singular>_key.
// WRONG — owned child should not be reached via AggregateReference
@MappedCollection(idColumn = "order_id")
private Set<AggregateReference<OrderItem, Long>> items;// WRONG — JPA-style relationship on a JDBC entity
@OneToMany(mappedBy = "order", fetch = FetchType.LAZY)
private Set<OrderItem> items;Owned children are POJOs annotated with @Table (so the framework knows the table name) but are not aggregate roots — they have no repository and are not referenced from outside the aggregate. The aggregate root's repository handles their lifecycle.
---
AggregateReference — cross-aggregate links
When the field points to an entity that belongs to another aggregate, model the link as AggregateReference<Target, IdType>. No annotation is required; the type itself carries the meaning. The IdType must equal the target entity's @Id type — read it via get_jdbc_entity_details (idField.type).
// CORRECT — cross-aggregate link to a Customer (separate aggregate root)
@Column(value = "customer_id")
private AggregateReference<Customer, Long> customer;To resolve the linked aggregate, the service layer calls the target repository: customerRepository.findById(order.getCustomer().getId()).
// WRONG — raw foreign-key id field (loses type information; breaks refactors)
@Column(value = "customer_id")
private Long customerId;// WRONG — owned association used to cross an aggregate boundary
@MappedCollection(idColumn = "customer_id")
private Set<Customer> customers;Rule of thumb — if the target type has its own repository, it is an aggregate root and must be reached via AggregateReference. When in doubt, call get_jdbc_entity_details on the target type: a non-null aggregateRootFqn means the target is an owned child; a null aggregateRootFqn means it is itself a root and must be linked via AggregateReference.
If the target is an owned child of another aggregate (non-null aggregateRootFqn) — stop. This does not mean "fall back to a raw FK column"; referencing a member of another aggregate is not allowed in any shape (raw FK included — it is the same WRONG raw-FK example above, and it is invisible to referencedBy tooling). Read the rule "External references may only target aggregate roots" in references/aggregate-rules-impl.md and pick one of its three resolutions: re-frame the direction (the member side holds a link collection pointing at your root), promote the child to its own aggregate root, or reference the owning root instead.
---
Records — extra notes
When class shape = record (from Step 1.5):
// CORRECT
@Table("orders")
public record Order(
@Id Long id,
@Column("placed_at") Instant placedAt,
@MappedCollection(idColumn = "order_id") Set<OrderItem> items,
@Column("customer_id") AggregateReference<Customer, Long> customer
) {}- Do not write a manual
equals/hashCode/toStringfor records. - Do not use Lombok on records.
- Mutating fields is not possible — re-create the record (
new Order(...)) to update state.
---
equals & hashCode
Branch on the equals/hashCode style convention from Step 1.5.
Spring Data JDBC does not create proxies. Do NOT copy the JPA HibernateProxy pattern here — it is dead code in this stack.Manual on id (mutable / immutable classes)
// CORRECT — plain id-based equality
@Override
public final boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Order other)) return false;
return id != null && id.equals(other.id);
}
@Override
public final int hashCode() {
return getClass().hashCode();
}Notes:
equalsreturnsfalsewhenid == nullso transient (unsaved) entities are not considered equal — this matches the JPA convention.hashCodeis stable across the lifecycle by hashing on the class, not the (mutable) id.
Lombok
// CORRECT
@EqualsAndHashCode(onlyExplicitlyIncluded = true)
public class Order {
@Id @EqualsAndHashCode.Include
private Long id;
// ...
}Only annotate id (or the fields resolved in Step 1.5) with @EqualsAndHashCode.Include. Never include AggregateReference fields or @MappedCollection fields.
Record
The record auto-generated equals/hashCode compares every component, including collections — which is usually fine for value-style aggregates but expensive for large owned collections. If that is a problem, switch the class shape to immutable class with a manual id-based equals/hashCode.
WRONG — copy from JPA
// WRONG — Spring Data JDBC has no proxies; this branch is dead
@Override
public final boolean equals(Object o) {
if (o instanceof HibernateProxy proxy) { /* never true */ }
// ...
}---
toString
Branch on the toString style convention from Step 1.5.
Spring Data JDBC has no lazy loading — all local scalar fields, embedded objects, and AggregateReference fields (which only hold an id) are safe to include in toString(). Owned @MappedCollection collections are also loaded eagerly with the aggregate, so they are technically safe but typically excluded to keep output short.
Manual
// CORRECT — all local non-collection fields + AggregateReference fields
@Override
public String toString() {
return "Order{id=" + id
+ ", placedAt=" + placedAt
+ ", customer=" + customer // AggregateReference is safe; it holds an id
+ "}";
}Lombok
// CORRECT
@ToString(onlyExplicitlyIncluded = true)
public class Order {
@ToString.Include private Long id;
@ToString.Include private Instant placedAt;
@ToString.Include private AggregateReference<Customer, Long> customer;
@MappedCollection(idColumn = "order_id") private Set<OrderItem> items; // excluded
}Or — without onlyExplicitlyIncluded:
@ToString
public class Order {
private Long id;
private Instant placedAt;
@ToString.Exclude private Set<OrderItem> items;
}Record
Use the record's auto-generated toString(). Override only when output volume is a concern.
Detect Repository Conventions
Follow substeps 1.1 → 1.2 → 1.3 → 1.4 → 1.5 in order. Do not skip or reorder them.
---
Step 1.1: Find existing repositories
Call list_entity_repositories (parameters: entityFqn optional, moduleName optional — there is no built-in JDBC filter) to get the list of repositories. When you already know the target entity, pass entityFqn so the result is scoped to it. Otherwise omit entityFqn and filter the result manually: for each returned entityFqn, only keep those whose entity class is annotated with @Table from org.springframework.data.relational.core.mapping (open the file or call get_jdbc_entity_details — JPA @Entity classes will simply fail the latter).
Pick 2–3 representative JDBC repositories and read their source files. Skip Spring Data JPA repositories — they belong to a different skill.
A Spring Data JDBC repository is a *Repository interface whose target entity is annotated with @Table from org.springframework.data.relational.core.mapping. It typically extends one of:
org.springframework.data.repository.CrudRepository<T, ID>org.springframework.data.repository.ListCrudRepository<T, ID>org.springframework.data.repository.PagingAndSortingRepository<T, ID>org.springframework.data.repository.ListPagingAndSortingRepository<T, ID>
@Query annotations on JDBC repositories come from org.springframework.data.jdbc.repository.query.Query — not from jakarta.persistence or org.springframework.data.jpa.repository.Query.
---
Step 1.2: Score each convention
General conventions:
- Base interface — which Spring Data interface is extended:
CrudRepository,ListCrudRepository,PagingAndSortingRepository,ListPagingAndSortingRepository? Default:ListCrudRepository(returnsListinstead ofIterableand is friendlier to call sites). - Repository naming — does the project use
<Entity>Repository(e.g.OrderRepository) or some other template? Default:<Entity>Repository. - Repository location — same package as the entity, a
repositorysub-package, or a separateinfrastructuremodule? Default: same package as the entity. - Generic ID parameter — does the repository's
IDgeneric match the entity's@Idtype exactly (no widening)? Default: yes, exact match.
Query conventions:
- Query style — when a finder cannot be expressed as a derived method name, is it written as (a)
@Query("SELECT ...")with named parameters (:name), or (b)@Query("SELECT ... WHERE x = ?1")with positional parameters? Default: named parameters. - SQL dialect — are queries written in standard SQL or in vendor-specific dialect (Postgres functions, MySQL hints)? Default: standard SQL.
- Result mapping — for
@Queryprojections, does the project use (a) DTO mapping viarecord/ class, (b) interface projections, or (c) plainMap<String, Object>? Default: DTO via record. - Pagination shape — when pagination is needed, do methods take
Pageableand returnPage<T>/Slice<T>, or justSort+Limit? Default:Pageable+Page<T>.
Modifying queries:
- `@Modifying` usage — are UPDATE/DELETE statements always annotated with
@Modifying(fromorg.springframework.data.jdbc.repository.query.Modifying)? Default: yes — required. - Modifying return type —
void,int(row count), orboolean? Default:int.
Transaction conventions:
- Transactional layer — where are
@Transactionalboundaries declared: on repository methods, service methods, or controller methods? Default: on service methods. Spring Data already wraps each save/find in its own transaction; broader boundaries belong on the service layer.
---
Step 1.3: Collect uncertain conventions
Score confidence only where the code contains relevant examples but the pattern is ambiguous. If a convention is absent (e.g. no @Query anywhere, no pagination, no @Modifying) — confidence is high, use the default without asking.
Collect all conventions where confidence < 80. For each, formulate a question with explicit answer options; put the default value first (marked as "Recommended").
---
Step 1.4: Ask developer
If there are any uncertain conventions, ask the developer. In Claude Code, use AskUserQuestion and combine all questions into a single tool call. In runtimes without that primitive (Codex / OpenCode / plain CLI), render the same questions inline — see the "Harness compatibility" section in SKILL.md. The JSON payload below is both the AskUserQuestion payload and a template for the inline rendering.
Example call:
{
"questions": [
{
"header": "Base interface",
"question": "Which Spring Data interface should JDBC repositories extend?",
"multiSelect": false,
"options": [
{ "label": "ListCrudRepository (Recommended)", "description": "Returns List<T> from findAll/findAllById — easier to consume than Iterable" },
{ "label": "CrudRepository", "description": "Returns Iterable<T>; pre-Spring-Data 3 default" },
{ "label": "ListPagingAndSortingRepository", "description": "Adds Pageable/Sort to ListCrudRepository" }
]
},
{
"header": "Query style",
"question": "How should @Query parameters be referenced?",
"multiSelect": false,
"options": [
{ "label": "Named (:name) (Recommended)", "description": "@Query(\"... WHERE x = :foo\") + @Param(\"foo\") — survives parameter reordering" },
{ "label": "Positional (?1)", "description": "@Query(\"... WHERE x = ?1\") — terser but brittle on refactors" }
]
}
]
}---
Step 1.5: Summarize resolved conventions
Output a consolidated list of every convention from Step 1.2 with its resolved value:
- Base interface: ListCrudRepository
- Repository naming: <Entity>Repository
- Repository location: same package as the entity
- Generic ID parameter: matches entity's @Id type exactly
- Query style: named parameters (:name)
- SQL dialect: standard SQL
- Result mapping: DTO via record
- Pagination shape: Pageable + Page<T>
- @Modifying usage: required on every UPDATE/DELETE
- Modifying return type: int
- Transactional layer: service methodsThis list is your working contract for all repository code written in this session.
Repository Implementation Rules
Apply these rules when writing or modifying any Spring Data JDBC repository. All rules assume the conventions resolved in Step 1 of repository-conventions.md are already applied.
Imports come from Spring Data, not from jakarta.persistence or org.springframework.data.jpa.*:
org.springframework.data.repository.ListCrudRepositoryorg.springframework.data.repository.ListPagingAndSortingRepositoryorg.springframework.data.jdbc.repository.query.Queryorg.springframework.data.jdbc.repository.query.Modifyingorg.springframework.data.repository.query.Param
---
Pre-checks before creating a repository
Before generating a new repository class, run two MCP checks:
1. list_entity_repositories — confirm a repository for this entity does not already exist. 2. get_jdbc_entity_details on the target entity — confirm the entity is an aggregate root (aggregateRootFqn == null).
If aggregateRootFqn is non-null, the entity is an owned child of another aggregate. Owned children must not have their own repository — the aggregate root's repository owns their lifecycle. Refuse to create the repository and explain: "<entity> is an owned child of aggregate <aggregateRootFqn>. Access it through the aggregate root's repository."
---
Class — creating a new repository
- Declare as a
public interfacenamed<Entity>Repository(e.g.OrderRepository). - Extend the base interface chosen in Step 1.5 (default
ListCrudRepository<T, ID>). - The
IDgeneric must match the target entity's@Idtype exactly. Fetch the id type viaget_jdbc_entity_details(idField.type) — do not guess.
// CORRECT — Long id
public interface OrderRepository extends ListCrudRepository<Order, Long> {
}// WRONG — ID generic widened to Number
public interface OrderRepository extends ListCrudRepository<Order, Number> { ... }// WRONG — extends a JPA interface on a JDBC entity
public interface OrderRepository extends JpaRepository<Order, Long> { ... }---
Derived query methods
Prefer derived query method names when the criterion is expressible in Spring Data's query DSL:
// CORRECT — derived methods
List<Order> findByCustomer(AggregateReference<Customer, Long> customer);
Optional<Order> findByIdAndStatus(Long id, OrderStatus status);
long countByStatus(OrderStatus status);When querying by an AggregateReference field, use the AggregateReference value (not the raw id) in the method signature — Spring Data unwraps the id internally.
---
@Query — when a derived method is not enough
Use named parameters (:name) per the project's query style convention from Step 1.5:
// CORRECT — named parameters
@Query("""
SELECT o.* FROM orders o
WHERE o.status = :status
AND o.placed_at >= :since
""")
List<Order> findRecentByStatus(@Param("status") String status,
@Param("since") Instant since);// WRONG — positional parameters when project convention is named
@Query("SELECT o.* FROM orders o WHERE o.status = ?1")
List<Order> findByStatus(String status);Notes:
@Queryfor JDBC takes native SQL, not JPQL. Use real column names from the DDL, not Java field names.- Always alias the table (
orders o) and selecto.*so Spring Data can map the row to the aggregate. - When projecting to a DTO, write the SELECT list explicitly and declare the DTO either as a Java record or as an interface projection.
DTO projection (record)
public record OrderSummary(Long id, OrderStatus status, Instant placedAt) {}
@Query("""
SELECT o.id AS id, o.status AS status, o.placed_at AS placed_at
FROM orders o
WHERE o.customer_id = :customerId
""")
List<OrderSummary> findSummariesByCustomer(@Param("customerId") Long customerId);Interface projection
public interface OrderSummary {
Long getId();
OrderStatus getStatus();
Instant getPlacedAt();
}Pick the form (record vs interface) consistent with the Result mapping convention from Step 1.5.
---
Modifying queries
Every UPDATE / DELETE @Query must be annotated with @Modifying from org.springframework.data.jdbc.repository.query. The return type follows the project's convention (default int row count):
// CORRECT
@Modifying
@Query("UPDATE orders SET status = :newStatus WHERE status = :oldStatus")
int reassignStatus(@Param("oldStatus") String oldStatus,
@Param("newStatus") String newStatus);// WRONG — missing @Modifying
@Query("UPDATE orders SET status = :newStatus WHERE status = :oldStatus")
int reassignStatus(@Param("oldStatus") String oldStatus,
@Param("newStatus") String newStatus);// WRONG — JPA's @Modifying import
@org.springframework.data.jpa.repository.Modifying
@Query("...")
int reassignStatus(...);---
Pagination
When pagination is required and the project convention = Pageable + Page<T>:
Page<Order> findByStatus(OrderStatus status, Pageable pageable);Slice<T> is acceptable when you do not need a total count — it avoids the extra COUNT(*) query.
---
Transactional boundaries
Do not annotate repository methods with @Transactional. Spring Data already wraps each repository call in its own transaction. Transactional boundaries live on the service layer so that a single business operation spans multiple repository calls.
// CORRECT — boundary on service
@Service
public class OrderService {
@Transactional
public Order placeOrder(NewOrderCommand cmd) {
Order order = orderRepository.save(/* ... */);
auditRepository.save(/* ... */);
return order;
}
}// WRONG — @Transactional on repository method
public interface OrderRepository extends ListCrudRepository<Order, Long> {
@Transactional
@Modifying
@Query("UPDATE orders SET status = :s WHERE id = :id")
int updateStatus(@Param("id") Long id, @Param("s") String s);
}(Exception: @Transactional(readOnly = true) on a heavy read-only finder is acceptable if the project convention permits it.)
Related skills
How it compares
Use instead of ad-hoc code reading when you need DDD-aligned JDBC rules tied to machine-readable entity metadata.
FAQ
Who is spring-data-jdbc for?
Backend-focused developers and small teams on Spring Data JDBC who want agents to follow DDD aggregate rules with MCP-backed entity facts.
When should I use spring-data-jdbc?
Use in Build when modeling or refactoring aggregates; also in Ship review when validating that new entities are roots, owned children, or embeddables before PR.
Is spring-data-jdbc safe to install?
It is procedural documentation plus MCP read-style inspection; confirm MCP server scopes and review Security Audits on this Prism page before connecting to production domains.