সরাসরি পর্বে যান
পর্ব ৫২ · ৫৯-এর মধ্যে · মডিউল ৭

Spring Data JPA আর Hibernate — entity, relationship আর transaction

Spring Data JPA and Hibernate — Entity Mapping, the Persistence Context, Relationships, N+1, Projections, Transactions and Optimistic Locking

Taught in Bangla, this lesson explains Spring Data JPA and Hibernate: how JPA, Hibernate and Spring Data fit together, entity mapping, the persistence context and dirty checking, owning sides in relationships, LAZY loading and the N+1 problem, derived queries, JPQL, projections and pagination, @Transactional pitfalls, optimistic locking with @Version, and testing.

এই পর্বে যা শিখবেন

  • JPA, Hibernate আর Spring Data JPA-র দায়িত্ব কোথায় আলাদা, আর প্রতিটি সুবিধার পেছনে কোন SQL চলে
  • entity mapping: id তৈরির কৌশল, column-এর শর্ত, enum, embeddable value object
  • persistence context, entity-র জীবনচক্র, dirty checking আর flush কখন হয়
  • relationship, cascade, orphanRemoval, LAZY বনাম EAGER আর N+1 সমস্যার তিনটি প্রতিকার
  • derived query, JPQL, projection, pagination, @Transactional-এর নিয়ম আর @Version দিয়ে oversell ঠেকানো

১ · তিন স্তর — JPA, Hibernate আর Spring Data JPA

পর্ব ৪৯-এ JDBC দিয়ে হাতে SQL লিখেছেন: PreparedStatement বানানো, প্রতিটি parameter বসানো, ResultSet থেকে এক এক করে কলাম পড়ে object বানানো। দশটি টেবিলের একটি অনলাইন দোকানে এই কাজ শত শত লাইনের একঘেয়ে কোড। পর্ব ৫১-এ দেখেছেন, Spring Data JPA-তে শুধু একটি interface লিখলেই findById, save চলে আসে। এই পর্বে সেই জাদুর ভেতরে ঢুকব। কারণ একটি সহজ সত্য: প্রতিটি সুবিধা কিছু SQL লুকিয়ে রাখে। পেশাদার ডেভেলপারকে জানতে হয় কোন SQL চলছে, কখন চলছে, আর কোন transaction-এর ভেতরে।

এখানে তিনটি আলাদা জিনিস একসাথে কাজ করে, আর নামগুলো প্রায়ই গুলিয়ে যায়:

📜 JPA — নিয়মের বই

JPA (এখন নাম Jakarta Persistence) একটি মানক API: @Entity, @Id, @OneToMany-এর মতো annotation, EntityManager interface আর JPQL নামের query ভাষা। এটা শুধু চুক্তি — নিজে কোনো SQL চালায় না।

⚙️ Hibernate — ইঞ্জিন

Hibernate সেই চুক্তির বাস্তবায়ন, Spring Boot-এ ডিফল্ট। entity দেখে SQL বানায়, ফলাফল থেকে object বানায়, বদল খুঁজে বের করে। আরেকটি বাস্তবায়ন EclipseLink. এদের সাধারণ নাম ORM.

🧰 Spring Data JPA — সহকারী

Spring Data JPA EntityManager-এর ওপর একটি স্তর: interface থেকে repository বানায়, method-এর নাম থেকে query বানায়, pagination দেয়। নিচে কাজ করে সেই Hibernate-ই।

একটি garments কারখানার কথা ভাবুন। ক্রেতার দেওয়া মাপের নির্দেশিকা (কোন সাইজে কত ইঞ্চি) হলো JPA — সব কারখানার জন্য একই। যে কারখানা আসলে কাপড় কেটে সেলাই করে, সে Hibernate. আর ফ্লোরের সুপারভাইজার, যিনি “৫০০ পিস M সাইজ” শুনেই কোন লাইনে কী হবে ঠিক করে দেন, তিনি Spring Data JPA. সুপারভাইজার কাজ সহজ করেন, কিন্তু কাপড় নষ্ট হলে খুঁজতে হয় কারখানার ভেতরেই। তাই ভালো ব্যবস্থাপক কারখানার কাজও বোঝেন।

একটি ছোট ধাপে তিন স্তর দেখুন। নিচের তিনটি লাইন একই কাজ করে — ৭ নম্বর পণ্য আনা:

// ১. JDBC (পর্ব ৪৯) — সব হাতে
ps = con.prepareStatement("select id, name, price, stock from product where id = ?");
ps.setLong(1, 7);  rs = ps.executeQuery();  /* তারপর rs থেকে new Product(...) */

// ২. JPA-র EntityManager — SQL বানায় Hibernate
Product p = em.find(Product.class, 7L);

// ৩. Spring Data JPA — interface-এর method, বাস্তবায়ন চলার সময় তৈরি
Optional<Product> p = productRepository.findById(7L);

তিন নম্বরের ভেতরে আসলে দুই নম্বরই চলে, আর দুই নম্বরের ভেতরে এক নম্বর। স্তর যত ওপরে, কোড তত ছোট — আর লুকোনো আচরণ তত বেশি। এই পর্বের Spring কোড Spring Boot 3.x আর Hibernate 6.x ধরে লেখা (package jakarta.persistence); পরের বড় সংস্করণে কিছু খুঁটিনাটি বদলাতে পারে, ধারণাগুলো একই থাকে। এসব লাইব্রেরি অনলাইন runner-এ নেই, তাই Spring/JPA কোড চালানো যাবে না — পাশে প্রত্যাশিত ফল লেখা আছে। আর দুটি চালানো যায় এমন উদাহরণে আমরা সাধারণ Java দিয়েই Hibernate-এর মূল কৌশল দুটি নিজেরা বানিয়ে দেখব।

২০০১ সালে অস্ট্রেলিয়ার ডেভেলপার Gavin King EJB 2-এর ভারী “entity bean” নিয়ে বিরক্ত হয়ে Hibernate শুরু করেন — সাধারণ Java class দিয়েই টেবিলের সাথে কাজ। কয়েক বছরেই এটা কার্যত মানক হয়ে যায়। ২০০৬ সালে JSR 220-এর অংশ হিসেবে JPA 1.0 প্রকাশিত হয়, যার নকশায় Hibernate-এর বড় প্রভাব ছিল। পুরো গল্প ভাবনার প্রশ্ন ১-এ।

২ · entity mapping — টেবিলের সাথে class মেলানো

আমাদের উদাহরণ একটি অনলাইন দোকান, যেখানে নানা জেলার পণ্য বিক্রি হয় — রাজশাহীর আম, বগুড়ার দই, সিলেটের চা-পাতা। চারটি entity: Customer, Order, OrderItem আর Product. প্রথমে Product:

@Entity
@Table(name = "product")
public class Product {
    @Id
    @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "product_seq")
    @SequenceGenerator(name = "product_seq", sequenceName = "product_seq", allocationSize = 50)
    private Long id;

    @Column(nullable = false, unique = true, length = 40) private String sku;      // পণ্যের কোড
    @Column(nullable = false, length = 120)              private String name;
    @Column(nullable = false, length = 40)               private String district;  // উৎস জেলা
    @Column(nullable = false, precision = 12, scale = 2) private BigDecimal price;
    private int stock;

    @Enumerated(EnumType.STRING) @Column(nullable = false, length = 20)
    private ProductStatus status = ProductStatus.ACTIVE;

    @Version private long version;                     // অংশ ৭-এ — দুজনের একসাথে বদল ধরতে

    protected Product() {}                             // JPA-র জন্য খালি constructor
    public Product(String sku, String name, String district, BigDecimal price, int stock) { ... }
    // getter, আর শুধু যেগুলো বদলানোর কথা সেগুলোর setter …
}
Annotationকাজখেয়াল রাখুন
@Entity, @Tableclass-টি একটি টেবিলের সাথে মেলানোorder, user SQL-এর সংরক্ষিত শব্দ — টেবিলের নাম orders দিন
@Idprimary keyপ্রতিটি entity-তে একটি
@GeneratedValueid কে বানাবেনিচের টেবিল দেখুন
@Columnকলামের নাম, nullable, unique, length, precisionএগুলো মূলত টেবিল তৈরির সময় কাজে লাগে; চলার সময় Java নিজে যাচাই করে না — সেজন্য Bean Validation (পর্ব ৫১)
@Enumerated(EnumType.STRING)enum-এর নাম লেখা হয় ("ACTIVE")ডিফল্ট ORDINAL — ক্রমিক সংখ্যা; বিপজ্জনক
@Versionoptimistic locking-এর সংখ্যানিজে কখনো বদলাবেন না

id কে বানাবে — IDENTITY না SEQUENCE

GenerationType.IDENTITY মানে ডেটাবেসের auto-increment কলাম (MySQL-এর AUTO_INCREMENT, PostgreSQL-এর identity কলাম)। সহজ, কিন্তু একটি দাম আছে: id জানা যায় শুধু INSERT চালানোর পর। তাই Hibernate persist()-এর সাথে সাথেই INSERT চালাতে বাধ্য, আর অনেকগুলো INSERT একসাথে batch করে পাঠাতে পারে না। GenerationType.SEQUENCE-এ ডেটাবেসের একটি আলাদা sequence থেকে id আসে। allocationSize = 50 দিলে Hibernate একবারে ৫০টি id-এর একটি খণ্ড নেয়, তারপর মেমরি থেকেই বিলি করে — ৫০টি নতুন পণ্যে sequence-এ মাত্র এক-দুবার যাওয়া, আর INSERT-গুলো batch করা যায়। PostgreSQL বা Oracle-এ SEQUENCE ভালো পছন্দ; MySQL-এ sequence নেই, সেখানে IDENTITY-ই প্রচলিত।

enum — কেন ORDINAL নয়

পর্ব ২৪-এ দেখেছেন, প্রতিটি enum constant-এর একটি ক্রমিক সংখ্যা আছে (ordinal())। @Enumerated না লিখলে JPA এই সংখ্যাটিই টেবিলে রাখে: ACTIVE = 0, DISCONTINUED = 1. এক বছর পর কেউ মাঝখানে OUT_OF_SEASON যোগ করলেন — এখন DISCONTINUED হয়ে গেল 2. কিন্তু টেবিলে আগের হাজার সারিতে লেখা 1, যা এখন পড়া হবে OUT_OF_SEASON হিসেবে। কোনো error নেই, শুধু নীরবে ভুল ডেটা। EnumType.STRING নাম লেখে, তাই ক্রম বদলালে কিছু ভাঙে না (নাম বদলালে অবশ্য migration লাগবে)।

embeddable — ঠিকানা আর টাকার মতো value object

অর্ডারের ডেলিভারি ঠিকানায় থাকে জেলা, এলাকা আর বাড়ির লাইন। এগুলোর আলাদা পরিচয় নেই — দুটি ঠিকানার সব ঘর মিললে তারা একই ঠিকানা; এটাই value object. এর জন্য আলাদা টেবিল লাগে না; @Embeddable class বানিয়ে entity-তে @Embedded লিখলে তার field-গুলো মালিক entity-র টেবিলেই কলাম হয়ে যায়:

@Embeddable
public class Address {
    @Column(nullable = false, length = 40) private String district;   // orders.district কলাম
    @Column(nullable = false, length = 80) private String area;
    private String line;
    protected Address() {}
    public Address(String district, String area, String line) { ... }
}
// Order-এর ভেতরে:   @Embedded private Address deliveryAddress;

Java-তে কোডের ভাষায় order.getDeliveryAddress().getDistrict() — পরিষ্কার একটি ধারণা; আর টেবিলে সাধারণ কলাম। টাকার পরিমাণ আর মুদ্রা মিলিয়ে একটি Money বানানোও একই কৌশল। নতুন সংস্করণে (Hibernate 6.2 আর Jakarta Persistence 3.2 থেকে) record-ও embeddable হিসেবে চলে বলে ডকুমেন্টে আছে; ব্যবহারের আগে নিজের সংস্করণে মিলিয়ে নিন।

মূল ধারণা

record entity হতে পারে না, কিন্তু DTO হিসেবে চমৎকার। entity-র দরকার একটি খালি constructor, বদলানো যায় এমন field, আর lazy loading-এর জন্য subclass বানানোর সুযোগ — record final, তার field-ও final. তাই entity থাকে সাধারণ class হিসেবে, আর পড়ার জন্য ডেটা বাইরে নিতে record (পর্ব ২৫) — অংশ ৬-এর projection-এ দেখবেন।

entity-র equals আর hashCode সাবধানে। পর্ব ২৩-এর নিয়ম মনে আছে — যে object HashSet-এ আছে, তার hashCode বদলালে সেটা আর খুঁজে পাওয়া যায় না। নতুন entity-র id প্রথমে null, persist-এর পরে একটি সংখ্যা। IDE বা Lombok দিয়ে id-ভিত্তিক hashCode বানালে save করার মুহূর্তে hashCode বদলে যায়, আর Set-এর ভেতরের পণ্য “হারিয়ে” যায়। সব field দিয়ে বানানোও ভুল — lazy সম্পর্ক ছুঁয়ে বাড়তি query, এমনকি অসীম recursion. প্রচলিত দুটি নিরাপদ পথ: (১) কখনো না বদলানো একটি ব্যবসায়িক key (যেমন sku) দিয়ে equals/hashCode; (২) id দিয়ে equals (id null হলে শুধু নিজের সাথেই সমান) আর hashCode একটি স্থির মান (যেমন getClass().hashCode())। Hibernate কখনো কখনো আসল class-এর একটি proxy subclass দেয়, তাই getClass() != o.getClass() তুলনার বদলে instanceof নিরাপদ।

৩ · persistence context — entity-র জীবনচক্র আর dirty checking

Hibernate-এর সবচেয়ে গুরুত্বপূর্ণ — আর সবচেয়ে কম বোঝা — ধারণা হলো persistence context. সাধারণত প্রতিটি transaction-এর সাথে একটি করে। এটা একটি খাতা, যেখানে লেখা থাকে এই transaction-এ কোন কোন entity আনা হয়েছে, আর আনার মুহূর্তে প্রতিটির মান কী ছিল। এর দুটি কাজ:

  • এক id, এক object: একই transaction-এ find(Product.class, 1L) দুবার ডাকলে দ্বিতীয়বার SQL যায় না, একই Java object ফেরত আসে। এর নাম first-level cache বা identity map. তাই এখানে == সত্য হয় — কিন্তু শুধু এই context-এর ভেতরে।
  • নিজে থেকে বদল খোঁজা: commit-এর আগে Hibernate প্রতিটি entity-র বর্তমান মান আনার সময়ের কপির (snapshot) সাথে মেলায়। যেটা বদলেছে, তার জন্য UPDATE চালায়। এর নাম dirty checking. তাই @Transactional method-এ product.setStock(47) লিখলেই যথেষ্ট — save() লাগে না।

জমে থাকা বদল SQL হয়ে ডেটাবেসে পাঠানোর নাম flush. flush মানে commit নয় — SQL গেল, কিন্তু transaction এখনো খোলা, ROLLBACK হলে সব বাতিল। ডিফল্টে flush হয় commit-এর ঠিক আগে, আর এমন query চালানোর আগে যার ফল জমে থাকা বদলের ওপর নির্ভর করতে পারে (যাতে query পুরোনো ডেটা না দেখে)। em.flush() দিয়ে নিজেও আগে পাঠানো যায়।

প্রতিটি entity object এই খাতার সাপেক্ষে চারটি অবস্থার একটিতে থাকে — এটাই entity-র জীবনচক্র. ধাপে ধাপে দেখুন:

  1. Transientnewখাতায় নেই
  2. Managedpersistনজরে আছে
  3. FlushSQLবদল পাঠানো
  4. CommitCOMMITস্থায়ী
  5. Detachedclosedনজরের বাইরে
ধাপ ১transient
Product p = new Product("DOI-1", "বগুড়ার দই", "Bogura", 300, 20);
p.getId() → null
সাধারণ Java object — Hibernate একে চেনেই না
ধাপ ২persist → managed
em.persist(p);
খাতা: Product#51 → p   snapshot {stock=20, ...}
SEQUENCE থেকে id এলো; INSERT এখনো জমা আছে
ধাপ ৩field বদল
p.setStock(18);
বর্তমান {stock=18}  ≠  snapshot {stock=20}
কোনো SQL যায়নি — শুধু মেমরিতে বদল
ধাপ ৪commit-এর আগে flush
insert into product (district, name, price, sku, status, stock, version, id)
       values (?, ?, ?, ?, ?, ?, ?, ?)
একই transaction-এ নতুন আর বদলানো — Hibernate মিলিয়ে
INSERT-এ সরাসরি stock=18 পাঠাল (আলাদা UPDATE লাগেনি)
ধাপ ৫commit
COMMIT
এখন অন্য connection-ও নতুন দই দেখতে পাবে
ধাপ ৬context বন্ধ → detached
p.setStock(5);        // transaction-এর বাইরে
কোনো SQL কখনো যাবে না
p.getOrders()...      // lazy সম্পর্ক → LazyInitializationException
Product fresh = em.merge(p);   // নতুন transaction-এ আবার যুক্ত করা
  1. Transient. new দিয়ে বানানো object. id নেই, খাতায় নেই — টেবিলের সাথে কোনো সম্পর্ক নেই।
  2. persist → managed. em.persist(p) (বা Spring Data-র save) object-টিকে খাতায় তোলে। SEQUENCE হলে id এখনই আসে, কিন্তু INSERT জমা থাকে flush পর্যন্ত। (IDENTITY হলে id জানতে INSERT সাথে সাথেই যেত।)
  3. বদল খেয়াল করা। managed object-এ setter ডাকলেন। Hibernate এখনই কিছু করে না; শুধু পরে snapshot-এর সাথে মেলাবে।
  4. flush. transaction শেষ হওয়ার আগে Hibernate খাতা ঘুরে SQL বানায় — নতুনদের INSERT, বদলানোদের UPDATE, মোছাদের DELETE — তারপর সব একসাথে পাঠায়। এই “কাজ জমিয়ে শেষে একবারে” কৌশলের নাম unit of work.
  5. commit. ডেটাবেস বদলগুলো স্থায়ী করল। Spring-এ @Transactional method শেষ হলে এটা নিজে হয়।
  6. detached. transaction আর context বন্ধ হলে object-টি detached: মেমরিতে আছে, কিন্তু খাতায় নেই। এখন বদলালে কোনো SQL যাবে না, আর না-আনা lazy সম্পর্ক ছুঁলে exception. আবার যুক্ত করতে merge, যা একটি managed কপি ফেরত দেয় — পুরোনো object-টি detached-ই থাকে। চতুর্থ অবস্থা removed: em.remove(p)-এর পর, flush-এ DELETE.
EntityManagerকাজSpring Data-তে মোটামুটি
persist(e)নতুন entity-কে managed করে; flush-এ INSERTsave(e) (id নেই বা নতুন হলে)
find(Type.class, id)আগে খাতায় খোঁজে, না পেলে SELECT; না থাকলে nullfindById(id) → Optional
merge(e)detached-এর মান একটি managed কপিতে বসায়, কপিটি ফেরত দেয়save(e) (id আছে এমন entity)
remove(e)managed entity-কে removed করে; flush-এ DELETEdelete(e), deleteById(id)
flush() / clear()জমা SQL এখনই পাঠানো / খাতা খালি করা (সব detached)saveAndFlush, flush()

লক্ষ করুন, save() ভেতরে দেখে entity নতুন কি না — নতুন হলে persist, না হলে merge. আর save()-এর ফেরত মানটিই ব্যবহার করুন: merge-এর ক্ষেত্রে সেটা আলাদা object হতে পারে। এবার Hibernate-এর এই দুটি কৌশল — identity map আর dirty checking — সাধারণ Java দিয়ে নিজেরাই বানিয়ে দেখুন। ডেটাবেসের বদলে একটি Map, আর SQL চালানোর বদলে SQL-টি ছাপানো:

import java.util.*;

class Product {
    final long id; String name; int stock;
    Product(long id, String name, int stock) { this.id = id; this.name = name; this.stock = stock; }
}
class PersistenceContext {                                   // Hibernate Session-এর খুব ছোট নকল
    private final Map<Long, Product> managed = new TreeMap<>();         // identity map
    private final Map<Long, List<Object>> snapshot = new TreeMap<>();   // লোডের সময়ের কপি
    private final Map<Long, String> db = Map.of(1L, "Tea,50", 2L, "Rice,20");   // "টেবিল"
    Product find(long id) {
        if (managed.containsKey(id)) { System.out.println("find(" + id + ") -> cache, no SQL"); return managed.get(id); }
        System.out.println("SQL: select * from product where id=" + id);
        String[] row = db.get(id).split(",");
        var p = new Product(id, row[0], Integer.parseInt(row[1]));
        managed.put(id, p); snapshot.put(id, List.of(p.name, p.stock));
        return p;
    }
    void flush() {                                           // dirty checking
        for (Product p : managed.values())
            if (!List.of(p.name, p.stock).equals(snapshot.get(p.id))) {
                System.out.println("SQL: update product set name='" + p.name + "', stock=" + p.stock + " where id=" + p.id);
                snapshot.put(p.id, List.of(p.name, p.stock));
            }
        System.out.println("flush done");
    }
}
class Main {
    public static void main(String[] args) {
        var em = new PersistenceContext();
        Product tea = em.find(1);
        em.find(2);
        System.out.println("same object? " + (tea == em.find(1)));
        tea.stock -= 3;                                       // কোনো save() নেই
        em.flush();
        em.flush();                                           // আর কিছু বদলায়নি
    }
}

আউটপুট সাত লাইন: প্রথমে দুটি SQL: select * from product where id=1 আর id=2; তারপর find(1) -> cache, no SQL আর same object? true — তৃতীয় find ডেটাবেসে যায়নি, একই object দিয়েছে। এরপর SQL: update product set name='Tea', stock=47 where id=1 আর flush done; শেষে আবার শুধু flush done. লক্ষ করুন: (১) চায়ের স্টক বদলাতে কেউ “save” বলেনি — snapshot-এর সাথে অমিল দেখেই UPDATE তৈরি হলো; (২) চাল (id 2) বদলায়নি, তাই তার কোনো UPDATE নেই; (৩) দ্বিতীয় flush-এ snapshot আগেই হালনাগাদ, তাই কিছুই গেল না। আসল Hibernate ডিফল্টে UPDATE-এ সব কলাম লেখে (শুধু বদলানোটি নয়), আর SQL-এ মানের বদলে ? parameter রাখে — কিন্তু মূল কৌশল হুবহু এটাই। দাম আছে: প্রতিটি managed entity-র একটি কপি মেমরিতে, আর flush-এ সবার তুলনা। একটি transaction-এ লাখখানেক entity আনলে তাই মেমরি আর সময় দুটোই খরচ হয়।

৪ · relationship — একজন গ্রাহক, অনেক অর্ডার

টেবিলে সম্পর্ক থাকে একটি foreign key কলামে: orders.customer_id বলে অর্ডারটি কার। Java-তে সম্পর্ক থাকে reference-এ: order.getCustomer(), আর উল্টো দিকে customer.getOrders(). সমস্যা হলো, Java-তে দুটি আলাদা reference, কিন্তু টেবিলে একটিই কলাম। কোন reference সেই কলামের “মালিক”, তা JPA-কে বলে দিতে হয়।

@Entity
@Table(name = "orders")
public class Order {
    @Id @GeneratedValue(strategy = GenerationType.SEQUENCE) private Long id;

    @ManyToOne(fetch = FetchType.LAZY, optional = false)      // owning side: foreign key এখানে
    @JoinColumn(name = "customer_id")
    private Customer customer;

    @OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true)
    private List<OrderItem> items = new ArrayList<>();     // inverse side

    @Embedded private Address deliveryAddress;
    @Enumerated(EnumType.STRING) private OrderStatus status = OrderStatus.PLACED;
    private Instant placedAt = Instant.now();

    public void addItem(OrderItem item)    { items.add(item);    item.setOrder(this); }   // দুই দিক একসাথে
    public void removeItem(OrderItem item) { items.remove(item); item.setOrder(null); }
}

@Entity
public class OrderItem {
    @Id @GeneratedValue(strategy = GenerationType.SEQUENCE) private Long id;
    @ManyToOne(fetch = FetchType.LAZY, optional = false) private Order order;     // order_id
    @ManyToOne(fetch = FetchType.LAZY, optional = false) private Product product; // product_id
    private int quantity;
    @Column(precision = 12, scale = 2) private BigDecimal unitPrice;   // কেনার মুহূর্তের দাম
}
  • @ManyToOne = owning side. অনেক অর্ডার, একজন গ্রাহক। foreign key এই টেবিলে, তাই Hibernate UPDATE বা INSERT-এ customer_id কী লিখবে, তা ঠিক করে শুধু এই field দেখে।
  • @OneToMany(mappedBy = "order") = inverse side. mappedBy বলে “এই সম্পর্কের আসল মালিক OrderItem.order field”. শুধু order.getItems().add(item) লিখে item.setOrder(order) না লিখলে টেবিলে order_id বসবেই না — Hibernate inverse দিক দেখে কলাম লেখে না।
  • helper method. তাই addItem/removeItem দুই দিক একসাথে মেলায়। এটা না থাকলে মেমরির object-গুলো আর টেবিল একে অপরের সাথে অমিল হয়ে যায় — এমন বাগ খুঁজে পাওয়া কঠিন। unitPrice আলাদা রাখার কারণ: পণ্যের দাম পরে বদলালেও পুরোনো অর্ডারের দাম বদলাবে না।

@ManyToMany — আর কেন প্রায়ই আলাদা join entity ভালো

একটি পণ্য অনেক ক্যাটাগরিতে (“ফল”, “মৌসুমি”), একটি ক্যাটাগরিতে অনেক পণ্য। টেবিলে এর জন্য তৃতীয় একটি join table লাগে: product_category(product_id, category_id). JPA-তে @ManyToMany আর @JoinTable লিখলে Hibernate এই টেবিল নিজে সামলায়। কিন্তু দুটি কারণে অভিজ্ঞ দল প্রায়ই ProductCategory নামে একটি আলাদা entity বানায়, দুটি @ManyToOne-সহ। প্রথমত, সম্পর্কের নিজের তথ্য প্রায় সবসময় পরে আসে — কবে যোগ হলো, তালিকায় কোন ক্রমে দেখাবে। join table-এ নতুন কলাম রাখার জায়গা @ManyToMany-তে নেই। দ্বিতীয়ত, List দিয়ে @ManyToMany করলে Hibernate একটি সারি সরাতে গিয়ে অনেক সময় সেই পণ্যের সব সারি মুছে আবার ঢোকায়; অন্তত Set ব্যবহার করুন।

cascade আর orphanRemoval

cascade মানে মালিক entity-র ওপর করা কাজ সম্পর্কিত entity-তেও ছড়িয়ে দেওয়া। CascadeType.PERSIST: অর্ডার persist করলে নতুন আইটেমগুলোও; REMOVE: অর্ডার মুছলে আইটেমগুলোও; ALL: সবগুলো। আর orphanRemoval মানে সংগ্রহ থেকে সরিয়ে দেওয়া সন্তান entity মুছে ফেলা — অর্ডার ছাড়া একটি আইটেমের কোনো অর্থ নেই। ধাপে ধাপে দেখুন:

ধাপ ১আইটেম যোগ
Order order = orderRepo.findById(9L).orElseThrow();   // managed
order.addItem(new OrderItem(mango, 2, price));         // নতুন = transient
item.order = order   আর   order.items-এ item
ধাপ ২commit — cascade PERSIST
insert into order_item (order_id, product_id, quantity, unit_price, id)
       values (9, 3, 2, 650.00, ?)
item-এর জন্য আলাদা save() ডাকতে হয়নি
ধাপ ৩আইটেম সরানো
order.removeItem(item);
order.items থেকে বাদ,  item.order = null
item এখন “এতিম” — কোনো অর্ডারের নয়
ধাপ ৪orphanRemoval = true
delete from order_item where id=?
সংগ্রহ থেকে সরানো মানেই মুছে ফেলা
ধাপ ৫orphanRemoval না থাকলে
update order_item set order_id=null ... where id=?
order_id NOT NULL → constraint violation
নয়তো টেবিলে মালিকহীন সারি জমে থাকে
ধাপ ৬ভুল দিকে cascade
OrderItem:  @ManyToOne(cascade = CascadeType.REMOVE) Product product;
orderItemRepo.delete(item);
delete from product where id=3   ← রাজশাহীর আম-ই উধাও!
  1. যোগ। managed অর্ডারে helper method দিয়ে একটি নতুন আইটেম যোগ হলো। দুই দিকের reference-ই ঠিক আছে।
  2. cascade PERSIST. flush-এর সময় Hibernate অর্ডারের items-এ একটি transient object পেল; cascade থাকায় সেটাকেও persist করল। order_id এলো owning side item.order থেকে।
  3. সরানো। removeItem তালিকা থেকে বাদ দিল আর owning side null করল।
  4. orphanRemoval. মালিক-সংগ্রহ থেকে বাদ পড়া সন্তানকে Hibernate মুছে দিল। অর্ডার-আইটেম, ব্লগের মন্তব্য — যার আলাদা জীবন নেই, তার জন্য এটা সঠিক।
  5. orphanRemoval ছাড়া। Hibernate শুধু foreign key null করার চেষ্টা করে। কলাম NOT NULL হলে error; না হলে টেবিলে মালিকহীন সারি জমে।
  6. ভুল দিকে cascade. cascade সবসময় মালিক থেকে সন্তানের দিকে (@OneToMany). সন্তান থেকে মা-বাবার দিকে (@ManyToOne) REMOVE দিলে একটি আইটেম মুছতে গিয়ে পণ্যটি মুছে যায় — অন্য অর্ডারের foreign key থাকলে error, নইলে নীরবে ডেটা হারানো। Customer আর Product-এর মতো স্বাধীন entity-তে cascade দেবেন না।

৫ · LAZY, EAGER আর N+1 সমস্যা

একটি অর্ডার আনলে তার গ্রাহক, আইটেম, প্রতিটি আইটেমের পণ্য — সব কি একসাথে আনা উচিত? সব আনলে একটি সাধারণ তালিকার পাতায় পুরো ডেটাবেস টেনে আনার ঝুঁকি। তাই JPA-তে প্রতিটি সম্পর্কের একটি fetch type আছে। LAZY মানে দরকার না পড়া পর্যন্ত আনা হবে না: Hibernate আসল Customer-এর জায়গায় একটি proxy বসায় (পর্ব ৪৮-এর proxy pattern), যার কোনো field প্রথমবার ছুঁলে তবেই SELECT যায়। EAGER মানে মালিক আনার সাথে সাথেই আনা।

সম্পর্কJPA-র ডিফল্টপরামর্শ
@ManyToOne, @OneToOneEAGERfetch = FetchType.LAZY স্পষ্ট লিখুন
@OneToMany, @ManyToManyLAZYLAZY-ই রাখুন

এই অসমতা JPA 1.0-র একটি পুরোনো সিদ্ধান্ত, আর বেশিরভাগ বিশেষজ্ঞ এটাকে দুর্ভাগ্যজনক মনে করেন। EAGER ভালো শোনায় — “সব হাতের কাছে” — কিন্তু তা বদলানো যায় না: যে পাতায় গ্রাহকের দরকার নেই, সেখানেও গ্রাহক আসবে, আর গ্রাহকের EAGER সম্পর্কও, এভাবে শিকলের মতো। উল্টোটা সহজ: সব LAZY রাখুন, আর যে query-তে দরকার, শুধু সেখানে একসাথে আনুন।

LazyInitializationException — খাতা বন্ধ হওয়ার পর proxy ছোঁয়া

LAZY proxy নিজের ডেটা আনতে খোলা persistence context লাগে। transaction শেষ হওয়ার পর — ধরুন controller-এ, বা JSON বানানোর সময় — order.getCustomer().getName() ডাকলে Hibernate SELECT চালানোর জায়গা পায় না:

org.hibernate.LazyInitializationException: could not initialize proxy [com.shop.Customer#10] - no Session

সংগ্রহের ক্ষেত্রে বার্তাটি মোটামুটি “failed to lazily initialize a collection of role: com.shop.Order.items … no Session”. হুবহু লেখা সংস্করণভেদে আলাদা; মূল কথা “no Session” — খাতা বন্ধ। ভুল সমাধান হলো সম্পর্কটি EAGER করে দেওয়া। সঠিক সমাধান: service-এর transaction-এর ভেতরেই দরকারি ডেটা একসাথে এনে DTO বানিয়ে ফেরত দেওয়া।

N+1 — লুপের ভেতরে লুকোনো query

পর্ব ৪৯-এ হাতে লেখা JDBC-তে আর পর্ব ৫১-এ এক ঝলক দেখেছেন N+1 সমস্যা. ORM-এ এটা সবচেয়ে বেশি দেখা যায়, কারণ o.getCustomer().getName() দেখতে একটি নিরীহ getter. নিচের stepper-এ তিনটি অর্ডারের তালিকা ছাপাতে কয়টি SQL যায়, গুনে দেখুন:

1@Transactional(readOnly = true)2List<Order> orders = orderRepo.findAll();3for (Order o : orders)4  print(o.getId() + " " + o.getCustomer().getName());5// ---- একই কাজ, একটি query-তে ----6@Query("select o from Order o join fetch o.customer")7List<Order> findAllWithCustomer();8for (Order o : orderRepo.findAllWithCustomer()) print(...);
অবস্থা
o
customer
query
  1. তালিকা আনা — ১টি query. findAll() শুধু orders টেবিল পড়ে। প্রতিটি অর্ডারের customer এখন একটি খালি proxy, শুধু id জানা।
  2. প্রথম অর্ডার। getName() proxy ছুঁল — Hibernate এখনই একটি SELECT চালাল। কোডে কোথাও query লেখা নেই।
  3. দ্বিতীয় অর্ডার। আরেকজন গ্রাহক, আরেকটি SELECT. (দুটি অর্ডার একই গ্রাহকের হলে দ্বিতীয়বার first-level cache থেকে আসত — SQL যেত না।)
  4. তৃতীয় অর্ডার। আবার একটি। N = ৩ অর্ডার, তাই ৩টি বাড়তি query.
  5. মোট 1 + N. নিজের ল্যাপটপে ১০টি অর্ডারে কেউ টের পায় না। আসল ডেটায় ১০০০ অর্ডারের পাতায় ১০০১টি round trip — প্রতিটি কয়েক মিলিসেকেন্ড হলেও মোট কয়েক সেকেন্ড। সম্পর্কটি EAGER হলেও প্রায়ই একই হয়: findAll-এর পর Hibernate প্রতিটি গ্রাহকের জন্য সাথে সাথেই আলাদা SELECT চালায় — EAGER N+1 সারায় না, শুধু আগে ঘটায়।
  6. join fetch. JPQL-এ join fetch o.customer বলে: অর্ডার আনার সময়ই JOIN করে গ্রাহকও আনো। একটি SQL-এ সব সারি।
  7. মোট ১টি query. লুপে getCustomer() এখন আসল object দেয়; কোনো বাড়তি SQL নেই। query-র সংখ্যা আর ডেটার আকারের ওপর নির্ভর করছে না — এটাই লক্ষ্য।

একই ঘটনা সাধারণ Java দিয়ে চালিয়ে দেখুন। এখানে Order.getCustomer() একটি সাধারণ getter-এর মতো দেখতে, কিন্তু ভেতরে Hibernate-এর lazy proxy-র মতো প্রথম ডাকে একটি “query” চালায়। একই লুপ দুইভাবে চলে — lazy আর join fetch:

import java.util.*;
import java.util.function.Supplier;

class Order {
    final long id; private final Supplier<String> loader; private String customer;
    Order(long id, Supplier<String> loader) { this.id = id; this.loader = loader; }
    String getCustomer() { if (customer == null) customer = loader.get(); return customer; }   // দেখতে সাধারণ getter
}
class Db {
    int queries;
    private final Map<Long, String> customers = Map.of(10L, "Rahim", 11L, "Karim", 12L, "Shila");
    private final long[][] orders = {{1, 10}, {2, 11}, {3, 12}};                    // {order id, customer id}
    private void sql(String s) { queries++; System.out.println("  SQL #" + queries + ": " + s); }
    List<Order> findAll(boolean joinFetch) {
        sql(joinFetch ? "select o.*, c.* from orders o join customer c on c.id=o.customer_id" : "select * from orders");
        var list = new ArrayList<Order>();
        for (long[] o : orders) {
            String name = customers.get(o[1]);
            list.add(new Order(o[0], joinFetch ? () -> name
                    : () -> { sql("select * from customer where id=" + o[1]); return name; }));
        }
        return list;
    }
}
class Main {
    public static void main(String[] args) {
        for (boolean fetch : new boolean[] {false, true}) {
            var db = new Db();
            System.out.println(fetch ? "JOIN FETCH:" : "LAZY:");
            for (Order o : db.findAll(fetch)) System.out.println("  order " + o.id + " -> " + o.getCustomer());
            System.out.println("  total queries = " + db.queries);
        }
    }
}

প্রথম অংশ LAZY:-এর নিচে SQL #1: select * from orders, তারপর প্রতিটি অর্ডার ছাপানোর ঠিক আগে একটি করে select * from customer where id=10, 11, 12 — শেষে total queries = 4. দ্বিতীয় অংশ JOIN FETCH:-এ একটিমাত্র JOIN-ওয়ালা SQL, তারপর তিনটি অর্ডার order 1 -> Rahim, order 2 -> Karim, order 3 -> Shila, আর total queries = 1. খেয়াল করুন, main-এর লুপ দুই ক্ষেত্রে হুবহু একই — পার্থক্য শুধু findAll-এর ভেতরের দুই রকম lambda-তে: join fetch-এ নাম আগেই জানা, lazy-তে প্রথম ডাকে একটি query. এজন্যই N+1 কোড পড়ে ধরা যায় না; ধরা যায় SQL log দেখে।

তিনটি প্রতিকার

public interface OrderRepository extends JpaRepository<Order, Long> {

    // ১. JPQL-এ join fetch
    @Query("select o from Order o join fetch o.customer where o.status = :status")
    List<Order> findWithCustomer(@Param("status") OrderStatus status);

    // ২. @EntityGraph — query একই, শুধু বলে দেওয়া কোন সম্পর্ক একসাথে আনতে হবে
    @EntityGraph(attributePaths = {"customer", "items"})
    Optional<Order> findWithItemsById(Long id);
}
# ৩. batch fetching — lazy proxy বা সংগ্রহ একটি একটি না এনে একবারে অনেকগুলো
spring.jpa.properties.hibernate.default_batch_fetch_size=16

join fetch সবচেয়ে সরাসরি: একটি SQL. @EntityGraph একই কাজ annotation দিয়ে করে, derived query-র সাথেও চলে। আর batch fetching একটি নিরাপত্তা-জাল: lazy proxy ছোঁয়া হলে Hibernate একসাথে ১৬টি পর্যন্ত id-এর জন্য where id in (?, ?, …) চালায় — ১০০০ অর্ডারে ১০০১টির বদলে মোটামুটি ১ + ৬৩টি query. কোড না বদলেই বড় উন্নতি, তাই অনেক দল এটা ডিফল্ট হিসেবে চালু রাখে।

join fetch-এর দুটি সীমা। (১) সংগ্রহ (items) join fetch করে সাথে pagination দিলে ডেটাবেস “২০টি অর্ডার” নয়, “২০টি JOIN-সারি” কাটতে পারত — তাই Hibernate সব সারি এনে মেমরিতে পাতা কাটে আর log-এ সতর্কবার্তা দেয়। বড় টেবিলে এটা বিপজ্জনক; প্রথমে শুধু এক পাতার id আনুন, তারপর সেই id-গুলোর জন্য join fetch করুন। (২) একই query-তে দুটি List সংগ্রহ join fetch করলে Hibernate সাধারণত MultipleBagFetchException দেয়, আর তা না হলেও সারির সংখ্যা গুণ হয়ে বেড়ে যায় (cartesian product). একবারে একটি সংগ্রহ আনুন, বাকিগুলো batch fetching-এ।

open-in-view — যে সেটিং N+1 লুকিয়ে রাখে

Spring Boot-এ spring.jpa.open-in-view ডিফল্টে true: persistence context পুরো HTTP request ধরে খোলা থাকে, controller আর JSON বানানো পর্যন্ত। ফলে LazyInitializationException আসে না — কিন্তু এর কারণ হলো lazy query-গুলো নীরবে controller-এ বা Jackson-এর ভেতরে চলছে, transaction-এর বাইরে, আর ডেটাবেস connection পুরো request ধরে আটকে থাকছে। এ কারণেই Spring Boot চালুর সময় এটা নিয়ে একটি সতর্কবার্তা (WARN) log-এ লেখে, যদি আপনি নিজে মান না দেন। নতুন প্রজেক্টে false দিন, আর ডেটা service-এর ভেতরেই গুছিয়ে নিন।

৬ · Spring Data repository — derived query, JPQL, projection আর pagination

পর্ব ৫১-এ derived query-র প্রথম রূপ দেখেছেন। নামের ব্যাকরণ আরও অনেক দূর যায়: find…By, exists…By, count…By, delete…By, তারপর field-এর নাম আর And, Or, LessThan, Between, Containing, IgnoreCase, OrderBy…Desc-এর মতো শব্দ।

public interface ProductRepository extends JpaRepository<Product, Long> {

    Page<Product> findByDistrictAndPriceLessThan(String district, BigDecimal max, Pageable pageable);
    boolean existsBySku(String sku);                       // select ... limit 1 — পুরো সারি আনে না
    long countByStockLessThan(int threshold);              // কম স্টকের পণ্য কয়টি

    @Query("select p from Product p where lower(p.name) like lower(concat('%', :q, '%'))")
    List<Product> search(@Param("q") String q);           // JPQL — টেবিল নয়, entity আর field-এর নাম

    @Query(value = "select * from product where stock = 0 and status = 'ACTIVE'", nativeQuery = true)
    List<Product> outOfStockNative();                     // native SQL — ডেটাবেসের নিজের ভাষা

    @Modifying(clearAutomatically = true)
    @Query("update Product p set p.price = p.price * :factor where p.district = :district")
    int reprice(@Param("district") String district, @Param("factor") BigDecimal factor);
}
  • নামের সীমা। findByDistrictAndPriceLessThanAndStatusAndStockGreaterThanOrderByPriceAsc কাজ করবে, কিন্তু কেউ পড়তে পারবে না। দুই-তিনটি শর্তের বেশি হলে @Query লিখুন। নামে বানান ভুল হলে অ্যাপ চালুর সময়েই error — এটা ভালো দিক।
  • JPQL বনাম native query. JPQL entity আর field-এর নাম ব্যবহার করে, Hibernate তা ডেটাবেস অনুযায়ী SQL-এ বদলায় — ডেটাবেস বদলালেও চলে। native query ডেটাবেসের নিজস্ব সুবিধা (window function, PostgreSQL-এর JSON অপারেটর) দেয়, কিন্তু সেই ডেটাবেসে বাঁধা।
  • @Modifying. বাল্ক UPDATE বা DELETE সরাসরি ডেটাবেসে যায়, persistence context-কে পাশ কাটিয়ে। খাতায় আগে থেকে থাকা Product object-গুলো তখন পুরোনো দাম দেখায় — তাই clearAutomatically = true. এছাড়া বাল্ক query @Version বাড়ায় না আর entity-র lifecycle callback চালায় না। transaction লাগবে (অংশ ৭)।

pagination — পণ্যের তালিকা পাতা ধরে

অ্যাপের “রাজশাহীর পণ্য, ৭০০ টাকার নিচে, দাম অনুযায়ী” পাতা:

@Transactional(readOnly = true)
public Page<ProductCard> cheapFrom(String district, int page) {
    Pageable pageable = PageRequest.of(page, 20, Sort.by("price").ascending().and(Sort.by("id")));
    return products.findByDistrictAndPriceLessThan(district, new BigDecimal("700"), pageable)
                   .map(ProductCard::from);                // entity → record DTO
}
Hibernate: select p1_0.id,p1_0.district,p1_0.name,p1_0.price,... from product p1_0
           where p1_0.district=? and p1_0.price<? order by p1_0.price,p1_0.id
           offset ? rows fetch first ? rows only
Hibernate: select count(p1_0.id) from product p1_0 where p1_0.district=? and p1_0.price<?

দুটি query: এক পাতার সারি, আর মোট সংখ্যা (“২৩টি পণ্য, ২ পাতা”)। পাতা কাটার SQL ডেটাবেসভেদে আলাদা (LIMIT/OFFSET বা FETCH FIRST). লক্ষ করুন, sort-এ শেষে id যোগ করেছি — একই দামের অনেক পণ্য থাকলে ক্রম স্থির না হলে একই পণ্য দুই পাতায় আসতে পারে। মোট সংখ্যার দরকার না থাকলে (“আরও দেখুন” বোতাম) Page-এর বদলে Slice ফেরত দিন — count query বাঁচে। আর অনেক গভীর পাতায় (offset 100000) ডেটাবেসকে আগের সব সারি পেরোতে হয়; সেখানে “শেষ দেখা id-এর পর থেকে” ধরনের keyset pagination দ্রুত।

projection — জেলাভিত্তিক বিক্রির রিপোর্ট

পড়ার endpoint-এ পুরো entity আনার দরকার নেই। entity আনলে সব কলাম আসে, প্রতিটি খাতায় ওঠে, snapshot তৈরি হয় — অথচ আমরা কিছু বদলাব না। projection মানে query থেকে সরাসরি শুধু দরকারি কলাম একটি ছোট টাইপে আনা। মালিক জানতে চান গত মাসে কোন জেলায় কত বিক্রি:

public record DistrictSales(String district, long orders, BigDecimal revenue) {}   // record DTO

public interface OrderRepository extends JpaRepository<Order, Long> {
    @Query("""
        select new com.shop.report.DistrictSales(
                   o.deliveryAddress.district, count(distinct o.id), sum(i.unitPrice * i.quantity))
        from Order o join o.items i
        where o.placedAt >= :from and o.status <> com.shop.OrderStatus.CANCELLED
        group by o.deliveryAddress.district
        order by sum(i.unitPrice * i.quantity) desc
        """)
    List<DistrictSales> salesByDistrict(@Param("from") Instant from);
}
DistrictSales[district=Dhaka, orders=412, revenue=518300.00]
DistrictSales[district=Chattogram, orders=187, revenue=231950.00]
DistrictSales[district=Rajshahi, orders=96, revenue=104200.00]

select new … হলো JPQL-এর constructor expression: প্রতিটি ফল-সারি দিয়ে record-এর constructor ডাকা হয় (পুরো package-সহ নাম লাগে)। এটা managed নয়, খাতায় ওঠে না, snapshot নেই — সবচেয়ে হালকা। Spring Data-তে আরেকটি পথ interface projection: interface DistrictSales { String getDistrict(); Long getOrders(); BigDecimal getRevenue(); } আর JPQL-এ প্রতিটি কলামের as district-এর মতো নাম। দুটোই ভালো; record-এ টাইপ বেশি স্পষ্ট। নিয়ম মনে রাখুন: বদলানোর জন্য entity, দেখানোর জন্য projection.

৭ · transaction — @Transactional-এর সীমা, ফাঁদ আর optimistic locking

পর্ব ৪৯-এ হাতে setAutoCommit(false), commit(), rollback() লিখেছেন। Spring-এ সেটা একটি annotation: service method-এ @Transactional. অর্ডার নেওয়ার পুরো কাজ — স্টক কমানো, অর্ডার আর আইটেম লেখা — একটি transaction:

@Service
public class OrderService {
    private final ProductRepository products;
    private final CustomerRepository customers;
    private final OrderRepository orders;
    // constructor injection (পর্ব ৫০) …

    @Transactional
    public long placeOrder(long customerId, Address address, List<CartLine> lines) {
        Customer c = customers.findById(customerId).orElseThrow(() -> new NotFoundException("customer"));
        Order order = new Order(c, address);
        for (CartLine line : lines) {
            Product p = products.findById(line.productId()).orElseThrow(() -> new NotFoundException("product"));
            if (p.getStock() < line.qty()) throw new OutOfStockException(p.getId(), p.getStock());
            p.setStock(p.getStock() - line.qty());          // dirty checking → UPDATE (version-সহ)
            order.addItem(new OrderItem(p, line.qty(), p.getPrice()));
        }
        return orders.save(order).getId();                  // cascade → আইটেমগুলোর INSERT
    }
}

Spring অ্যাপ চালুর সময় OrderService-এর একটি proxy বানায় (পর্ব ৪৫-এর proxy আর AOP). controller আসলে proxy-র placeOrder ডাকে; proxy transaction শুরু করে, আসল method চালায়, তারপর ফল দেখে COMMIT বা ROLLBACK করে। এই একটি তথ্য থেকেই @Transactional-এর সব নিয়ম বোঝা যায়:

নিয়মঅর্থ
propagation REQUIRED (ডিফল্ট)আগে থেকে transaction চললে তাতেই যোগ দাও, না হলে নতুন শুরু করো। তাই service A থেকে service B ডাকলে দুটো মিলে একটি transaction — B-তে exception হলে পুরোটাই rollback.
REQUIRES_NEWচলমান transaction থামিয়ে রেখে একদম নতুন একটি। যেমন অডিট-লগ, যা মূল কাজ ব্যর্থ হলেও থাকা দরকার। সাবধানে — দুটি connection একসাথে লাগে।
readOnly = trueশুধু পড়ার কাজ। Hibernate-এর সাথে Spring তখন flush বন্ধ রাখে আর dirty checking-এর খরচ কমাতে পারে; কিছু ডেটাবেস driver-ও এটা কাজে লাগায়। ভুল করে কিছু বদলালেও তা লেখা হবে না।
rollback-এর নিয়মডিফল্টে শুধু unchecked exception (RuntimeException আর Error) হলে rollback. checked exception (পর্ব ২৯) হলে commit! দরকার হলে @Transactional(rollbackFor = Exception.class).
isolationডিফল্টে ডেটাবেসের নিজের level (PostgreSQL-এ READ COMMITTED, MySQL InnoDB-তে REPEATABLE READ — পর্ব ৪৯)। @Transactional(isolation = Isolation.SERIALIZABLE) দিয়ে বদলানো যায়, তবে খরচসহ।

self-invocation — নিজের class-এর ভেতর থেকে ডাকলে proxy নেই।

@Service
public class ReportService {
    public void nightly() {
        rebuild();                    // this.rebuild() — proxy-কে পাশ কাটায়
    }
    @Transactional
    public void rebuild() { ... }     // এখান থেকে ডাকলে transaction শুরুই হয় না!
}

nightly() থেকে rebuild() ডাকা আসলে this.rebuild() — আসল object-এর method, proxy-র নয়। তাই annotation নীরবে উপেক্ষিত হয়; কোনো error নেই। একই কারণে private method-এ @Transactional কখনো কাজ করে না (Spring 6-এ class-ভিত্তিক proxy-তে protected বা package-private method সমর্থিত, private নয়)। সমাধান: transaction-ওয়ালা method আলাদা bean-এ সরান, আর বাইরে থেকে ডাকুন।

optimistic locking — দুই কর্মী, একটি স্টক

ঈদের আগের রাত। রাজশাহীর ল্যাংড়া আমের স্টক ৫ বাক্স। একই মুহূর্তে দুজন গ্রাহক অর্ডার দিলেন — একজন ৩, আরেকজন ৪ বাক্স। দুটি request দুটি thread-এ, দুটি আলাদা transaction-এ। দুজনেই পড়ল স্টক ৫, দুজনেই দেখল “যথেষ্ট আছে”, দুজনেই লিখল। পর্ব ৪০-এর lost update — এবার টাকার হিসাবে: ৫ বাক্সে ৭ বাক্স বিক্রি। @Version ঠিক এটা ধরে ফেলে:

1@Version private long version;2Product p = products.findById(7L).orElseThrow();3p.setStock(p.getStock() - qty);4// commit-এর আগে flush:5update product set stock=?, version=?6       where id=7 and version=?7// ০টি সারি বদলালে → exception, ROLLBACK
অবস্থা
T1 (৩ বাক্স)
T2 (৪ বাক্স)
ডেটাবেস
  1. T1 পড়ল। প্রথম গ্রাহকের transaction আমের সারি আনল: স্টক ৫, version ১।
  2. T2-ও পড়ল। প্রায় একই মুহূর্তে দ্বিতীয় transaction একই সারি পড়ল — সেও দেখল স্টক ৫, version ১। কোনো লক নেই, কেউ কাউকে আটকায়নি।
  3. T1 বদলাল। মেমরিতে স্টক ৫ − ৩ = ২। ডেটাবেসে এখনো কিছু যায়নি।
  4. T1 commit. flush-এ Hibernate UPDATE-এ নিজে থেকে and version=1 শর্ত যোগ করে আর version ২ লেখে। সারিটির version তখনো ১, তাই ১টি সারি বদলাল। ডেটাবেস: স্টক ২, version ২।
  5. T2 বদলাল। T2-এর হাতে পুরোনো তথ্য: মেমরিতে ৫ − ৪ = ১। এটা লেখা গেলে স্টক হতো ১, অথচ বিক্রি হয়েছে ৭ বাক্স।
  6. T2 ব্যর্থ। T2-এর UPDATE-এ শর্ত version=1, কিন্তু সারির version এখন ২ — ০টি সারি বদলাল। Hibernate বোঝে কেউ মাঝখানে বদলেছে, আর exception ছোড়ে (JPA-তে OptimisticLockException; Spring সাধারণত সেটা ObjectOptimisticLockingFailureException-এ মুড়ে দেয়)। পুরো transaction rollback — অর্ডার, আইটেম কিছুই লেখা হয়নি।
  7. আবার চেষ্টা। service বা controller exception ধরে নতুন transaction-এ আবার পড়ে: স্টক ২, version ২। এবার ২ < ৪, তাই সঠিক উত্তর — 409, “মাত্র ২ বাক্স আছে”। একবার-দুবার স্বয়ংক্রিয় retry, নয়তো ব্যবহারকারীকে জানানো — দুটোই প্রচলিত।

একে বলে optimistic locking — আশাবাদী: ধরে নেওয়া হয় সংঘর্ষ কম হয়, তাই পড়ার সময় কিছু আটকানো হয় না, শুধু লেখার মুহূর্তে যাচাই। বিপরীত পথ pessimistic locking: @Lock(LockModeType.PESSIMISTIC_WRITE) দিয়ে পড়ার সময়েই select … for update, অন্যরা অপেক্ষা করে। খুব বেশি প্রতিযোগিতার একটি সারিতে (একটি ফ্ল্যাশ-সেলের একমাত্র পণ্য) pessimistic ভালো হতে পারে; সাধারণ দোকানে optimistic সহজ আর দ্রুত। আরেকটি সরল বিকল্প পর্ব ৪৯-এর মতো শর্তসহ একটি বাল্ক UPDATE: update product set stock = stock - :q where id = :id and stock >= :q, আর বদলানো সারির সংখ্যা দেখা। পর্ব ৫৫-এর ক্যাপস্টোনে দুজন গ্রাহক একসাথে শেষ পিসটি কেনার এই সমস্যাটিই আবার আসবে।

৮ · schema, টেস্ট আর পারফরম্যান্সের চেকলিস্ট

টেবিল কে বানাবে — ddl-auto বনাম migration

Hibernate entity দেখে টেবিল বানাতেও পারে। এটা নিয়ন্ত্রণ করে ddl-auto সেটিং:

মানকী করেকোথায়
create-dropচালুর সময় টেবিল বানায়, বন্ধের সময় মুছে ফেলেশেখা, টেস্ট
createচালুর সময় পুরোনো টেবিল মুছে নতুন বানায়শুধু পরীক্ষামূলক
updateনতুন টেবিল বা কলাম যোগ করে; কিছু মোছে না, নাম বদল বোঝে নানিজের মেশিনে বড়জোর
validateকিছু বদলায় না; entity আর টেবিল না মিললে চালু হতে দেয় নাproduction, migration-এর সাথে
noneকিছুই করে নাproduction

Spring Boot নিজে থেকে মান ঠিক করে: H2-এর মতো embedded ডেটাবেসে আর কোনো migration টুল না থাকলে create-drop, অন্যথায় none. production-এ update কেন নয়? একটি field-এর নাম phone থেকে mobile করলে update নতুন খালি কলাম বানায় — পুরোনো কলামের লাখখানেক নম্বর আর পড়া হয় না। কোন বদল কখন হলো তার কোনো ইতিহাস নেই, পিছিয়ে যাওয়ার উপায় নেই, আর দুটি সার্ভার একসাথে চালু হলে দুজনেই টেবিল বদলাতে চায়। তাই পেশাদার প্রজেক্টে schema-র প্রতিটি বদল একটি ক্রমিক SQL ফাইলে লেখা হয় — database migration — Flyway (db/migration/V1__create_product.sql, V2__add_version.sql) বা Liquibase দিয়ে। ফাইলগুলো git-এ থাকে, code review হয়, প্রতিটি পরিবেশে একই ক্রমে চলে। Hibernate থাকে validate-এ, শুধু পাহারাদার হিসেবে।

টেস্ট — @DataJpaTest আর Testcontainers

@DataJpaTest
class ProductRepositoryTest {
    @Autowired ProductRepository repo;
    @Autowired TestEntityManager em;

    @Test
    void findsCheapProductsFromRajshahi() {
        em.persist(new Product("MANGO-1", "ল্যাংড়া আম ৫ কেজি", "Rajshahi", new BigDecimal("650"), 40));
        em.persist(new Product("MANGO-2", "হিমসাগর ১০ কেজি", "Rajshahi", new BigDecimal("1200"), 10));
        em.persist(new Product("DOI-1", "বগুড়ার দই", "Bogura", new BigDecimal("300"), 15));
        em.flush(); em.clear();                    // খাতা খালি — যাতে query সত্যিই ডেটাবেস থেকে পড়ে

        var page = repo.findByDistrictAndPriceLessThan("Rajshahi", new BigDecimal("700"), PageRequest.of(0, 10));
        assertEquals(List.of("MANGO-1"), page.map(Product::getSku).getContent());
    }
}

@DataJpaTest শুধু JPA অংশ চালু করে, আর প্রতিটি টেস্ট একটি transaction-এ চালিয়ে শেষে rollback করে — টেস্টগুলো একে অপরের ডেটা দেখে না। em.clear() না দিলে query-র ফল first-level cache থেকেও আসতে পারত, আর টেস্ট আসল SQL যাচাই না করেই পাস করত। ডিফল্টে এটা মেমরির ডেটাবেস ব্যবহার করে, কিন্তু H2 আর PostgreSQL-এর SQL পুরো এক নয় — native query, sequence বা JSON কলাম H2-তে পাস করে production-এ ভাঙতে পারে। তাই অনেক দল Testcontainers দিয়ে টেস্টের সময় Docker-এ আসল PostgreSQL চালায় (Spring Boot 3.1 থেকে @ServiceConnection এটা আরও সহজ করেছে)।

পারফরম্যান্সের চেকলিস্ট

logging.level.org.hibernate.SQL=debug                 # প্রতিটি SQL log-এ
logging.level.org.hibernate.orm.jdbc.bind=trace       # Hibernate 6: parameter-এর মান
spring.jpa.properties.hibernate.default_batch_fetch_size=16
spring.jpa.open-in-view=false
spring.jpa.hibernate.ddl-auto=validate
  • SQL দেখুন। প্রতিটি নতুন endpoint একবার চালিয়ে log-এ গুনে দেখুন কয়টি SQL গেল। তালিকার আকার বাড়লে query-র সংখ্যা বাড়লে, সেটা N+1.
  • সব সম্পর্ক LAZY; দরকারি জায়গায় join fetch, @EntityGraph, আর ডিফল্ট হিসেবে batch fetching.
  • পড়ার endpoint-এ projection, আর service-এ @Transactional(readOnly = true).
  • বড় batch-এ খাতা খালি রাখুন। লাখ সারি ঢোকাতে প্রতি কয়েকশো সারিতে flush() আর clear(), নয়তো খাতা ফুলে মেমরি শেষ। hibernate.jdbc.batch_size চালু করুন — আর মনে রাখুন, IDENTITY id-তে INSERT batch হয় না।
  • open-in-view বন্ধ, আর কোনো entity কখনো সরাসরি JSON হয়ে বাইরে যাবে না (পর্ব ৫১)।
  • index. যে কলামে খোঁজেন বা JOIN করেন (district, customer_id), তাতে index — migration ফাইলে (পর্ব ৪৯)।
C/C++/Python জানলে

Python-এর SQLAlchemy-র Session হুবহু persistence context-এর ধারণা — identity map, unit of work, flush আর commit আলাদা; relationship(back_populates=...) এখানকার mappedBy, selectinload মোটামুটি batch fetching আর joinedload join fetch-এর কাজ করে; Alembic হলো Flyway-র জায়গায়। পার্থক্য: Java-তে মানক (JPA) আর বাস্তবায়ন (Hibernate) আলাদা, Python-এ SQLAlchemy একাই দুটো। C আর C++-এ ভাষার কোনো মানক ORM নেই; ODB বা sqlpp11-এর মতো লাইব্রেরি থাকলেও বেশিরভাগ কোড SQL সরাসরি লেখে। Python-এর দিকটা দেখুন ORM — SQLAlchemy দিয়ে ডেটাবেস পর্বে।

পরীক্ষা ও ইন্টারভিউতে

JPA আর Hibernate-এর পার্থক্য? JPA একটি মানক API (নিয়ম), Hibernate তার একটি বাস্তবায়ন — সবচেয়ে প্রচলিত।
persistence context কী? transaction-এর managed entity-র খাতা: এক id-তে এক object (first-level cache) আর dirty checking.
LAZY আর EAGER-এর ডিফল্ট? @ManyToOne/@OneToOne EAGER, @OneToMany/@ManyToMany LAZY; পরামর্শ — সব LAZY.
N+1 কীভাবে সারাবেন? join fetch, @EntityGraph, batch fetching, বা DTO projection.
একই class থেকে @Transactional method ডাকলে কী হয়? proxy পাশ কাটানো হয়, তাই transaction-এর সেটিং কাজ করে না।

৯ · AI-এর যুগে — AI-এর লেখা JPA কোড যাচাই

AI-কে “Order আর Customer entity বানাও” বললে চলার মতো কোড আসে, compile হয়, ছোট ডেটায় ঠিকঠাক চলে। JPA-র বিপদগুলো ঠিক সেখানেই, যেখানে ছোট ডেটায় কিছু টের পাওয়া যায় না। AI-এর লেখা JPA কোডে বারবার যা দেখা যায়:

AI যা করেকেন সমস্যাআপনি যা করবেন
সব জায়গায় FetchType.EAGER, বা @ManyToOne-এ ডিফল্ট রেখে দেয়প্রতিটি query-তে শিকলের মতো বাড়তি ডেটা, আর N+1সব LAZY; দরকারে join fetch বা @EntityGraph
bidirectional সম্পর্ক, কিন্তু helper method নেই; বা দুই দিকেই owning-এর মতো @JoinColumnforeign key খালি থাকে, বা বাড়তি টেবিল/UPDATEএক দিকে mappedBy, আর addItem/removeItem
entity সরাসরি controller থেকে ফেরত দেয়LazyInitializationException, অসীম JSON recursion, গোপন field ফাঁসrecord DTO বা projection
private method-এ বা একই class থেকে ডাকা method-এ @Transactionalproxy পাশ কাটানো — transaction নীরবে নেইpublic method, আলাদা bean থেকে ডাকা
ddl-auto=update production-এর সেটিং-এনাম বদলে ডেটা হারানো, ইতিহাস নেইFlyway/Liquibase + validate
লুপের ভেতরে findById বা lazy getterN+1findAllById, join fetch, batch fetching
সব field দিয়ে বা Lombok @Data দিয়ে equals/hashCodepersist-এর পর hashCode বদল, lazy সম্পর্ক ছুঁয়ে query বা recursionব্যবসায়িক key, বা id + স্থির hashCode
@Enumerated ছাড়া enum, javax.persistence importORDINAL-এর নীরব ভুল; Spring Boot 3-এ compile-ই হয় নাEnumType.STRING, jakarta.persistence

AI-কে JPA-র কোড লিখতে বা রিভিউ করতে বলার সময় এই কাঠামো ব্যবহার করুন:

আমি Java 21, Spring Boot 3.x, Hibernate 6.x আর PostgreSQL ব্যবহার করছি। একটি অনলাইন দোকানের জন্য Customer, Order, OrderItem, Product entity আর repository লিখে দাও। শর্ত: ১. সব import jakarta.persistence থেকে। সব @ManyToOne-এ fetch = FetchType.LAZY লিখবে; কোথাও EAGER নয়। ২. Order–OrderItem bidirectional: OrderItem owning side, Order-এ mappedBy, cascade = ALL আর orphanRemoval = true, আর addItem/removeItem helper method. Customer বা Product-এর দিকে কোনো cascade নয়। ৩. enum-এ @Enumerated(EnumType.STRING); id-তে SEQUENCE (allocationSize বলো); Product-এ @Version. ৪. equals/hashCode কোন নীতিতে লিখলে, কারণসহ বলো; Lombok @Data ব্যবহার করবে না। ৫. অর্ডারের তালিকার জন্য একটি repository method, যা গ্রাহকসহ একটি query-তে আনে (join fetch বা @EntityGraph), আর জেলাভিত্তিক বিক্রির জন্য record DTO projection. ৬. OrderService.placeOrder-এ @Transactional (public, আলাদা bean), স্টক কম হলে RuntimeException; OptimisticLockException হলে কী করা উচিত, বলো। ৭. প্রতিটি method কয়টি SQL চালাবে, তা মন্তব্যে লেখো। কোনো annotation বা property-র অস্তিত্ব নিয়ে নিশ্চিত না হলে স্পষ্ট করে বলো। ৮. ddl-auto=validate আর Flyway-র V1 migration SQL দাও; শেষে @DataJpaTest দিয়ে দুটি টেস্ট।

AI-এর লেখা JPA কোড হাতে পেলে — যাচাইয়ের চেকলিস্ট

ভাবনার প্রশ্ন

প্রতিটি প্রশ্ন নিয়ে নিজে কিছুক্ষণ ভাবুন — তারপর “→ উত্তর” চাপুন। এগুলোই আপনাকে কোড-লেখক থেকে সিস্টেম-চিন্তক বানাবে।

প্র ০১EJB 2-এর entity bean কেন ব্যর্থ হলো, আর একজন ডেভেলপারের শখের প্রকল্প Hibernate কীভাবে ২০০৬ সালের JPA 1.0 মানকের ভিত্তি হয়ে উঠল?

১৯৯০-এর দশকের শেষে Sun-এর Enterprise JavaBeans (EJB) ছিল বড় প্রতিষ্ঠানের Java-র “সরকারি” পথ। ২০০১ সালের EJB 2.0-তে ডেটাবেসের প্রতিটি সারি ছিল একটি entity bean — একটি সাধারণ class নয়, বরং container-এর ভেতরে বাস করা একটি component. একটি টেবিলের জন্য লিখতে হতো কয়েকটি interface (home, local বা remote) আর bean class, সাথে লম্বা XML deployment descriptor. bean চলত শুধু application server-এর ভেতরে, তাই সাধারণ unit test প্রায় অসম্ভব; প্রতিটি ছোট বদলের পর পুরো server-এ deploy. শুরুর নকশায় entity bean-কে নেটওয়ার্কের ওপারে থেকেও ডাকা যায় এমন remote object ভাবা হয়েছিল, ফলে getter ডাকাও ভারী হতে পারত। query ভাষা (EJB QL) ছিল দুর্বল, আর inheritance-এর মতো সাধারণ object-ধারণা প্রায় অচল।

২০০১ সালে অস্ট্রেলিয়ার Gavin King এই অবস্থায় বিরক্ত হয়ে Hibernate শুরু করেন। ভাবনাটি ছিল সরল: সাধারণ Java class (POJO) — container নেই, বিশেষ interface নেই — আর একটি mapping, যা বলে কোন field কোন কলামে। এর সাথে আসে persistence context, lazy loading আর HQL নামের object-ভিত্তিক query ভাষা। সাধারণ main থেকেই চালানো যেত, টেস্টও লেখা যেত। ডেভেলপাররা দ্রুত এটা গ্রহণ করে; পরে JBoss তাঁকে নিয়োগ দেয়। প্রায় একই সময়ে Rod Johnson-এর বই আর Spring (পর্ব ৫০-এর ভাবনার প্রশ্ন ১) EJB-র জটিলতার বিরুদ্ধে একই সুর তোলে।

মানক সংস্থা Java Community Process তখন বুঝল, বাজার অন্য দিকে চলে গেছে। JSR 220 (EJB 3.0)-এর বিশেষজ্ঞ দলে King-সহ Hibernate, Oracle TopLink আর JDO-জগতের মানুষ ছিলেন। ফল: persistence অংশটি আলাদা একটি API — Java Persistence API, JPA 1.0, ২০০৬ সালে Java EE 5-এর সাথে। তার @Entity, EntityManager, JPQL দেখলে Hibernate-এর Session আর HQL-এর ছাপ স্পষ্ট। আর JPA শুধু application server-এর ভেতরে নয়, সাধারণ Java SE প্রোগ্রামেও চলার মতো করে বানানো হয়েছিল।

এখান থেকে একটি শিক্ষা আছে। EJB 2 আগে মানক লিখেছিল, তারপর বাস্তবতাকে তার সাথে মেলাতে চেয়েছিল। JPA উল্টোটা করেছে — যে পদ্ধতি ডেভেলপাররা আগেই বেছে নিয়েছিল, তাকে মানক বানিয়েছে। আজও Hibernate নিজের API-তে নতুন জিনিস আগে আনে, পরে কিছু অংশ মানকে যায়।

মূল উপলব্ধি: টেকসই মানক সাধারণত পরীক্ষিত চর্চাকে লিখে রাখে, শূন্য থেকে আবিষ্কার করে না। আর যে প্রযুক্তি টেস্ট করা কঠিন বানায়, ডেভেলপাররা শেষ পর্যন্ত তাকে ছেড়ে যায়।

প্র ০২JPA ২০০৬ থেকে আজ পর্যন্ত কীভাবে বদলেছে, আর javax.persistence থেকে jakarta.persistence-য় নাম বদল পুরোনো প্রজেক্টের জন্য কী অর্থ বহন করে?

JPA 1.0 ছিল প্রয়োজনীয় ন্যূনতম। এরপর প্রতিটি সংস্করণ বাস্তব প্রজেক্টের অভাব মিটিয়েছে। JPA 2.0 (২০০৯, JSR 317) আনে Criteria API — string-এর বদলে Java object দিয়ে query বানানো — আর orphanRemoval, @ElementCollection, pessimistic lock-এর মানক রূপ। JPA 2.1 (২০১৩) আনে entity graph — এই পর্বের @EntityGraph-এর ভিত্তি — attribute converter আর stored procedure ডাকার উপায়। JPA 2.2 (২০১৭) java.time-এর LocalDate-এর মতো টাইপ সরাসরি সমর্থন করে।

তারপর আসে নামের বড় বদল। ২০১৭ সালে Oracle Java EE-কে Eclipse Foundation-এ দিয়ে দেয়, কিন্তু “Java” trademark আর javax namespace নতুন করে বদলানোর অধিকার দেয়নি। তাই প্ল্যাটফর্মের নাম হয় Jakarta EE. Jakarta EE 8 (২০১৯) পুরোনো package নামেই ছিল; Jakarta EE 9 (২০২০)-এ সব javax.* package jakarta.* হয়ে যায় — কোনো নতুন ক্ষমতা নয়, শুধু নাম। JPA হয় Jakarta Persistence 3.0. এরপর 3.1 (২০২২, Jakarta EE 10) আর 3.2 (Jakarta EE 11-এর অংশ) কিছু নতুন সুবিধা আনে, যেমন record-কে embeddable হিসেবে ব্যবহার। বৃহত্তর Spring-গল্প পর্ব ৫০-এর ভাবনার প্রশ্ন ৫-এ।

বাস্তবে এর অর্থ: Spring Boot 3.0 (২০২২) আর Hibernate 6 থেকে শুধু jakarta.persistence. Spring Boot 2.x-এর একটি পুরোনো প্রজেক্ট আপগ্রেড করতে প্রতিটি import বদলাতে হয়, আর যে লাইব্রেরি এখনো javax ব্যবহার করে, তার নতুন সংস্করণ খুঁজতে হয়। OpenRewrite-এর মতো টুল এই রূপান্তর অনেকটা স্বয়ংক্রিয় করে। AI-এর জন্য এটা একটি পরিচিত ফাঁদ: ইন্টারনেটে বছরের পর বছর জমা হওয়া উদাহরণের বড় অংশ javax-এ লেখা, তাই AI প্রায়ই পুরোনো import দেয়, যা নতুন প্রজেক্টে compile-ই হয় না।

মূল উপলব্ধি: একটি API-র নাম আইনি আর সাংগঠনিক কারণেও বদলাতে পারে, প্রযুক্তিগত কারণ ছাড়াই। ভালো টেস্ট আর অল্প জায়গায় বাঁধা নির্ভরতা থাকলে এমন বদল কয়েক দিনের কাজ; না থাকলে কয়েক মাসের।

প্র ০৩object আর টেবিলের অমিল (impedance mismatch)-এর প্রতিটি দিক JPA কোন কৌশলে সামলায়, আর কোথায় অমিলটা এখনো “ফুটো” হয়ে বেরিয়ে আসে?

অমিলের তত্ত্ব — কেন object-এর জগৎ আর relational জগৎ মৌলিকভাবে আলাদা — পর্ব ৪৯-এর ভাবনার প্রশ্ন ৪ আর Python কোর্সের পর্ব ৫২-এর প্রথম ভাবনার প্রশ্নে আছে। এখানে প্রশ্নটা ব্যবহারিক: এই পর্বের প্রতিটি JPA কৌশল আসলে কোন অমিলের জোড়াতালি, আর জোড়াটা কোথায় খোলে।

পরিচয়। Java-তে দুটি object একই কি না, তা ==; টেবিলে primary key. persistence context এই দুটো মেলায় — এক id, এক object — কিন্তু শুধু একটি context-এর ভেতরে। দুটি transaction-এ আনা একই সারি দুটি আলাদা object; আর detached object-এর equals/hashCode নিয়ে অংশ ২-এর সব জটিলতা এখান থেকেই। সূক্ষ্মতা (granularity). Java-তে Address একটি আলাদা class, টেবিলে কয়েকটি কলাম — @Embeddable এর উত্তর, আর এটা প্রায় ফুটোহীন।

সম্পর্কের দিক। Java reference এক দিকে চলে; foreign key দুই দিকেই JOIN দেয়। তাই JPA-কে বলতে হয় কে owning side, আর দুই দিক মেলানোর দায়িত্ব আপনার helper method-এর — ফুটোটা আপনার কোডে। inheritance. টেবিলে inheritance নেই; JPA তিনটি কৌশল দেয় — SINGLE_TABLE (সব subclass এক টেবিলে, অনেক কলাম null), JOINED (প্রতিটি class আলাদা টেবিল, পড়তে JOIN), TABLE_PER_CLASS (প্রতিটি concrete class আলাদা, polymorphic query-তে UNION). কোনোটিই নিখুঁত নয় — প্রতিটি একটি খরচ বেছে নেওয়া।

চলাচল (navigation). এটাই সবচেয়ে বড় ফুটো। Java-তে reference ধরে হাঁটা প্রায় বিনা খরচে; ডেটাবেসে প্রতিটি পদক্ষেপ একটি network round trip. lazy loading এই পার্থক্য লুকিয়ে রাখে — যতক্ষণ না লুপের ভেতরে N+1 হয়ে তা ফিরে আসে। LazyInitializationException-ও একই ফুটোর আরেক চেহারা: object-এর জগৎ ভাবে reference সবসময় আছে, কিন্তু ডেটাবেসের জগতে তা আনতে একটি খোলা সংযোগ লাগে। আর relational জগতের শক্তি — একটি SQL-এ পুরো set-এ কাজ — object-এর এক এক করে হাঁটার ধরনে হারিয়ে যায়; তাই বাল্ক কাজে @Modifying আর রিপোর্টে projection.

মূল উপলব্ধি: ORM অমিল মুছে দেয় না, তার বেশিরভাগকে সুবিধাজনক ডিফল্টের পেছনে রাখে। প্রতিটি JPA annotation-কে “এই অমিলের জন্য আমি কোন দাম দিতে রাজি” — এই প্রশ্নের উত্তর হিসেবে পড়ুন।

প্র ০৪Martin Fowler ২০১২ সালে “OrmHate” লেখায় ORM-বিরোধী ক্ষোভের জবাব দিয়েছিলেন। তাঁর ভারসাম্যপূর্ণ যুক্তিটি কী, আর আজকের দলের জন্য তা কতটা প্রাসঙ্গিক?

২০১০-এর দশকের শুরুতে ব্লগ আর সম্মেলনে ORM-এর বিরুদ্ধে কথা বলা প্রায় ফ্যাশন হয়ে গিয়েছিল: ORM ধীর, অদ্ভুত SQL বানায়, জটিলতা লুকায়, সরাসরি SQL লেখাই ভালো। ২০১২ সালের মে মাসে Martin Fowler তাঁর ওয়েবসাইটের “bliki”-তে “OrmHate” নামে একটি ছোট লেখায় এর জবাব দেন। তিনি ORM-কে নিখুঁত বলেননি; বরং বলেছিলেন, ক্ষোভের বড় অংশ এসেছে ভুল প্রত্যাশা থেকে।

তাঁর যুক্তির মূল কথাগুলো (ভাবার্থে): object আর relational মডেলের মধ্যে অনুবাদ সত্যিই কঠিন সমস্যা, আর যে সরঞ্জাম কঠিন সমস্যা সামলায়, সেটা সুন্দর হবে না। ORM-এর প্রতিশ্রুতি কখনো হওয়া উচিত নয় “ডেটাবেস ভুলে যান”; ভালো ORM বেশিরভাগ একঘেয়ে কাজ — সাধারণ CRUD আর সম্পর্ক ধরে আনা — সরিয়ে দেয়, আর বাকি কঠিন অংশের জন্য আপনাকে SQL-এ নামার রাস্তা খোলা রাখে। যারা ORM বাদ দিয়ে নিজেরাই mapping কোড লেখেন, তাঁরা প্রায়ই একটি ছোট, কম পরীক্ষিত, ঘরে-বানানো ORM বানিয়ে ফেলেন। আর আসল বিকল্প হলো relational ডেটাবেস নিজেই বাদ দেওয়া — যেমন document ডেটাবেস, যেখানে mapping-এর সমস্যা ছোট — কিন্তু সেটা অন্য অনেক কিছুর দাম দিয়ে।

এই পর্বের ভাষায় অনুবাদ করলে: Spring Data JPA-র derived query আর dirty checking সেই একঘেয়ে কাজ সরায়; @Query, native query, projection আর JdbcClient হলো SQL-এ নামার রাস্তা। ক্ষোভের যে অভিযোগগুলো সত্যি — N+1, open-in-view, EAGER-এর শিকল — সেগুলো প্রায় সবই দেখা দেয় যখন কেউ ORM-কে “ডেটাবেস ভুলে যাওয়ার” যন্ত্র হিসেবে ব্যবহার করে, SQL log না দেখে।

আজকের প্রাসঙ্গিকতা আরও বেড়েছে। AI দিয়ে entity আর repository লেখা এখন কয়েক সেকেন্ডের কাজ, ফলে “ORM নিজে সব সামলাবে” মানসিকতা আরও সহজে ঢোকে। Fowler-এর পরামর্শ তাই একটি দক্ষতার তালিকায় দাঁড়ায়: ORM ব্যবহার করুন, কিন্তু SQL জানুন, log পড়ুন, আর জানুন কখন ORM থেকে বেরিয়ে যেতে হবে।

মূল উপলব্ধি: একটি সরঞ্জামের প্রতি ক্ষোভ প্রায়ই তার কাছে ভুল প্রতিশ্রুতি চাওয়া থেকে আসে। ORM-কে “SQL-এর বিকল্প” নয়, “SQL-এর সহকারী” হিসেবে দেখলে বেশিরভাগ যন্ত্রণা চলে যায়।

প্র ০৫কখন ORM বাদ দিয়ে jOOQ বা Spring-এর JdbcClient দিয়ে সরাসরি SQL লেখা ভালো? একই প্রজেক্টে দুটো মেশানো কি দুর্বলতা, না পরিণত নকশা?

JPA-র শক্তি “একটি entity আনো, নিয়ম মেনে বদলাও, সংরক্ষণ করো” — অর্ডার নেওয়া, প্রোফাইল বদলানো, স্টক কমানো। এখানে persistence context, dirty checking, cascade আর @Version সত্যিকারের মূল্য দেয়। কিন্তু অনেক কাজ এমন নয়। মাসিক বিক্রির রিপোর্ট — জেলা আর মাস ধরে, আগের মাসের সাথে তুলনা, শীর্ষ দশ পণ্যের ক্রম — SQL-এর window function, CTE আর জটিল GROUP BY-এর কাজ। JPQL সীমিত (Hibernate 6-এর HQL অনেক এগিয়েছে, কিছু window function আর CTE-ও বোঝে, তবু SQL-এর পুরো ক্ষমতা নয়)। আর রিপোর্টে কেউ কিছু বদলায় না, তাই খাতা আর snapshot শুধু খরচ।

এখানে দুটি জনপ্রিয় বিকল্প। JdbcClient এসেছে Spring Framework 6.1-এ (Spring Boot 3.2): jdbcClient.sql("select ...").param("from", from).query(DistrictSales.class).list() — পর্ব ৪৯-এর JDBC-র সব আনুষ্ঠানিকতা ছাড়াই সরাসরি SQL, আর ফল সরাসরি record-এ। আর jOOQ (Lukas Eder-এর তৈরি) ডেটাবেসের schema পড়ে Java class বানায়, তারপর Java method দিয়ে SQL লেখায়: টেবিল বা কলামের নাম ভুল হলে compile-এর সময়েই ধরা পড়ে, আর window function-সহ প্রায় পুরো SQL ব্যবহার করা যায়। jOOQ-এর open source সংস্করণ বিনামূল্যে open source ডেটাবেসের জন্য; কিছু বাণিজ্যিক ডেটাবেসে লাইসেন্স লাগে।

একই প্রজেক্টে দুটো মেশানো দুর্বলতা নয় — এটা প্রায়ই পরিণত নকশা। একটি প্রচলিত বিভাজন: লেখার দিকে (command) JPA entity আর transaction, যেখানে ব্যবসার নিয়ম আর optimistic locking দরকার; পড়ার আর রিপোর্টের দিকে (query) সরাসরি SQL বা projection, যা পর্দার চাহিদামতো আকারে ডেটা দেয়। এই ধারণার পূর্ণ রূপের নাম CQRS, যদিও ছোট প্রজেক্টে শুধু এই মানসিক বিভাজনটুকুই যথেষ্ট। দুটো একই DataSource আর একই Spring transaction ব্যবহার করতে পারে। একটি সতর্কতা: একই transaction-এ JPA দিয়ে বদলানোর পর সরাসরি SQL দিয়ে পড়লে, জমে থাকা বদল আগে flush হয়েছে কি না নিশ্চিত হতে হয় — নইলে SQL পুরোনো ডেটা দেখতে পারে।

মূল উপলব্ধি: সরঞ্জাম বাছুন কাজের ধরন দেখে, প্রজেক্টের নাম দেখে নয়। বদলানোর জন্য ORM, দেখানোর আর বিশ্লেষণের জন্য SQL — দুটো পাশাপাশি থাকাই অনেক সফল সিস্টেমের চেহারা।

অনুশীলন

বেশিরভাগ কাজে নতুন কোড লিখতে হবে না — ওপরের কোড বদলে দেখা আর ভাবাই আসল কাজ।

  1. dirty checking পরীক্ষা: অংশ ৩-এর চালানো যায় এমন প্রোগ্রামে tea.stock -= 3;-এর পরে আরেকটি লাইন যোগ করুন: tea.stock += 3;. আর em.find(2)-এর ফল একটি ভ্যারিয়েবলে রেখে তার name বদলে "Basmati" করুন। না চালিয়ে বলুন প্রথম flush() কী ছাপাবে। তারপর চালিয়ে মিলিয়ে নিন।

    চায়ের জন্য কোনো UPDATE নেই — স্টক ৫০ থেকে ৪৭, তারপর আবার ৫০; flush-এর সময় বর্তমান মান snapshot-এর সমান। dirty checking “কেউ setter ডেকেছে কি না” দেখে না, দেখে শেষ মান আলাদা কি না। চালের জন্য একটি লাইন: SQL: update product set name='Basmati', stock=20 where id=2, তারপর flush done. আসল Hibernate-এও একই: মান আগের জায়গায় ফিরে এলে UPDATE যায় না। এখান থেকে আরেকটি শিক্ষা: managed entity-তে “শুধু দেখার জন্য” মান বদলানো বিপজ্জনক — ফিরিয়ে না দিলে commit-এ তা টেবিলে লেখা হবে।

  2. query গুনুন: একটি transaction-এ orderRepo.findAll() ২০টি অর্ডার আনল। প্রতিটি অর্ডারের জন্য লুপে o.getCustomer().getName() আর o.getItems().size() ডাকা হলো। ২০টি অর্ডার মাত্র ৮ জন আলাদা গ্রাহকের। সব সম্পর্ক LAZY, batch fetching বন্ধ। মোট কয়টি SQL? default_batch_fetch_size=16 দিলে মোটামুটি কয়টি?

    অর্ডারের তালিকা ১টি। গ্রাহক: ৮টি — একই গ্রাহক দ্বিতীয়বার এলে first-level cache থেকে আসে। আইটেম: প্রতিটি অর্ডারের সংগ্রহ আলাদা, তাই ২০টি। মোট ১ + ৮ + ২০ = ২৯টি। batch size ১৬ দিলে গ্রাহকের প্রথম proxy ছোঁয়ার সময় Hibernate খাতায় থাকা অন্য না-আনা গ্রাহক proxy-গুলোও একটি in (...) query-তে আনে — ৮ জন একটিতেই। আইটেমের সংগ্রহ ১৬টি করে — ২০টির জন্য ২টি query. মোট মোটামুটি ১ + ১ + ২ = ৪টি (Hibernate-এর সংস্করণভেদে batch-এর ঠিক আকার একটু আলাদা হতে পারে)। শুধু একটি property বদলে ২৯ থেকে ৪ — তবু পড়ার পাতায় সবচেয়ে ভালো হলো একটি projection query.

  3. AI-এর কোডে ভুল খুঁজুন: নিচের কোডে অন্তত পাঁচটি সমস্যা আছে। প্রতিটি কী ক্ষতি করবে, বলুন।
    @Entity public class Order {
        @Id @GeneratedValue Long id;
        @ManyToOne Customer customer;
        @OneToMany(fetch = FetchType.EAGER) List<OrderItem> items;
        OrderStatus status;
    }
    @Service public class OrderService {
        public void cancelAll(List<Long> ids) { for (Long id : ids) cancel(id); }
        @Transactional
        private void cancel(Long id) { orderRepo.findById(id).get().setStatus(CANCELLED); }
    }

    (১) @ManyToOne-এ fetch লেখা নেই — ডিফল্ট EAGER; প্রতিটি অর্ডারের সাথে গ্রাহক আসবে, প্রায়ই N+1. (২) items EAGER — যেকোনো অর্ডার আনলেই সব আইটেম; তালিকার পাতায় বিপুল ডেটা। (৩) @OneToMany-এ mappedBy নেই — Hibernate একে আলাদা একমুখী সম্পর্ক ভাবে আর একটি বাড়তি join table আশা করে; cascade, orphanRemoval বা helper method-ও নেই। (৪) status-এ @Enumerated(EnumType.STRING) নেই — ORDINAL সংখ্যা লেখা হবে। (৫) @Transactional private method-এ, আর একই class থেকে ডাকা — দুই কারণেই transaction নেই। ফলে setStatus-এর পর কেউ flush করে না; transaction ছাড়া entity managed থাকে না, তাই বদল টেবিলে যায়ই না (open-in-view চালু থাকলে আরও বিভ্রান্তিকর আচরণ)। (৬) findById(id).get() — না পেলে NoSuchElementException; orElseThrow দিন। (৭) প্রতিটি id-তে আলাদা transaction আর আলাদা SELECT; একটি @Transactional public cancelAll আর findAllById, বা শর্তসহ একটি বাল্ক UPDATE ভালো।

  4. নিজে লিখুন — কম স্টকের রিপোর্ট: ProductRepository-তে দুটি method লিখুন: (ক) একটি derived query, যা একটি জেলার যেসব পণ্যের স্টক একটি সংখ্যার কম, সেগুলো স্টক অনুযায়ী ছোট থেকে বড় ক্রমে দেয়; (খ) একটি JPQL query, যা প্রতিটি জেলার কম-স্টক (১০-এর নিচে) পণ্যের সংখ্যা একটি record DTO-তে দেয়।

    একটি সম্ভাব্য সমাধান:

    List<Product> findByDistrictAndStockLessThanOrderByStockAsc(String district, int threshold);
    
    public record LowStock(String district, long products) {}
    
    @Query("""
        select new com.shop.report.LowStock(p.district, count(p))
        from Product p
        where p.stock < 10 and p.status = com.shop.ProductStatus.ACTIVE
        group by p.district
        order by count(p) desc
        """)
    List<LowStock> lowStockByDistrict();

    (ক)-তে নামের প্রতিটি অংশ একটি শর্ত: District সমান, StockLessThan, আর OrderByStockAsc. (খ)-তে constructor expression সরাসরি record বানায় — managed entity নেই, snapshot নেই। count(p)-এর ফল JPQL-এ Long, তাই record-এ long. চাইলে ১০ সংখ্যাটি :threshold parameter করুন। পরীক্ষা করতে অংশ ৮-এর মতো একটি @DataJpaTest লিখুন, আর SQL log-এ দেখুন একটিই GROUP BY query যাচ্ছে।

নিজেকে যাচাই করুন

ভুল হলে কারণসহ ব্যাখ্যা পাবেন, আবার চেষ্টাও করতে পারবেন।

ক্রমএকটি নতুন JPA entity-র জীবনের ঘটনাগুলো সঠিক ক্রমে সাজান (▲▼ চেপে সরান)

  1. 🆕 new দিয়ে object তৈরি — transient, id নেই
  2. 📌 em.persist() — managed, persistence context-এ উঠল
  3. ✏️ setter দিয়ে field বদল — শুধু মেমরিতে
  4. 📤 commit-এর আগে flush — জমে থাকা SQL ডেটাবেসে গেল
  5. ✅ COMMIT — বদল স্থায়ী হলো
  6. 🔌 context বন্ধ — object detached, আর বদল ট্র্যাক হয় না

JPA আর Hibernate-এর সম্পর্ক কোনটি?

  • Hibernate একটি মানক, JPA তার বাস্তবায়ন
  • JPA একটি মানক API, Hibernate তার একটি বাস্তবায়ন
  • দুটো একই লাইব্রেরির দুটি নাম
  • Hibernate একটি ডেটাবেস, JPA তার driver

একটি @Transactional method-এর ভেতরে repository থেকে আনা Product-এ product.setStock(47) লেখা হলো, কিন্তু save() ডাকা হয়নি। method স্বাভাবিকভাবে শেষ হলে কী ঘটে?

  • কিছুই লেখা হয় না, কারণ save() ডাকা হয়নি
  • TransactionRequiredException ছোড়া হয়
  • commit-এর আগে Hibernate নিজে থেকে UPDATE চালায়
  • সারিটি DELETE করে নতুন INSERT হয়

JPA-তে কোন সম্পর্কের ডিফল্ট fetch type EAGER?

  • @ManyToOne আর @OneToOne
  • @OneToMany আর @ManyToOne
  • @ManyToMany আর @OneToOne
  • সব সম্পর্কই ডিফল্টে LAZY

একটি ORM-ভিত্তিক অ্যাপে ১০০টি অর্ডারের তালিকা দেখাতে ১০১টি SQL query যাচ্ছে — ১টি অর্ডারের জন্য আর প্রতিটি অর্ডারের গ্রাহকের জন্য ১টি করে। কোনটি এর সঠিক প্রতিকার নয়?

  • JPQL-এ join fetch o.customer
  • repository method-এ @EntityGraph
  • hibernate.default_batch_fetch_size চালু করা
  • সম্পর্কটি FetchType.EAGER করে দেওয়া

Spring-এর একটি service class-এ public method nightly() একই class-এর @Transactional method rebuild()-কে সরাসরি ডাকে। বাইরে থেকে nightly() ডাকা হলে কী হয়?

  • rebuild()-এর জন্য নতুন transaction শুরু হয়
  • কোনো transaction শুরু হয় না, আর কোনো error-ও আসে না
  • অ্যাপ চালু হওয়ার সময়েই error দেয়
  • compile error হয়

Product entity-তে @Version আছে। দুটি transaction একই সারি version 1 অবস্থায় পড়ল; প্রথমটি স্টক বদলে commit করল। দ্বিতীয়টি নিজের বদল commit করতে গেলে কী ঘটে?

  • দ্বিতীয়টির বদল প্রথমটির ওপর লেখা হয়ে যায়
  • দ্বিতীয়টি প্রথমটি শেষ হওয়া পর্যন্ত পড়ার সময়েই আটকে ছিল
  • OptimisticLockException ছোড়া হয়, transaction rollback হয়
  • Hibernate দুটি বদল মিলিয়ে একটি নতুন মান লেখে

শব্দকোষ

লেখার মধ্যে ডটেড দাগ দেওয়া শব্দে মাউস রাখুন বা চাপুন — অর্থ দেখাবে। এই পর্বের সব নতুন শব্দ এখানে একসাথে:

মনে রাখুন

  1. তিন স্তর: JPA (মানক) → Hibernate (বাস্তবায়ন) → Spring Data JPA (repository). প্রতিটি সুবিধার পেছনে SQL আছে — log দেখে জানুন কোনটি, কখন।
  2. mapping: SEQUENCE id batch-এর জন্য ভালো, IDENTITY সহজ; enum-এ EnumType.STRING; value object-এ @Embeddable; entity সাধারণ class, DTO record; equals/hashCode ব্যবসায়িক key বা id + স্থির hashCode দিয়ে।
  3. persistence context: transient → managed → detached (আর removed). এক id, এক object; dirty checking-এ save() ছাড়াই UPDATE; flush মানে SQL পাঠানো, commit নয়।
  4. relationship: @ManyToOne owning side, @OneToMany(mappedBy) + helper method; cascade শুধু মালিক থেকে সন্তানে; সব LAZY; N+1-এর প্রতিকার join fetch, @EntityGraph, batch fetching, projection; open-in-view=false.
  5. transaction: @Transactional service-এর public method-এ; proxy-র কারণে self-invocation কাজ করে না; checked exception-এ ডিফল্টে commit; @Version lost update ঠেকায়; production schema Flyway/Liquibase-এ।

পরের পর্বে: দোকানের API আর ডেটা এখন শক্ত; কিন্তু যে কেউ যেকোনো endpoint ডাকতে পারে। পর্ব ৫৩-এ Spring Security — লগইন, কে কোন কাজ করতে পারবে, আর একটি নিরাপদ API.

আরও পড়ুন · ABCL TECH-এ আপনার পরবর্তী পদক্ষেপ

এই পর্বটি শেয়ার করুন:
এই পর্বে ভুল পেয়েছেন? জানান