Migrating from declarative transactions
Many popular application containers, such as Spring or Java EE, offer declarative transaction management. Specifically, the container offers a model where transaction boundaries are defined using metadata, usually as annotations on public methods.
The Basics
A typical transactional method in a declarative model might look like this:
@Transactional public Long getCustomerId(String email) { // Some business logic in here... }
Using Transaction control the same thing would look like:
public Long getCustomerId(String email) { txControl.required(() -> { // Some business logic in here... }); }
The main change is that the transactions have moved from being metadata defined, and started by the container, to being code defined and started by the Transaction Control service. This gives several significant advantages.
The Scoping Problem
When using method-level metadata to define transaction boundaries it is not possible to manage transactions on a finer level. This makes it difficult to suspend or nest transactions unless you create a new method to hold the extra logic.
A Simple Example
If we want to write an audit message it usually needs to occur in a new transaction, as typically it should persist even if the overall action fails. In a declarative model this requires a separate method with a new boundary, even if the audit function is a private implementation detail of the Object.
@Transactional public void changePassword(String email, String password) { auditPasswordChangeAttempt(email); // Some business logic in here... } @Transactional(REQUIRES_NEW) public void auditPasswordChangeAttempt(String email) { // One line to write the audit message }
With transaction control there is no need to create a method just for the internal audit function.
@Transactional public void changePassword(String email, String password) {
txControl.required(() -> { txControl.requiresNew(() -> { // One line to write the audit message }); // Some business logic in here... }); }
The Proxy Problem
The Proxy problem is a rather insidious issue that affects declarative transactions, and it is a result of how they are implemented. If bytecode weaving is used to directly add transactional behaviour to an object then it will always work the same way. Most solutions, however, do not use bytecode weaving, but instead use a proxy pattern. When a call is made on the proxy the proxy will perform any necessary transaction management before and after delegating the call to the real object. This works well, except if the object makes any internal method calls.
In the general case proxying is unsafe because you cannot rely on a method’s metadata to decide what transaction state will exist when it is called!
Examples of the Proxy Problem
The following examples are all based on real code migrated from Spring to Transaction Control.
A Simple Example
AbstractPersistenceDataManagerImpl { @Transactional(propagation = SUPPORTS, readOnly = true) public <T> T search(Class<T> entityClass, Object pk, Object params) { return (T) find(entityClass, pk, params); } @Transactional(readOnly = true) public UniWorksSuperDBEntity find(Class entityClass, Object pk, Object params) { return (UniWorksSuperDBEntity) em.find(entityClass, pk); } }
In this case the search
method does not require a transaction, but delegates to the find
method which does.
If proxying is used then the find
method will sometimes run in a transaction and sometimes not.
On the other hand, if weaving is used then the find
method will always run under a transaction This may seem innocuous, but it can cause big problems.
We always want to be certain about where a transaction will start and stop!
Extending the Simple Example
The following type is part of the same project as the previous example, and interacts with it.
AbstractDataManager { @Transactional(propagation = NOT_SUPPORTED, readOnly = true) public UniWorksSuperDTO search(Object pk, Object params) { UniWorksSuperDBEntity entity = persistenceDataManager .search(getEntityClass(), createNewPKFromDTOPK(pk), null); loadLinkedTablesTop(entity, params); } protected final void loadLinkedTablesTop(UniWorksSuperDBEntity entity, Object params) { loadLinkedTables(entity, params); } @Transactional(readOnly = true) public void loadLinkedTables(UniWorksSuperDBEntity entity, Object params) { loadLinkedTablesGenerated(entity, params); loadLinkedTransients(entity, params); } }
Now lets imagine that a call comes in to the search
method of this class from some client.
Proxied
-
The container proxy for the data manager halts any ongoing transaction due to the
NOT_SUPPORTED
metadata, entering an undefined scope. -
The code calls search on the persistenceDataManager
-
The container proxy for the persistence data manager does not start or stop a transaction due to the
SUPPORTS
scope. -
The code calls
find
on the persistenceDataManager, but without touching the proxy. -
The code accesses the entity outside a transaction
-
The code returns the entity, and no transaction change is necessary
-
The code calls loadLinkedTablesTop, which calls loadLinkedTables. No proxy is touched therefore no transaction is started.
-
The Tables are populated with data from the entity. Lazy loading is possible as the entity is still attached.
Woven
-
The weaving code for the data manager halts any ongoing transaction due to the
NOT_SUPPORTED
metadata, entering an undefined scope. -
The code calls search on the persistenceDataManager
-
The weaving code for the persistence data manager does not start or stop a transaction due to the
SUPPORTS
scope. -
The code calls
find
on the persistenceDataManager, at this point the weaving code starts a transaction. -
The code accesses the entity inside the transaction
-
The code returns the entity, and the transaction completes. This detaches the entity and prevents lazy loading.
-
The code calls loadLinkedTablesTop, which calls loadLinkedTables. The weaving code starts a new transaction.
-
The Code fails as the entity is not able to access its lazily loaded data.
Strategies for Managing Transaction States
Ensuring consistency is vital when writing code that uses transactions. It is therefore usually a good idea to ensure that any reused code is captured in a private method, and that it asserts the correct transaction state before it begins.
AbstractDataManager { public UniWorksSuperDTO search(Object pk, Object params) { txControl.build().readOnly().notSupported(() -> { UniWorksSuperDBEntity entity = persistenceDataManager .search(getEntityClass(), createNewPKFromDTOPK(pk), null); loadLinkedTablesTop(entity, params); return entity; } } protected final void loadLinkedTablesTop(UniWorksSuperDBEntity entity, Object params) { loadLinkedTables(entity, params); } public void loadLinkedTables(UniWorksSuperDBEntity entity, Object params) { txControl.build().readOnly().required(() -> { loadLinkedTables(entity, params); }); } /** * This method does not need a transaction, but does need a scope */ private void loadLinkedTablesInternal(UniWorksSuperDBEntity entity, Object params) { assert txControl.activeScope(); loadLinkedTablesGenerated(entity, params); loadLinkedTransients(entity, params); } }
Writing code in this way ensures that even when a mixture of transactional and non transactional actions are needed, there will always be a consistent expectation of the transaction scope in each method.
Exception management
The final significant difference between declarative models and the Transaction Control Service is in how much work your application code needs to do when managing exceptions. More detail about managing exceptions is available here.