Detached Entities

How Entities Become Detached

  • The transaction ends: in Spring Boot the entity returned from a @Transactional service method is detached in the controller.
  • em.detach(entity), em.clear() or em.close().
  • The object is serialized (e.g. sent to another service) and deserialized again.

Changes to Detached Objects Are Lost

@RestController
class ProductController {
    @PutMapping("/api/products/{id}/stock")
    public Product setStock(@PathVariable Long id, @RequestParam int stock) {
        Product p = productService.get(id);   // transaction ended -> p is DETACHED
        p.setStock(stock);                    // nobody is watching: NO UPDATE
        return p;                             // response shows the new stock, database does not!
    }
}

The fix: do the change inside a transactional service method, where the entity is managed:

@Transactional
public Product setStock(Long id, int stock) {
    Product p = repo.findById(id).orElseThrow();   // managed
    p.setStock(stock);                             // UPDATE at commit
    return p;
}

Reattaching with merge

Example

The detached object keeps its own changes, but only the copy returned by merge is managed. A common bug is to keep using the old reference:

import java.util.*;

public class Main {
    static Map<Long, Product> context = new LinkedHashMap<>();
    static Map<Long, Integer> table = new TreeMap<>(Map.of(42L, 120));

    public static void main(String[] args) {
        Product detached = new Product(42L, 120);     // loaded in an earlier, finished transaction
        detached.stock = 100;                          // changed while detached

        Product managed = merge(detached);
        System.out.println("managed.stock  = " + managed.stock + ", same object? " + (managed == detached));

        detached.stock = 5;                            // BUG: changing the detached copy
        flush();
        System.out.println("database stock = " + table.get(42L));

        managed.stock = 90;                            // correct: change the managed instance
        flush();
        System.out.println("database stock = " + table.get(42L));
    }

    static Product merge(Product d) {
        Product m = context.computeIfAbsent(d.id, id -> new Product(id, table.get(id)));  // SELECT
        m.stock = d.stock;                                                               // copy state
        return m;
    }

    static void flush() { context.values().forEach(p -> table.put(p.id, p.stock)); }
}

class Product {
    final Long id; Integer stock;
    Product(Long id, Integer stock) { this.id = id; this.stock = stock; }
}

Output

managed.stock  = 100, same object? false
database stock = 100
database stock = 90

Tip: Rule of thumb: product = em.merge(product); — always reassign the result of merge.