ORM - Sessions and Transactions
ORM - Sessions and Transactions
The ORM session is the most important concept to understand — and the most common source of confusion. This page covers the session lifecycle, entity states, dirty checking, flush behaviour, transactions, and multi-datasource usage.
What is the ORM Session?
The ORM session is a first-level cache between your application and the database. When you load or save an entity, it goes through the session:
- Load: Hibernate checks the session cache first. If the entity is already loaded, it returns the same instance — loading the same PK twice gives you the exact same object
- Save:
entitySave()registers the entity in the session but doesn't immediately write to the database - Flush: When the session flushes, Hibernate generates and executes the SQL (INSERT, UPDATE, DELETE) for all pending changes
Session Lifecycle
- Application start — Hibernate builds a
SessionFactory(expensive, lives for the application lifetime) - First ORM operation — Lucee creates a session on demand (when you call
entityLoad(),entitySave(),ORMGetSession(), etc.) - During the request — the session accumulates changes
- Flush — pending changes are written to the database (see When Does the Session Flush?)
- Request end — the session is closed and all entities become detached
Entity Lifecycle States
Every entity is in one of four states:
entitySave() ormClearSession()
| |
entityNew() --> TRANSIENT --> PERSISTENT -------> DETACHED
| ^ |
| | entityMerge() |
| +----------------+
|
entityDelete()
|
v
REMOVED
Transient
A new entity that hasn't been saved. The session doesn't know about it:
entity = entityNew( "User", { id: createUUID(), name: "Susi" } );
// entity is transient — not tracked by the session
Persistent
An entity that's been saved or loaded. The session tracks it and will flush changes:
entitySave( entity );
// entity is now persistent — session tracks it
loaded = entityLoadByPK( "User", 42 );
// loaded is also persistent — came from the session/database
Detached
An entity that was persistent but is no longer in the session (session was cleared or closed):
ormClearSession();
// all entities in this session are now detached
// modifying them has no effect on the database
Reattach a detached entity with EntityMerge():
merged = entityMerge( detachedEntity );
// merged is a NEW persistent instance — use it, not the old reference
Removed
An entity marked for deletion. It will be DELETEd on the next flush:
entityDelete( entity );
// entity is removed — DELETE executes on flush
Verifying State
Use the native Hibernate session to check:
sess = ORMGetSession();
sess.contains( entity ); // true if persistent
When Does the Session Flush?
This is the #1 source of ORM confusion. Flush timing depends on your configuration:
flushAtRequestEnd: true (the default)
All dirty entities are automatically flushed at the end of every request. This means:
user = entityLoadByPK( "User", 42 );
user.setName( "Oops" );
// You didn't call entitySave() or ormFlush()
// But the change persists anyway — Hibernate detected the dirty property
This is called implicit flush, and it's the most dangerous default in ORM. Set flushAtRequestEnd: false in your ORM - Configuration.
flushAtRequestEnd: false (recommended)
You control when persistence happens:
// Nothing persists until you say so
user = entityLoadByPK( "User", 42 );
user.setName( "Safe to modify" );
// change is NOT persisted — no flush happens automatically
With flushAtRequestEnd: false, persistence happens when:
- Transaction commit — a
cftransactionblock commits (explicit or implicit) - Explicit
ormFlush()— you call it directly - Before HQL queries — Hibernate auto-flushes to ensure query consistency (but NOT before
queryExecute()/ raw SQL)
Dirty Checking
Hibernate automatically detects changes to persistent entities. You don't need to call entitySave() on an entity you loaded and modified — the change is detected at flush time.
This is powerful but can bite you: modifying an entity for display purposes (e.g. formatting a name) silently persists the change.
Session BIFs
| Function | Description |
|---|---|
| Function | Description |
| ---------- | ------------- |
| ORMGetSession() | Returns the native org.hibernate.Session for the current request. Lucee returns the Hibernate session directly (unlike ACF which wraps it) |
| ORMGetSessionFactory() | Returns the org.hibernate.SessionFactory |
| ORMFlush() | Flushes the current session — writes all pending changes to the database |
ORMFlush( datasource ) |
Flushes the session for a specific datasource |
ORMFlushAll() |
Flushes all datasource sessions |
| ORMClearSession() | Clears the session cache — all entities become detached. Does NOT flush |
| ORMCloseSession() | Closes the current session. A new one is created on next ORM operation |
| ORMCloseAllSessions() | Closes all sessions across all datasources |
| EntityMerge() | Reattaches a detached entity, returning a new persistent instance |
| EntityReload() | Refreshes an entity from the database, discarding in-memory changes |
Important:
ORMGetSession()returns the nativeorg.hibernate.Sessiondirectly. In ACF, it returns aSessionWrapperthat requires.getActualSession()for native API access. This matters if you're migrating code from ACF — see ORM - Migration Guide.
Session Diagnostics
The native session exposes useful debugging methods:
sess = ORMGetSession();
// Is anything pending?
sess.isDirty(); // true if there are unflushed changes
// Session statistics
stats = sess.getStatistics();
stats.getEntityCount(); // number of entities in the session cache
stats.getCollectionCount(); // number of collections in the session cache
Transactions
For anything beyond throwaway scripts, wrap ORM operations in a transaction:
Basic Transaction
transaction {
product = entityNew( "Product", { name: "Widget", price: 9.99 } );
entitySave( product );
// transaction commit flushes the session and commits
}
When the transaction block ends without an explicit commit or rollback, it auto-commits. The session is flushed before the commit.
Explicit Commit and Rollback
transaction {
entitySave( entityNew( "Product", { name: "Widget", price: 9.99 } ) );
transactionCommit();
// committed — data is in the database
}
transaction {
entitySave( entityNew( "Product", { name: "Widget", price: 9.99 } ) );
transactionRollback();
// rolled back — nothing persisted
}
Why Always Use Transactions?
Without a transaction, ormFlush() auto-commits each statement individually. If something fails mid-flush:
- Some INSERTs succeed, others don't
- Your data is in a partial, inconsistent state
- There's no way to roll back
With a transaction, either everything commits or nothing does.
Transaction Session Lifecycle
Understanding what happens to the ORM session during transactions is critical:
- Transaction begins — a new Hibernate transaction starts on the session
- Transaction commits — the session is auto-flushed, then the database transaction commits
- Transaction rolls back — the database transaction rolls back and the session is cleared (all entities become stale — don't reuse them)
- Transaction ends without commit/rollback — auto-commits
After rollback, entities are stale. The session is cleared, but your CFC variables still point to the old objects. Don't try to save or modify them — load fresh entities if you need to continue.
Rollback Example
id1 = createUUID();
id2 = createUUID();
transaction {
entitySave( entityNew( "Auto", { id: id1, make: "Toyota" } ) );
ormFlush();
entitySave( entityNew( "Auto", { id: id2, make: "Ford" } ) );
ormFlush();
transactionRollback();
}
// Both Toyota and Ford are rolled back — neither is in the database
Savepoints
Savepoints let you partially roll back within a transaction: (new in 5.6)
transaction {
entitySave( entityNew( "Auto", { id: createUUID(), make: "Toyota" } ) );
transactionSetSavepoint();
entitySave( entityNew( "Auto", { id: createUUID(), make: "Ford" } ) );
transactionRollback( "savepoint1" );
// Toyota is kept, Ford is rolled back
transactionCommit();
}
IsWithinORMTransaction()
Check whether you're inside an active ORM transaction: (new in 5.6)
isWithinORMTransaction(); // false
transaction {
isWithinORMTransaction(); // true
entitySave( entityNew( "Auto", { id: createUUID(), make: "Toyota" } ) );
}
isWithinORMTransaction(); // false
This replaces the need for Java hacks like ACF's TransactionTag.getCurrent().
Transaction Isolation Levels
Control the isolation level for a transaction: (requires Lucee 7.1+)
transaction isolation="serializable" {
// strictest isolation — serializable reads
entitySave( ... );
}
Check the current isolation level with GetORMTransactionIsolation().
Multi-Datasource Restriction
Only one datasource session can be dirty within a single transaction. If you modify entities from two different datasources inside the same transaction block, Lucee throws an exception and rolls back.
Mixing ORM and Raw SQL
A common need — use entitySave() for some operations and queryExecute() for others in the same request.
Critical rule: entitySave() does NOT automatically flush before queryExecute() runs. If you save an entity and then try to read it with raw SQL, the row won't be there yet:
entitySave( entityNew( "User", { id: 1, name: "Susi" } ) );
// BAD — the row hasn't been flushed to the database yet
result = queryExecute( "SELECT * FROM users WHERE id = 1" );
// result.recordCount is 0!
// GOOD — flush first
ormFlush();
result = queryExecute( "SELECT * FROM users WHERE id = 1" );
// result.recordCount is 1
This applies to both Lucee and ACF. Always call ormFlush() before raw SQL reads if you've made ORM changes. Within a transaction, the flush ensures both ORM and SQL operations see the same state.
Multiple Datasources
ORM supports multiple datasources with separate entity sets and sessions.
Configuration
Map entities to specific datasources using the datasource attribute on the entity, or configure multiple datasources in Application.cfc:
this.datasources["inventory"] = { ... };
this.datasources["accounts"] = { ... };
this.ormSettings = {
datasource: "inventory" // default for ORM
};
Lazy Session Opening
Sessions are created lazily — only when you first use a datasource:
// Only the default datasource session opens
car = entityNew( "Auto" );
car.setId( createUUID() );
entitySave( car );
ormFlush();
// NOW the second datasource session opens
dealer = entityNew( "Dealership" );
dealer.setId( createUUID() );
entitySave( dealer );
ormFlush( "accounts" );
Flushing All Datasources
ORMFlushAll() flushes every open datasource session in one call:
entitySave( car );
entitySave( dealer );
ORMFlushAll(); // flushes both datasources
Datasource-Scoped HQL
Pass the datasource option to ORMExecuteQuery():
results = ORMExecuteQuery(
"FROM Product WHERE active = true",
{},
false,
{ datasource: "inventory" }
);
Common Session Mistakes
Never Store Entities in SESSION or APPLICATION Scope
ORM entities are bound to the request-scoped Hibernate session. When the request ends, the session closes and entities become detached. Accessing lazy-loaded relationships on a detached entity throws "could not initialize proxy - no Session".
// BAD
session.currentUser = entityLoadByPK( "User", 42 );
// GOOD — store the PK, reload each request
session.currentUserId = 42;
user = entityLoadByPK( "User", session.currentUserId );
Serializing Entities
duplicate(), serializeJSON(), and storing in session scope all trigger lazy loading of ALL relationships recursively. This can explode memory or hit "no Session" errors. Convert to plain structs first if you need to serialize.
ORMFlush Unnecessary Updates
A known issue: flushing a one-to-many relationship can generate UPDATE statements that set child FK columns to null and then back to the correct value. This is cosmetic (no data loss) but generates unnecessary SQL. Wrapping in a transaction avoids the issue.
What's Next?
- ORM - Events — entity lifecycle events that fire during flush
- ORM - Troubleshooting — "no Session", "unsaved transient instance", and transaction error messages
- ORM - Configuration — flushAtRequestEnd, autoManageSession settings