Spring Data JPA Transactions and Queries

The last but not least part of my series of articles on Spring Data JPA guide, employing best practices to help you keep the code base clean and avoid common mistakes.

This guide was initially written based on observations from a huge legacy project, and to promote good practices, I collected all the problems, systematized them, and shared them with my former colleagues. The guide is updated and completely rewritten to utilize the latest features of Spring Data JPA.

Other articles in the series:

Some of the examples may seem obvious, but they’re not. It’s only from your experienced perspective. They’re real examples from the production code base.

Keep in mind that this series of articles explains the latest version of Spring Data JPA, so there may be nuances that I’ll highlight differently.

1 Manual and automatic transaction management

Imagine you need custom logic to work with your database, such as managing transactions, but Spring Data JPA repositories are insufficient for you. So what could you do? Spring Data JPA offers various approaches to achieve this.

1.1 Using EntityManager to manage transactions

You can use the JPA EntityManager together with EntityTransaction to manage transactions in Spring manually, as it provides low-level access to the persistence context and database operations, but managing transactions yourself quickly becomes error-prone and requires a lot of careful attention.

There is one more catch: you cannot use the injected EntityManager to start a transaction directly. In a typical Spring setup, that EntityManager is a container-managed proxy, and Spring expects you to use its own transaction management mechanisms instead.

Creating your own EntityManager from an EntityManagerFactory and using JPA’s EntityTransaction API is technically possible, but it bypasses Spring’s transaction infrastructure and is generally discouraged except in very special cases.

Let’s examine a small code example that utilizes the entity manager to manage transactions.

try (EntityManager em = emf.createEntityManager()) {
    EntityTransaction tx = em.getTransaction();
    try {
        tx.begin();
        em.persist(new MyEntity());
        tx.commit();
    } catch (Exception e) {
        if (tx.isActive()) tx.rollback();
        throw e;
    }
}

Ouch! There is a lot of code. You should always use the entity manager factory to create an instance of the manager. Be careful, as it should be closed at the end of the logic. Additionally, ensure that commits and rollbacks are placed. That looks like a burden with different ways to shoot yourself in the foot.

I couldn’t recommend this approach to managing transactions, but real life has plenty of nuances, and for some reason, you may have to use it. Actually, I used it before (spoiler: it was a legacy project), as I had complex low-level logic to manage database transactions.

1.2 Using TransactionTemplate

Manual transaction management with an entity manager is a headache, but Spring @Transactional annotation too magical? There is a solution in the middle, it’s a TransactionTemplate.

This approach gives you the best of both worlds: you get programmatic control over exactly where a transaction starts and ends, but you let Spring handle commits, rollbacks, and closings of the entity manager.

The beauty here is that, unlike the manual approach, you can use the injected EntityManager. Because the transaction template interacts directly with Spring’s PlatformTransactionManager, it correctly binds the transaction to the current thread, allowing the standard container-managed proxy to function exactly as intended.

Look at this! The explicit try-catch-finally block is gone, along with the closing entity manager.

@Service
@RequiredArgsConstructor
public class CustomTransactionService {
    private final PlatformTransactionManager txManager;
    private final EntityManager em;
    public void saveSafely() {
        TransactionTemplate tmplt = new TransactionTemplate(txManager);
        tmplt.executeWithoutResult(status -> {
            em.persist(new MyEntity()); 
            // No manual commit needed; it happens automatically on exit.
        });
    }
}

If your code inside the lambda throws a RuntimeException, the template automatically marks the transaction for rollback. If you need to roll it back based on a specific condition without throwing an exception, you can simply call status.setRollbackOnly().

This is the preferred way to write “custom” transaction logic in the Spring ecosystem. It keeps your code clean, concise, and integrated with the container’s lifecycle.

1.3 Using @Transactional annotation

Now we arrive at the most common approach. Why write code to manage transactions when you can just declare them with some Spring magic? This is the declarative style, and it is likely what I’ve seen in many Spring applications.

Instead of injecting managers or creating templates, you simply annotate your method (or class) with @Transactional. Spring’s AOP infrastructure takes over, wrapping your bean in a proxy that handles the begin, commit, and rollback logic for you.

It looks so simple, you focus entirely on business logic, and the container handles the atomicity.

@Service
@RequiredArgsConstructor
public class UserService {
    private final UserRepository userRepository;
    @Transactional
    public void createUser(String name) {
        userRepository.save(new User(name));
        // If an unchecked exception (RuntimeException) is thrown here, 
        // the transaction rolls back automatically.
    }
}

However, there are many nuances. I’ll briefly explain some of them. However, the subject actually warrants a dedicated article.

Since it relies on Spring proxies, the interception only happens when the method is called from outside the bean. If you call a transactional method from another method within the same class (self-invocation), the proxy is bypassed, and no transaction is started.

@Service
public class TicketService {
    // 1. External caller invokes this method (Proxy intercepts here)
    public void bookBatch() {
        // 2. Inside the class, "this" refers to the raw Target object, not the Proxy.
        // 3. PROBLEM: Calling a @Transactional method from within the same class.
        // The Proxy is completely bypassed. No new transaction is started.
        reserveSeat(); 
    }
    @Transactional
    public void reserveSeat() {
        // Logic that EXPECTS a transaction, but won't get one 
        // if called from bookBatch().
        repo.save(new Ticket());
    }
}

In this scenario, if you call bookBatch() from a Controller, Spring intercepts that first call, but when bookBatch() calls reserveSeat(), it is a direct Java method call inside the instance. Spring’s proxy wrapper is sitting outside, unaware that reserveSeat() is running, so the spring transaction annotation is ignored.

What else? Spring Proxies cannot intercept private methods. If you do so, Spring will silently ignore your annotation without errors or warnings, which could lead to significant problems and make your app unreliable. The best way to mark this annotation is for public methods only.

One common bug in Spring transaction management is that transactions are always rolled back in the event of any exceptions. But it’s not, Spring only rolls back for RuntimeException (unchecked) with its child and Error. It does not rollback for checked exceptions (like IOException or your custom BusinessException if it extends from Exception).

Best practices here? Tell Spring what to rollback or extend your exceptions from RuntimeException and you’ll sleep well.

Using spring transactional annotation is a better approach than using an entity manager to manage transactions, as it eliminates the need for boilerplate code. However, you have to spend time learning all the nuances of using this annotation. Your education is a foundation for a better future for your applications.

2. Manual Querying

Repositories are fantastic, and they could close 90% of your work. But for the last 10 percent, you’ve to write some queries in a less abstract way. For example, you need a complex report, a specific join, or a database-specific function that Spring Data’s method naming convention just can’t handle. You could use mentioned before EntityManager.

One important note: EntityManager is a good way to query, but many tasks can be resolved with JPA repositories, as it provides a way to write native queries, and you don’t need to write boilerplate code as above. If your project has a convention for using an entity manager or other reasons, use it. Otherwise, give it a try to write queries with Spring Data JPA repositories.

2.1 Query vs TypedQuery

When you create a query in JPA, you have two choices. You can use the raw Query interface, or the generic TypedQuery. The raw Query interface returns Object or List<?>. It tells the compiler, “Trust me, I know what I’m doing.” This forces you to manually cast the result, which is a code smell and a potential runtime crash waiting to happen.

Just look at this example. There is casting data without verification, which could lead to different runtime problems; it’s better to avoid it.

public User getUserViaSimpleQuery(Long id) {
    // Returns a raw Query. We have to cast (User) manually.
    Query query = entityManager.createQuery(
        "SELECT u FROM User u WHERE u.id = :id"
    );
    query.setParameter("id", id);
    return (User) query.getSingleResult();
}

Always prefer TypedQuery. It carries the type information with it, allowing you to delete those unchecked casts and let the compiler help you.

public User getUserViaTypedQuery(Long id) {
    // Pass the class type! Now we get TypedQuery<User>
    TypedQuery<User> query = entityManager.createQuery(
        "SELECT u FROM User u WHERE u.id = :id", User.class
    );
    query.setParameter("id", id);
    return query.getSingleResult();
}

2.2 Native Queries

Sometimes JPQL isn’t enough. You need to use database-specific features (like CONNECT BY in Oracle or specific window functions). This is a place where createNativeQuery should be used. But there is a problem.

If you are fetching a whole Entity, you can still map it. But if you are fetching a single column (scalar value), things get messy. In legacy code, I saw that developers were assuming that if a database column is a number, Java would return a Long.

public Long getActiveVersionId() {
    Query query = entityManager.createNativeQuery(
        "select id from utl_rep_version_cfg where rownum = 1"
    );
    // DANGER: In Oracle, this returns BigDecimal. 
    // Casting (Long) throws ClassCastException!
    return (Long) query.getSingleResult(); 
}

If you run this against Oracle, your app will throw exceptions. The JDBC driver often maps numbers to BigDecimal or BigInteger, and you cannot cast an object of type BigDecimal directly to Long.

When dealing with native queries and scalars, treat the result as a generic Number. It may seem verbose, but this approach is safer than the previous one. Use it or not; it’s only your choice. However, I suggest learning a bit about JDBC data mapping, especially for your DBMS. For Oracle, you can refer to this documentation.

public Long getActiveVersionId() {
    Query query = entityManager.createNativeQuery(
        "select id from utl_rep_version_cfg where rownum = 1"
    );
    Object result = query.getSingleResult();
    // Safely convert whatever number the DB driver returns
    return result != null ? ((Number) result).longValue() : null;
}

2.3 The Criteria API

If TypedQuery is safe, the Criteria API adds more type safety. It allows you to build queries programmatically, protecting you from typical problems. However, I must admit that I dislike the criteria code because it is overly wordy. It’s hard to read. It looks like you are writing an abstract syntax tree instead of SQL. However, it is the only safe way to build dynamic queries where filters change at runtime.

Unfortunately, I have seen this a lot. One of the common security bugs is passing parameters with string concatenation. This approach leads to problems because user input is not sanitized properly and becomes part of the raw SQL query. To solve this, you could use named parameters and pass them safely, or you could also utilize the criteria API for this purpose.

The following example is not complex and highlights only the SQL injection issue. In the real world, developers often use a criteria API to build custom filters depending on user input.

// BAD: Concatenation logic that creates a wide-open security hole
public boolean login(String username, String password) {
    
    // DANGER: We are pasting user input directly into the query string.
    String hql = "SELECT count(u) FROM User u " + 
                 "WHERE u.username = '" + username + "'" + 
                 "AND u.password = '" + password + "'";
    
    Long count = (Long) entityManager.createQuery(hql).getSingleResult();
    return count > 0;
}

Let’s take a look at the alternative criteria: Yes, this is a lot of code, but we’ve introduced simple logic. If the password is missing, it shouldn’t be used in the WHERE clause. When using simple queries, string concatenation is required, which can lead to security issues. Here, you’re avoiding this, bringing more flexibility, but overall, it adds complexity to your codebase.

public boolean loginSafe(String username, String password) {
    CriteriaBuilder cb = entityManager.getCriteriaBuilder();
    CriteriaQuery<Long> cq = cb.createQuery(Long.class);
    Root<User> user = cq.from(User.class);
    
    // 1. We want to count users
    cq.select(cb.count(user));
    
    // 2. Build predicates dynamically
    List<Predicate> predicates = new ArrayList<>();
    predicates.add(cb.equal(user.get("username"), username));
    
    // Example of dynamic logic: simple IF statement, no string mess
    if (password != null) {
        predicates.add(cb.equal(user.get("password"), password));
    }
    
    cq.where(predicates.toArray(new Predicate[0]));
    
    // 3. Execute safely
    return entityManager.createQuery(cq).getSingleResult() > 0;
}

As I mentioned earlier, I don’t like this approach because it’s too wordy, contains a lot of code, and adds complexity. However, on the other hand, you could use string concatenation. It’s not pure evil. With a proper approach, there wouldn’t be a problem, but it’s a slippery road you’ve to avoid.

2.4 Passing parameters into queries

We have discussed how to write the queries, and in the example code, I used named parameters to pass parameters safely. This may seem trivial, but in a large codebase, differences in passing parameters can cause significant issues. There are generally two ways to pass parameters: positional (?) and named (:id). If you value your sanity and the time of your colleagues, you should have a strong preference for this approach. For me, it’s named parameters.

Positional parameters are a relic. They depend entirely on the order of arguments. If a developer comes along six months later and adds a new condition in the middle of your WHERE clause, the parameter indexes shift, and your code breaks – often silently or with confusing data errors.

Look at this fragile code. You need to mentally prepare to update and verify the passed parameters and their indexes. To me, it’s not as cool as it seems, as it leads to problems. If you could avoid problems from the start, it’s better to avoid them.

TypedQuery<User> q = em.createQuery(
    "SELECT u FROM User u WHERE u.id = ?1 AND u.status = ?2",
    User.class
);
q.setParameter(1, id);
q.setParameter(2, Status.ACTIVE);
//and with native query
Query q = em.createNativeQuery(
    "SELECT * FROM users WHERE id = ? AND status = ?",
    User.class
);
q.setParameter(1, id);
q.setParameter(2, "ACTIVE");

After seeing this example, I hope you find named parameters more effective, as they provide a clearer way to keep your code clean and maintainable. Just look at this example. This is kind of self-documented code. The parameter names tell us what they are, and we don’t need to concern ourselves with indexes for now.

TypedQuery<User> q = em.createQuery(
    "SELECT u FROM User u WHERE u.id = :id AND u.status = :status",
    User.class
);
q.setParameter("id", id);
q.setParameter("status", Status.ACTIVE);

2.5 Calling stored procedures

You can also call stored procedures via the EntityManager.
The usual pattern is:

  1. Create a StoredProcedureQuery by specifying the procedure name
  2. Register all parameters with registerStoredProcedureParameter
  3. Set the parameter values

In many codebases, these last two steps differ – registering a parameter, setting it, and then registering the next one. This makes the code jump back and forth between two ideas (declaring parameters and providing values). I find it clearer to first register all parameters and only then set their values, but this is mostly a matter of preference.

public void execute(Long userId) {
    StoredProcedureQuery spq =
        em.createStoredProcedureQuery("pkg_base_computing.recalc");
    spq.registerStoredProcedureParameter("p_user_id",   Long.class, ParameterMode.IN);
    spq.setParameter("p_user_id", userId);
    spq.registerStoredProcedureParameter("p_result_id", Long.class, ParameterMode.OUT);
    spq.execute();
}

For better readability, first register all parameters and then assign their values.
It’s a small stylistic change, but it slightly improves readability and maintainability, because with a quick glance, you can see which parameters the procedure uses.

public void execute(Long userId) {
    StoredProcedureQuery spq =
            em.createStoredProcedureQuery("pkg_base_computing.recalc");
    spq.registerStoredProcedureParameter("p_user_id",   Long.class, ParameterMode.IN);
    spq.registerStoredProcedureParameter("p_result_id", Long.class, ParameterMode.OUT);
    spq.setParameter("p_user_id", userId);
    spq.execute();
}

3 Projection data with EntityManager

In the previous article about Spring Data JPA repositories, we discussed how to project data into DTOs or Interfaces. However, when using EntityManager, you must handle projections manually. You generally have three options: mapping an entity after fetching it, selecting directly into a DTO, or dealing with raw data.

3.1 Mapping entity to DTO with mapping libraries

The easiest way to get a DTO is to fetch the Entity and map it. Libraries like MapStruct or ModelMapper make your code cleaner and more readable (probably).

public UserDto getUserDtoViaMap(Long id) {
    // 1. Fetches the WHOLE entity (all columns)
    User user = entityManager.find(User.class, id);
    
    // 2. Maps it in memory
    UserDto userDto = new UserDto();
    modelMapper.map(user, userDto);
    return userDto;
}

This is functionally correct. Mapping libraries use some magic to generate the mapping logic, and while they might introduce a tiny bit of CPU overhead (especially reflection-based ones like ModelMapper), they generally make your work easier.

3.2 Mapping using a constructor

If you use JPQL, you can construct the DTO directly inside the query. This query is faster because the database only sends the columns you asked for. However, there is one major problem: you must specify the full path to the DTO.

Why is this a problem? If you rename your DTO class or move it to a different package, the string in your query won’t update automatically. The compiler misses this problem, and it will only appear as a RuntimeException when the code is executed. IntelliJ IDEA is smart enough to catch this and highlight it, but only if you analyze your entire codebase or open the specific file. If you miss the IDE warning, you are shipping a bug.

Here is a small example of using constructor expressions:

public UserDto getUserDtoViaQuery(Long id) {
    // Note: You must use the fully qualified class name!
    TypedQuery<UserDto> query = entityManager.createQuery(
        "SELECT new com.app.dto.UserDto(u.username, u.email) " +
        "FROM User u WHERE u.id = :id",
        UserDto.class
    );
    query.setParameter("id", id);
    return query.getSingleResult();
}

3.3 Mapping with Tuple and Object Array

When you are forced to use Native SQL, you can’t use the new Class(...) syntax. This leaves you with a choice: the raw Object array or strictly typed Tuples.

Actually, it’s insane, it’s scary as hell, but I see this often. Let me introduce – using object arrays for data mapping. You get an array of generic objects, and you have to know which index corresponds to which column and what type it is. If something changes in the database or you modify the indexes, an exception occurs.

// BAD: Object[] projection (Hard to read, error-prone)
public List<UserDto> getActiveUsersBad() {
    List<Object[]> results = entityManager.createNativeQuery(
        "SELECT id, username, is_active FROM users WHERE is_active = 1"
    ).getResultList();
    List<UserDto> dtos = new ArrayList<>();
    
    for (Object[] row : results) {
        // MAGIC NUMBERS! What is row[1]? What is row[2]?
        // You have to manually cast everything.
        Long id = row[0] != null ? ((Number) row[0]).longValue() : null;
        String username = (String) row[1];
        Boolean active = (Boolean) row[2]; // Hope this isn't a BigDecimal in Oracle!
        
        dtos.add(new UserDto(id, username, active));
    }
    return dtos;
}

The better way is to use Tuple interface. It allows you to access results by alias (name) rather than index. It is cleaner, safer, and self-documenting.

// BETTER: Tuple projection with aliases
public List<UserDto> getActiveUsersSafe() {
    // 1. Note the aliases (AS id, AS username)
    List<Tuple> tuples = entityManager.createNativeQuery(
        "SELECT u.id AS id, u.username AS username, u.is_active AS active " +
        "FROM users u WHERE u.is_active = 1",
        Tuple.class
    ).getResultList();
    List<UserDto> dtos = new ArrayList<>();
    
    for (Tuple t : tuples) {
        // 2. Fetch by name and type. No casting needed.
        Long id = t.get("id", Long.class);
        String username = t.get("username", String.class);
        Boolean active = t.get("active", Boolean.class);
        
        dtos.add(new UserDto(id, username, active));
    }
    return dtos;
}

Using Tuple makes your intent clear. You are telling the next developer exactly what columns you expect, and you don’t care what order they are in.

At the end

That was a tough series of articles. I’m pleased to say that I’ve finally adopted my biggest guide and published it in three separate articles. I’ve more ideas for upcoming articles, and if you enjoy my articles, please connect with me on LinkedIn to stay informed about new content.

If you missed the previous articles, here they are:

Avatar photo
Dmitry Protsenko

Senior Software Engineer
Specialized on Java / Kotlin and CyberSecurity
Author of this blog

Articles: 34