An object graph spanning several tables must reach the database in dependency order, with generated keys flowing from parents to children. JPA and Storm both solve it. They start from opposite ends.
An Owner with two Pets, one of which has a Visit. Four rows across three tables: the owner first, then the pets carrying the generated owner key, then the visit carrying a pet key. Whatever tool you use has to order the writes and move the keys.
In JPA the graph is a class model with associations in both directions, and cascade configuration on those associations decides how far each operation travels:
// The graph lives in the mapping: collections point down, cascade rides them @Entity class Owner( @Id @GeneratedValue var id: Long = 0, var firstName: String, ) { @OneToMany(mappedBy = "owner", cascade = [CascadeType.PERSIST]) var pets: MutableList<Pet> = mutableListOf() fun addPet(pet: Pet) { pets += pet; pet.owner = this } } @Entity class Pet( @Id @GeneratedValue var id: Long = 0, var name: String, @ManyToOne var owner: Owner? = null, )
Persisting the graph is one call on the root. persist makes the owner managed, propagates along every association marked CascadeType.PERSIST, and the provider writes the statements at flush time, assigning each generated key to the managed instances:
// One call on the root; the mapping decides how far it travels val owner = Owner(firstName = "Alice") owner.addPet(Pet(name = "Wolfie")) owner.addPet(Pet(name = "Rex")) entityManager.persist(owner) // pets become managed too; SQL runs at flush
-- at flush time, parents before children INSERT INTO owner (first_name) VALUES (?) INSERT INTO pet (name, owner_id) VALUES (?, ?) INSERT INTO pet (name, owner_id) VALUES (?, ?) -- the provider walked owner.pets; every persist that reaches an Owner, -- from any call site, cascades exactly the same way
Two things carried that graph. The parent-to-child collections, kept consistent with the child-to-parent references by the addPet helper. And the mapping, where the cascade lives: it is declared once and applies to every call site in the application. That is genuinely convenient, and it is also a decision you can no longer make per use case.
Storm entities are immutable values that model rows, so they reference only their parents, exactly as the foreign key columns do. No collections, no back-references, no helpers keeping the two sides in sync:
// The graph lives in the values: a row names its parents, like the FK column does data class Owner( @PK val id: Int = 0, val firstName: String, ) : Entity<Int> data class Pet( @PK val id: Int = 0, val name: String, @FK val owner: Owner, ) : Entity<Int> data class Visit( @PK val id: Int = 0, val description: String, @FK val pet: Pet, ) : Entity<Int>
A write set applies one operation to a heterogeneous collection of entities. Pass the leaves; unsaved parents are discovered through the foreign key fields:
// Build the graph by holding parent instances, then write it in one call val owner = Owner(firstName = "Alice") val wolfie = Pet(name = "Wolfie", owner = owner) val rex = Pet(name = "Rex", owner = owner) // same instance val visit = Visit(description = "Check-up", pet = wolfie) orm.writeSet().insert(wolfie, rex, visit)
-- one batch per entity type per dependency level INSERT INTO owner (first_name) VALUES (?) INSERT INTO pet (name, owner_id) VALUES (?, ?) -- batch of 2 INSERT INTO visit (description, pet_id) VALUES (?, ?) -- the owner was never passed: the pet values imply it (insertion closure), -- and both pets hold the same instance, so both rows share its generated key
The owner was never passed. insert extends the entities you supply with every unsaved entity reachable through their foreign key fields, the insertion closure. Storm orders the result by foreign key dependencies, executes one batch per entity type per level, and propagates generated keys by instance identity: both pets hold the same owner instance, so both rows receive the same key. Nothing on Owner says that pets depend on it; the pet values say so themselves.
One difference follows from immutability. JPA assigns generated keys to your instances; Storm values never change, so the AndFetch variants return the persisted state:
// Values never mutate: fetch the persisted graph back when you need the keys val saved: Visit = orm.writeSet().insertAndFetch(visit) // saved.pet.owner.id carries the generated key; visit itself is untouched
The deeper difference is one of direction. A JPA model references in both directions because cascades travel parent to child, along the collections. A Storm value references only upward: a pet names its owner because the pet row holds the foreign key, and that is the only graph there is. Insert discovery therefore needs no configuration, a value declares its own dependencies. And delete discovery deliberately does not exist, because a value never names its children.
Behind that sits a difference of philosophy. In JPA, persistence is a property of the object model: you configure once how operations travel, and the session derives the writes from state at flush time. In Storm, persistence is an operation on values: a single call whose inputs fully determine what happens. Neither is free. Storm's price is that every call site states its intent; there is no per-association default to set once and rely on. JPA's price is action at a distance: the call site alone does not tell you what will be written, because the mapping and the session state complete the sentence.
transaction { } when the graph must commit or fail as one. Keys correlate by instance, not by equality: a copy() of an unsaved parent describes a second row.The other actions follow the same contract. Where merge with CascadeType.MERGE walks the associations and decides insert-or-update from entity state, Storm splits the decision into explicit actions: update for rows you know exist, upsert when the database should resolve it atomically. Both write exactly the entities you pass, and update rejects unsaved members instead of quietly inserting them.
That includes the case that trips JPA instincts hardest: an updated owner held inside a pet you pass to update is not written. A keyed referenced entity contributes only its primary key, and there is no session that will flush the owner later. When both changed, name both: update(pet, owner). Each row is written with its own dirty check, so unchanged members still cost nothing.
There is no cascade that insert has and update lacks; one rule covers every action: a write set writes the entities you name, plus the entities your values make necessary. An unsaved owner is necessary, the pet row cannot be written without its key, so holding it is unambiguous intent to create it. A keyed owner is never necessary: it already provides the foreign key value, and beyond that it is whatever snapshot was hydrated when the pet was read. Writing it anyway would silently overwrite newer state with a stale copy. JPA can afford to walk the graph on merge because the session identity-maps entities; without a session, a snapshot is not write intent.
remove deletes the entities you name, children before parents. What it does not do is walk the graph looking for dependents:
// remove deletes exactly what you pass, children before parents orm.writeSet().remove(owner, wolfie, visit) // executed as: visit, wolfie, owner // reactive cleanup of dependents is the schema's job: // pet.owner_id REFERENCES owner(id) ON DELETE CASCADE
orphanRemoval has no counterpart by design: Storm entities hold no child collections, so there is no collection mutation to react to. Declare the reaction where the database can enforce it for every writer, on the constraint, or delete rows explicitly.
| JPA cascades | Storm write sets | |
|---|---|---|
| Behavior declared | On the mapping, for every call site | At the call site, per operation |
| What carries the graph | Collections and back-references, kept in sync | FK fields; a value names its parents |
| When SQL runs | At flush time | At the call, in dependency-ordered batches |
| Generated keys | Assigned to managed instances | Propagated by instance identity; AndFetch returns keyed values |
| What counts as new | Entity state: transient, managed, detached | Local test: default id on an auto-generated key |
| Cascading deletes | CascadeType.REMOVE, orphanRemoval | Explicit remove; ON DELETE CASCADE in the schema |
The reference documentation covers the mechanics in depth: