Skip to main content

Where to Place @Transactional in Hexagonal Architecture: Use Cases vs Adapters

 Migrating to Hexagonal Architecture (Ports and Adapters) promises decoupled code and domain-centric design. However, it introduces a specific friction point for Spring Boot developers: Transaction Management.

In a traditional Layered Architecture, placing @Transactional on the Service layer is muscle memory. In Hexagonal, the "Service" is split into Use Cases, Input Ports, and Application Services. If you misplace the transaction boundary, you face two critical failures: the dreaded LazyInitializationException during DTO mapping, or worse, data corruption due to partial commits when a business operation spans multiple driven adapters.

This guide defines strictly where @Transactional belongs in a robust Hexagonal setup, why it belongs there, and how to implement it using Java 21 and Spring Boot 3.

The Root Cause: Why Boundaries Break

To understand where to put the annotation, we must understand what Spring and Hibernate are doing under the hood.

1. The Proxy Problem

Spring manages transactions via AOP (Aspect-Oriented Programming) proxies. When you annotate a method, Spring wraps that class. The transaction opens before the method enters and commits after the method exits (if no exception is thrown).

If you place @Transactional on a Driven Adapter (e.g., your JPA Repository implementation), the transaction scope is too small. If a Use Case calls adapterA.save() and then adapterB.update(), they run in two separate transactions. If adapterB fails, adapterA has already committed. Your system is now in an inconsistent state.

2. The Hibernate Session Lifecycle

LazyInitializationException occurs when you try to access a lazily loaded relationship (like a list of Orders on a Customer entity) after the Hibernate Session has closed.

In Hexagonal Architecture, strict separation dictates that Domain Entities should not leak into the UI/Web layer. If you return a Domain Entity to the Controller and the Controller tries to map it to a JSON DTO, the transaction is already closed. The proxy is gone. Accessing the lazy collection throws the exception.

The Golden Rule: Transactional Use Cases

The transaction boundary must match the Business Use Case boundary.

In Hexagonal terms, the Application Service (which implements the Input Port/Use Case interface) is the correct place for @Transactional. This ensures that every operation within that business action—whether it involves fetching data, modifying domain state, or saving to multiple output ports—happens atomically.

Implementation Guide

Let's implement a scenario: Registering a Customer. This requires saving the customer profile and initializing a loyalty account in a separate table. These must occur atomically.

1. The Domain and Ports (No Frameworks)

First, define the domain logic and the ports. Note the absence of Spring dependencies here.

package com.example.domain;

import java.util.UUID;

// Domain Entity
public class Customer {
    private final UUID id;
    private String email;
    private boolean active;

    public Customer(UUID id, String email) {
        this.id = id;
        this.email = email;
        this.active = true;
    }

    // Domain Logic
    public void activate() {
        this.active = true;
    }

    public UUID getId() { return id; }
    public String getEmail() { return email; }
}

// Output Port (Driven Port)
public interface SaveCustomerPort {
    void save(Customer customer);
}

// Output Port (Driven Port)
public interface CreateLoyaltyAccountPort {
    void createAccount(UUID customerId);
}

// Input Port (Driver Port / Use Case Interface)
public interface RegisterCustomerUseCase {
    CustomerResponse register(String email);
}

// DTO Record (Java 21)
public record CustomerResponse(UUID id, String email, String status) {}

2. The Application Service (The Transaction Boundary)

This is the critical component. The implementation of the Use Case lives in the application package. This is where we introduce Spring's transaction management.

We force the transaction here to ensure that saveCustomerPort and createLoyaltyAccountPort succeed or fail together. Furthermore, we perform DTO mapping inside this boundary to prevent LazyInitializationException.

package com.example.application;

import com.example.domain.Customer;
import com.example.domain.CustomerResponse;
import com.example.domain.RegisterCustomerUseCase;
import com.example.domain.SaveCustomerPort;
import com.example.domain.CreateLoyaltyAccountPort;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.UUID;

@Service
public class CustomerRegistrationService implements RegisterCustomerUseCase {

    private final SaveCustomerPort saveCustomerPort;
    private final CreateLoyaltyAccountPort loyaltyPort;

    public CustomerRegistrationService(SaveCustomerPort saveCustomerPort, 
                                       CreateLoyaltyAccountPort loyaltyPort) {
        this.saveCustomerPort = saveCustomerPort;
        this.loyaltyPort = loyaltyPort;
    }

    @Override
    @Transactional // The Boundary Start
    public CustomerResponse register(String email) {
        // 1. Domain Logic
        var newCustomer = new Customer(UUID.randomUUID(), email);
        newCustomer.activate();

        // 2. Interaction with Output Ports
        saveCustomerPort.save(newCustomer);
        
        // If this fails, the customer save is rolled back
        loyaltyPort.createAccount(newCustomer.getId());

        // 3. Mapping to DTO within Transaction
        // Prevents LazyInitializationException because the session is open
        return new CustomerResponse(
            newCustomer.getId(), 
            newCustomer.getEmail(), 
            "REGISTERED"
        );
    } // The Boundary End (Commit/Rollback)
}

3. The Driven Adapters (Persistence)

The adapters implement the output ports. These interact with JPA Repositories. Do not put @Transactional here unless you specifically require a REQUIRES_NEW propagation (which is rare). They should participate in the existing transaction opened by the Use Case.

package com.example.adapter.out.persistence;

import com.example.domain.Customer;
import com.example.domain.SaveCustomerPort;
import org.springframework.stereotype.Component;

@Component
class CustomerPersistenceAdapter implements SaveCustomerPort {

    private final SpringDataCustomerRepository repository;
    private final CustomerMapper mapper;

    public CustomerPersistenceAdapter(SpringDataCustomerRepository repository, 
                                      CustomerMapper mapper) {
        this.repository = repository;
        this.mapper = mapper;
    }

    @Override
    public void save(Customer customer) {
        var entity = mapper.toEntity(customer);
        repository.save(entity);
        // No explicit commit here; relies on upstream transaction
    }
}

Why This Fixes LazyInitializationException

By returning a CustomerResponse (DTO) record instead of the Customer domain entity (or the JPA entity), we force the data loading to happen inside the register method.

Because register is annotated with @Transactional, the Hibernate Session remains open during the mapping process. If the domain entity had lazy collections, accessing them during DTO construction would trigger the necessary SQL queries safely.

Once the method returns the DTO to the Web Adapter (Controller), the transaction closes. Since the Controller only holds the DTO (which is just data), no database connection is needed to render the JSON response.

Pitfalls and Edge Cases

1. Read-Only Use Cases

For Use Cases that only fetch data (queries), use @Transactional(readOnly = true). This offers significant performance benefits. Hibernate skips dirty checking on entities, and Spring can optimize the JDBC connection flags.

@Override
@Transactional(readOnly = true)
public CustomerResponse getCustomer(UUID id) {
    // Fetch and map
}

2. Self-Invocation

If you have a method processBatch() that calls register() within the same class, the @Transactional on register() will be ignored.

Spring AOP proxies only work when methods are called from outside the bean. In Hexagonal Architecture, this is rarely an issue because the Web Adapter calls the Use Case interface, ensuring the proxy is triggered.

3. Side Effects (Emails/Queues)

If your Use Case sends an email via an Output Port, and the database transaction rolls back, you cannot "un-send" the email.

Solution: Do not execute side effects directly in the transaction. Instead, publish an application event. Use @TransactionalEventListener to execute the side effect only after the transaction successfully commits.

@Component
public class WelcomeEmailListener {

    private final EmailPort emailPort;

    // Only runs if the transaction commits successfully
    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    public void onCustomerRegistered(CustomerRegisteredEvent event) {
        emailPort.sendWelcomeEmail(event.email());
    }
}

Conclusion

In Hexagonal Architecture, the Application Service (Use Case Implementation) is the orchestrator of business logic. Therefore, it owns the unit of work.

By placing @Transactional strictly on the Use Case implementation, you ensure data consistency across multiple adapters and solve lazy loading issues by mapping DTOs within the transactional boundary. Keep your adapters dumb, your domain pure, and your transactions centered in the application layer.