Skip to main content

Handling Database Transactions in Hexagonal Architecture Without Leaking Abstractions

 

The Blocker: The "Transactional" Annotation Trap

You have meticulously designed your Hexagonal Architecture (Ports & Adapters). You have a pristine Domain layer and a segregated Application (Use Case) layer. You have stripped away all frameworks from your core.

Then, you write a Use Case that requires atomicity:

  1. Deduct inventory.
  2. Charge the credit card.
  3. Save the order.

If any step fails, all must fail. The instinct is to reach for a framework-specific annotation (like Spring’s @Transactional or NestJS interceptors) and slap it on the Use Case method.

Stop.

The moment you import org.springframework.transaction.annotation.Transactional (or similar) into your Use Case layer, you have violated the Dependency Rule. Your inner circle now depends on a framework mechanism living in the outer circle. You are no longer framework-agnostic. If you swap the persistence adapter or the framework, your business logic breaks.

The Root Cause: Mixing Business Boundaries with Technical Mechanisms

The tension arises because we confuse Atomicity (a business rule) with Database Transactions (a technical mechanism).

  1. Business Requirement: "This series of operations must happen all at once or not at all." This belongs in the Application/Use Case layer.
  2. Technical Implementation: "Open a JDBC connection, set auto-commit to false, run SQL, commit/rollback." This belongs in the Infrastructure layer.

The goal is to allow the Use Case to define the boundary of atomicity without knowing how that atomicity is enforced mechanically. To fix this, we must invert the control of the transaction lifecycle using a Port.

The Solution: The Transaction Runner Pattern

We will solve this by defining a functional interface in the Application Core (Port) and implementing it in the Infrastructure layer (Adapter).

1. Define the Transaction Port (Application Layer)

First, define an interface in your Use Case/Application layer. This interface strictly uses language-level features, not library specific types.

package com.company.core.application.ports.output;

import java.util.function.Supplier;

/**
 * A functional interface for defining atomic boundaries.
 * Living in the Core, it has zero dependencies on frameworks/DBs.
 */
public interface TransactionOperations {
    
    /**
     * Executes the provided logic within a single transaction context.
     * @param operation The business logic to execute.
     * @return The result of the operation.
     */
    <T> T executeInTransaction(Supplier<T> operation);

    /**
     * overload for void operations
     */
    void executeInTransaction(Runnable operation);
}

2. Implement the Adapter (Infrastructure Layer)

In your infrastructure layer (e.g., Spring Boot), implement this interface. This is where you import your framework-specific transaction managers.

package com.company.infra.adapters.persistence;

import com.company.core.application.ports.output.TransactionOperations;
import org.springframework.stereotype.Component;
import org.springframework.transaction.support.TransactionTemplate;
import org.springframework.transaction.PlatformTransactionManager;

@Component
public class SpringTransactionAdapter implements TransactionOperations {

    private final TransactionTemplate transactionTemplate;

    public SpringTransactionAdapter(PlatformTransactionManager transactionManager) {
        this.transactionTemplate = new TransactionTemplate(transactionManager);
    }

    @Override
    public <T> T executeInTransaction(java.util.function.Supplier<T> operation) {
        return transactionTemplate.execute(status -> {
            try {
                return operation.get();
            } catch (RuntimeException ex) {
                // Mark for rollback ensures the framework handles the connection reset
                status.setRollbackOnly();
                throw ex;
            }
        });
    }

    @Override
    public void executeInTransaction(Runnable operation) {
        transactionTemplate.executeWithoutResult(status -> {
            try {
                operation.run();
            } catch (RuntimeException ex) {
                status.setRollbackOnly();
                throw ex;
            }
        });
    }
}

3. Usage in the Use Case (Application Layer)

Now, inject the TransactionOperations interface into your Use Case. You can now define transaction boundaries programmatically without importing infrastructure code.

package com.company.core.application.usecases;

import com.company.core.application.ports.input.PlaceOrderUseCase;
import com.company.core.application.ports.output.InventoryPort;
import com.company.core.application.ports.output.OrderRepositoryPort;
import com.company.core.application.ports.output.TransactionOperations;
import com.company.core.domain.Order;
import com.company.core.domain.command.PlaceOrderCommand;

public class PlaceOrderService implements PlaceOrderUseCase {

    private final InventoryPort inventoryPort;
    private final OrderRepositoryPort orderRepository;
    private final TransactionOperations tx; // The abstraction

    public PlaceOrderService(
        InventoryPort inventoryPort,
        OrderRepositoryPort orderRepository,
        TransactionOperations tx
    ) {
        this.inventoryPort = inventoryPort;
        this.orderRepository = orderRepository;
        this.tx = tx;
    }

    @Override
    public Order placeOrder(PlaceOrderCommand command) {
        // We defer the execution to the implementation of tx
        return tx.executeInTransaction(() -> {
            
            // 1. Domain Logic: Check and decrement inventory
            boolean available = inventoryPort.checkAvailability(command.productId());
            if (!available) {
                throw new IllegalStateException("Product out of stock");
            }
            inventoryPort.decrement(command.productId(), command.quantity());

            // 2. Domain Logic: Create Order
            Order newOrder = Order.create(command.productId(), command.quantity());
            
            // 3. Persistence
            return orderRepository.save(newOrder);
            
            // If any line above throws an exception, the Adapter 
            // catches it and rolls back the DB transaction.
        });
    }
}

Why This Works

1. Inversion of Control

Instead of the Use Case asking the database for a connection, the Use Case hands a unit of work (a lambda) to an interface. The infrastructure layer decides how to run that unit of work (opening a Hibernate session, starting a JDBC transaction, etc.).

2. Zero Leakage

Look at the imports in PlaceOrderService. There are no references to java.sql.*javax.persistence.*, or org.springframework.*. The core remains pure Java/Kotlin/TypeScript.

3. Explicit Boundaries

Annotations like @Transactional are often "magic." They rely on AOP proxies. If you call a transactional method from within the same class, the proxy is bypassed, and the transaction fails silently.

By using tx.executeInTransaction(...), the scope of the atomic operation is visually explicit in the code block. You can clearly see exactly which lines participate in the transaction and which do not (e.g., sending an email confirmation might be placed outside the lambda).

4. Testability

Testing the Use Case becomes trivial. You do not need an in-memory database or a Spring Context to test the logic flow. You can mock TransactionOperations to simply execute the runnable immediately.

// JUnit 5 / Mockito Example
@Test
void shouldPlaceOrder() {
    // Setup
    TransactionOperations mockTx = new TransactionOperations() {
        @Override
        public <T> T executeInTransaction(Supplier<T> s) { return s.get(); } // Pass-through
        @Override
        public void executeInTransaction(Runnable r) { r.run(); }
    };
    
    var service = new PlaceOrderService(mockInventory, mockRepo, mockTx);
    
    // Execute
    Order result = service.placeOrder(command);
    
    // Verify
    verify(mockRepo).save(any(Order.class));
}

Conclusion

Hexagonal Architecture requires strict discipline. While databases are infrastructure, the need for atomicity is business logic. By representing "Transaction" as a functional interface (a Port) rather than an annotation, you satisfy the business requirement while keeping the implementation details securely locked in the adapter layer where they belong.