Transaction handling using an annotation processor

Jacek Dubikowski
VirtusLab
Published in
9 min readJan 11, 2023

Declarative transaction processing is a very popular feature of Java application frameworks. We can see examples from the three most popular frameworks below.

So why not build a simplified version of your own to learn how it works? The repository Java Own Framework — step-by-step shows how to do it purely in runtime. I want to show you the compile-time version today.

An important reminder is that this is the second post of the series. The first is available here: Build your own framework using annotation processor — introduction and dependency injection. You do not need to know the previous part by heart, but I recommend reading it or at least glancing through it.

In the first article of the series, I jump-started creating an annotation processor-based framework by providing the first feature — dependency injection.

The source code for the article is available in the repository https://github.com/JacekDubikowski/build-your-own-framework.

Note: Micronaut has heavily inspired all code examples in this text.

What exactly is declarative transaction support?

At the end of the previous article, we saw logs for the running app:

As you can guess, the transaction is already there. It is managed by the TransactionManager instance implemented by ManualTransactionParticipationService. The Begin transaction and Commit transaction messages are printed by a fake TransactionManager implementation called TransactionalManagerStub.

If we take a look at the ManualTransactionParticipationService code:

We see that the transaction here adds lots of boilerplate. Wouldn’t it be easier to write code like this:

From now on, being able to write code like that is our target. We want to handle transactions by adding the @Transactional annotation to the method of our interest.

The @Transactional annotation and TransactionManager

First, it would be beneficial to have an annotation to achieve the outcome. As I wanted to use the standard one instead of writing my own, I chose the @Transactional annotation and the TransactionManager interface from Jakarta EE Transactions 2.0 specification.

How is the transactional handling going to work

Once a method is annotated with @Transactional, we want the annotation processor to generate transaction handling code. For the sake of simplicity, the processor will generate code only for methods of concrete classes.

Since the processor can only generate new code, it will create a subclass of the class with annotated methods. Therefore, the class cannot be final. The methods cannot be final, private or static. Non-annotated methods won’t be touched at all.

To get a better idea, please look at the example below.

For the below class:

The annotation processor should generate the following:

The example presents a simplified version of what will be generated, but you probably get the idea. The actual code generation and other issues will be shown later on. The generated code will be simple. It won’t care about transaction propagation. It will wrap checked exceptions into the RuntimeException and rethrow them in that form.

The problem is that if you want transactions, you cannot directly create an instance of the class with annotated methods using new or any other factory method. You must rely on the framework, in our case, the one described in the previous article of the series, to provide it. This is because only the generated class will have the expected transactional code.

The only extra thing worth noticing in the example is the generated class name. For the rest of this project, if the annotation processor ever creates replacements for some classes, their names will include the Intercepted word.

Handling @Transactional

As transaction handling is the main subject of this text, we will get straight to it.

Processing the @Transactional annotation is not a mandatory part of our framework. It should be used based on the user’s decision. Therefore, the code responsible for it will be called TransactionalPlugin, as this is a pluggable feature.

Let’s look at the code below (the code also is available here).

Now, it is time for us to dive deeply into the provided source.

  1. Set<? extends Element> annotated contains all Elements annotated with @Transactional.
  2. In the first step, we filter all methods out of the annotated set of elements using ElementFilter.
  3. Then, the annotated elements are validated against the previously mentioned rules. I introduced the utility class TransactionalMessenger (code here). Its sole responsibility is to wrap Messager and provide a unified API for raising errors associated with the @Transactional processing. Every raiseForSth method calls TransactionalMessenger, providing information about the error. The raiseForSth methods’ code is skipped to keep the example concise and manageable.
  4. Now, we group the annotated methods by classes that the methods are declared. In Java, you can only create a method in a class or interface. However, the plugin accepts only concrete class methods and raises errors for others. Therefore, we can be sure that calling element.getEnclosingElement(_), where an element is the annotated method, will return class representation TypeElement.
  5. Once we have the mentioned grouping, we can write the code. We need to intercept classes that are keys in the grouping and write transactional versions of methods that are values of the mapping.
  6. The last part is to write the code. The logic is stored in TransactionalInterceptedWriter, so we can move to see its code.
  7. As the TransactionalPlugin must be somehow plugged into our framework workings, the class implements the ProcessorPlugin interface. How it all works will be described after we finish with the transaction handling, as it is not the main topic here.

Writing the code with TransactionalInterceptedWriter

For code generation, I will use the proven JavaPoet library.

The code of the TransactionalInterceptedWriter is quite complicated. The thing that requires special attention is writing transactional versions of void and value-returning methods. Unfortunately, Java language has the void type contrary to Kotlin, Scala, Rust and others.

We will get to the mentioned part later. Now let’s start with instance fields and constructor.

Instance fields and constants

The Writer constructor is fairly simple, so it can be omitted.

The constants are fairly simple, and their names are self-explanatory. The class instance fields are more interesting.

1. The transactionalElement stores the TypeElement representation of the class with the annotated methods. The class will be referred to as intercepted class or superclass.

2. The transactionalMethods stores the ExecutableElement representation of the annotated methods of the transactionalElement class.

3. The packageElement stores the PackageElement representation of the package in which transactionalElement is defined.

Intercepting class definition

We will start with the most high-level thing. Let’s see how the intercepting class is written, but without going into details.

First of all, the class must have a name. As mentioned before the generated class will be called the old one but with an extra Intercepted part. For example, Repository will be changed into Repository$Intercepted. Therefore, we know that the type before $ is intercepted by the generated class.

  1. The created class must be annotated with @Singleton, so the DI solution from the first part will pick it up.
  2. To fulfil its role, the generated class will extend the class with methods annotated with @Transactional. We have already talked about it above.
  3. The class will also implement the Intercepted interface, which will be covered later. The interface is related to the provisioning of the intercepted instances. This requires the generated class to implement an extra method. I will describe how it works at the end of the article, as this is unrelated to transactions.
  4. To handle transactions, the class needs a TransactionalManager field. Adding the field is very straightforward.
  5. The class must have a constructor that will call super(requiredDependencies) and set the transactionManager field.
  6. The class will override the methods annotated in its superclass.
  7. The generated code will be stored in the JavaFile object to be written in a real file later.

Now, having the high-level view, we can dive into the details where needed. So let’s start with writing the constructor.

Constructor

To provide the transactional capability, the constructor must call the constructor of its superclass via the super keyword, passing the parameters in the correct order. The transactionManager field of the intercepting class also must be populated.

  1. The first thing that is done to create a constructor is to find out the intercepted class's dependencies. To do it conveniently, we will reuse TypeDependencyResolver, created for the DI solution. You can read more about it here.
  2. Having the dependencies of the superclass, we can create parameters for the constructor. The transactionManager is the first parameter, and the rest is provided conveniently as Type ${position in the constructorParameters list}.
  3. Having the intercepting class constructor params, we can prepare the content of the super call. Then it can be added to the super call in the constructor.
  4. The last thing to do is to also set up the transactionManager field.

The generated constructor may look like the code below:

Overriding transactional methods

In the case of generating the methods, we will start with an example.

TransactionManager’s methods are defined with the checked exception. Therefore, transactional methods need to include try/catch blocks. In the try block, the begin and commit must be called, and a rollback in the catch clause. If the return type isn’t void, the result of super method call results must be stored in a variable.

So the high-level method to generate such a call looks like this:

In this and the previous article of this series, I have shown a lot of code, mainly containing JavaPoet usage. I hope that now you get how the JavaPoet works. From now on, I will minimise the boilerplate JavaPoet code by omitting it in the examples or sharing it as Gists. The full code is still present in the repository, of course.

Catch and try blocks

The try and catch blocks code are quite simple. So as mentioned before, here are the gists:

https://gist.github.com/JacekDubikowski/167bcaaab151f9d4ad6f033ef1543cec

https://gist.github.com/JacekDubikowski/8b691faf3e0aba2e04d03211823794ff

Super method calls

Once we have try and catch blocks handled, we can focus on the actual super method call.

The code generation is really simple here.

  1. The first step is deciding upon the method call based on the return type of the super method.
  2. A void call is generated. This is very simple, as there is no need to store the results of the super call.
  3. Finally, the value-returning method call is generated. The result is stored in a variable to be returned after the commit.

The full code of the TransactionalInterceptedWriter.

That’s all for transactions

This is everything I have prepared for you in transaction handling.

The code below could be used, and transaction support can be provided.

However, we must get the expected instance during the runtime, right? Let us check how to make it all work within our framework. To reach the goal, we need two more things.

  1. The TransactionPlugin must be used during compilation.
  2. We must make our framework provide only intercepted instances.

Plugging the transaction handling into the framework

We have seen the code that handles transaction processing. Now, we have to make use of the TransactionalPlugin in our framework. To keep everything simple, I created an interface ProcessorPlugin which will be a way to register extensions. Thanks to that, the whole transaction processing code is held in separate classes.

The interface has three methods.

  1. The init method is responsible for the initialisation of the plugin.
  2. The process method does the actual processing. Therefore, it returns generated Java files.
  3. The reactsTo method provides information about annotation that the plugin is interested in.

The plugins are hardwired so far and are used as presented in the code:

As you can see:

  1. Once the processor is initialised, it also initialises its plugins. In the more real-world code, the plugin discovery could be run here.
  2. The processor starts its processing by running the plugins.
  3. All the plugins are run with elements annotated with the annotation that the plugin reacts to.
  4. The files generated by plugins are written to the actual files in some /generated directory.

Implementation of the ProcessorPlugin for the TransactionalPlugin

In the main part of the article, I omitted some code of the TransactionalPlugin related to the ProcessorPlugin implementation. Now, you can see the missing parts of the code below.

The init method implementation is fairly simple. It just requires setting processingEnv and creating TransactionalMessenger.

  1. The reactsTo method implementation states that the plugin is interested in @Transactional annotation. Who would guess?

The provided code is nothing big. The most exciting thing was the process method shown before.

Provisioning of intercepted class

In the “production” code, the framework must be able to provision the intercepted instances. To make this possible, I introduced the interface below.

This is very simple, yet very important. Thanks to the interface, we can be sure which type has its intercepted version. You may have remembered from the main part that our $Intercepted classes have implemented the interface. So how was this done?

Implementing the interface

The implementation of the interface is quite simple. For the RepositoryA:

It will be implemented as:

In the source code of TransactionalInterceptedWriter, it would just add a few extra lines:

Now, we can differentiate Intercepted classes from regular ones and point out types that have their intercepted versions.

Using only intercepting classes during provisioning

To get only the intercepted version and not the original one, we need to update the BaseBeanProvider. The simplified code was shown in the previous part about DI. Now, it needs an extra step.

  1. Firstly, we find all the beans matching the needed type.
  2. Then, we find the beans that implement the Intercepted type.
  3. In the end, we return a list of the matching beans filtering out the beans among the types with their intercepted version.

It works

Now the whole solution works as expected. The framework provides the $Intercepted instances that handle transactions for us. That is enough for today. Class dismissed.

In the next and final part, we will look at RestControllers, so stay tuned!

--

--