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

Spring Boot আর Rails — enterprise Java আর convention over configuration

Spring Boot and Rails — Enterprise Java and Convention over Configuration

Taught in Bangla, this lesson contrasts explicit Spring Boot with convention-driven Rails: starters, auto-configuration and a runnable jar, constructor injection and the IoC container, Spring Data JPA and Hibernate N+1 fixed with an entity graph, turning the default 400 into a field-level 422, Rails generators, migrations and naming rules, Active Record with includes and strong parameters, Hotwire and Rails 8 defaults, and an honest comparison.

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

  • Spring Boot-এর starter, auto-configuration আর IoC container কীভাবে একটি অ্যাপ জোড়া লাগায়
  • Spring Data JPA আর Hibernate-এ entity, repository, N+1 আর তার দুই সমাধান
  • Bean Validation-এর ডিফল্ট 400 থেকে মাঠভিত্তিক 422 ProblemDetail-এ যাওয়া
  • Rails-এর generator, migration, Active Record, includes আর params.expect
  • মডিউল ১০-এর সব framework, Express আর Next.js-এর সাথে একই slice-এ সৎ তুলনা করে বেছে নেওয়া

১ · দুই দর্শন — সব লিখে দেওয়া, নাকি নিয়ম মেনে চলা

দুটি রেস্তোরাঁর কথা ভাবুন। একটি বড় হোটেল চেইনের রান্নাঘর। সেখানে প্রতিটি পদের লিখিত নিয়ম আছে, কে কোন চুলায় দাঁড়াবেন তার তালিকা আছে, আর সব কিছু খাতায় সই করে হয়। নতুন কেউ এলে খাতা পড়ে কাজ বোঝেন। আরেকটি পাড়ার পুরোনো হোটেল। সেখানে লেখা কিছু নেই, কিন্তু সবাই জানেন লবণ বাঁ দিকের তাকে, ডাল সবসময় তিন নম্বর হাঁড়িতে। নিয়ম মানলে কিছু বলতেই হয় না। নিয়ম না জানলে নতুন মানুষ প্রথম দিন হারিয়ে যান।

প্রথমটি Spring Boot-এর দর্শন: Java-র কঠোর type, স্পষ্ট annotation আর একটি container, যা সব অংশ জোড়া লাগায়। দ্বিতীয়টি Ruby on Rails-এর convention over configuration: Product নামের model থাকলে টেবিল products, controller ProductsController, আর কাউকে তা বলে দিতে হয় না। পর্ব ৪-এ (Architecture pattern) দেখা MVC দুটিরই কঙ্কাল।

ইতিহাস, সংক্ষেপে: Rod Johnson ২০০২ সালের একটি বইয়ে ভারী J2EE-র বিকল্প হিসেবে যে কোড দেখান, সেখান থেকেই Spring Framework জন্মায়। প্রথম পূর্ণ সংস্করণ ১.০ আসে ২০০৪ সালের দিকে। এরপর প্রায় দশ বছর ধরে XML-এ লম্বা configuration লেখা Spring-এর পরিচিত অভিযোগ ছিল। ২০১৪ সালের দিকে Spring Boot ১.০ এই অভিযোগের উত্তর দেয়: যুক্তিসঙ্গত ডিফল্ট, ভেতরে বসানো server আর একটিমাত্র jar. অন্যদিকে David Heinemeier Hansson (DHH) Basecamp নামের একটি পণ্য বানানোর সময় তার কাঠামো আলাদা করে ২০০৪ সালে Rails প্রকাশ করেন, আর ১.০ আসে ২০০৫ সালের ডিসেম্বরে। এই পর্বের সব run লেখার সময়ের সংস্করণে: Spring Boot 4.1.2 (Spring Framework 7.0.10, Hibernate 7.4.13, Tomcat 11.0.27), Temurin JDK 25.0.5, Maven 3.10.0, আর Rails 8.1.4 (Ruby 4.0.9, SQLite) সংস্করণে।

দুই framework-ই বাজারঘরের একই টুকরো বানিয়েছে: ৩টি ক্যাটাগরি, ৬টি পণ্য, স্টকে থাকা পণ্যের তালিকা দামের ক্রমে, একটি পণ্য দেখা, আর যাচাইসহ নতুন পণ্য যোগ। Laravel, Django আর FastAPI-র lab-এও একই fixture, তাই শেষে তুলনা ন্যায্য হবে। নিচের ছবিতে একটি request দুই stack-এ কোন কোন স্তর পেরোয়।

একই GET /products, দুই stack

বাঁয়ে Spring Boot, ডানে Rails · প্রতিটি বাক্স একটি স্তর

Spring Boot আর Rails-এ একটি request-এর স্তর Spring Boot Rails Tomcatভেতরে বসানো server DispatcherServletURL → method @RestControllerআপনার কোড Repositoryশুধু interface Hibernate (JPA)SQL বানায় JDBC → H2ডেটাবেস Pumaserver Routerresources :products ProductsControllerআপনার কোড Active Recordmodel নিজেই SQL SQLiteডেটাবেস স্তর বেশি মানে খারাপ নয়: প্রতিটি স্তর আলাদা করে বদলানো যায়
ছবি ৭১.১ — Spring-এ ডেটা পথে তিনটি আলাদা অংশ (repository, Hibernate, JDBC), Rails-এ model নিজেই ডেটাবেসের সাথে কথা বলে (Active Record নকশা)।

২ · Spring Boot শুরু — starter, auto-configuration আর একটি jar

Spring Boot project সাধারণত start.spring.io (Spring Initializr) থেকে শুরু হয়। আপনি dependency বেছে নেন, সে একটি zip দেয়। ভেতরের সবচেয়ে গুরুত্বপূর্ণ ফাইল Maven-এর pom.xml. Maven আর Gradle-এর বিস্তারিত আছে Java কোর্সের Maven পর্বে.

<parent>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-parent</artifactId>
  <version>4.1.2</version>
</parent>
<properties><java.version>25</java.version></properties>
<dependencies>
  <dependency><artifactId>spring-boot-starter-webmvc</artifactId></dependency>
  <dependency><artifactId>spring-boot-starter-data-jpa</artifactId></dependency>
  <dependency><artifactId>spring-boot-starter-validation</artifactId></dependency>
  <dependency><artifactId>h2</artifactId><scope>runtime</scope></dependency>
</dependencies>

এখানে দুটি ধারণা। Starter হলো একটি “ঝুড়ি” dependency: starter-data-jpa লিখলে Hibernate, connection pool, Spring Data সব একসাথে আসে, আর তাদের version পরস্পরের সাথে মেলানো থাকে। Parent POM সেই version-গুলোর তালিকা রাখে, তাই ওপরে কোনো dependency-তে version লেখা নেই। লক্ষ করুন, web-এর starter-টির নাম spring-boot-starter-webmvc. Spring Boot 4-এ Initializr এই নাম দিয়েছে। Boot 3-এর লেখা ও টিউটোরিয়ালে আপনি spring-boot-starter-web দেখবেন, তাই কোন সংস্করণের docs পড়ছেন মিলিয়ে নিন।

প্রথম ফাঁদ: .RELEASE

Initializr-এর metadata-তে version লেখা ছিল 4.1.2.RELEASE. পুরোনো Spring Boot সংস্করণে (২.৩ পর্যন্ত; ২.৪ থেকে নেই) সত্যিই এমন নাম ছিল, তাই অনেক পুরোনো উদাহরণে এটি দেখা যায়। কিন্তু Maven-এর ভাণ্ডারে এখনকার parent-এর নাম শুধু 4.1.2. ভুল নামটি দিলে কী হয়, আমরা ইচ্ছা করে চালিয়ে দেখলাম:

সত্যিকারের run · Maven 3.10.0 · parent-এ 4.1.2.RELEASE · আউটপুট ছাঁটা

$ mvn -U validate
Downloading from central: …/spring-boot-starter-parent/4.1.2.RELEASE/spring-boot-starter-parent-4.1.2.RELEASE.pom
[ERROR] [ERROR] Some problems were encountered while processing the POMs:
[FATAL] Non-resolvable parent POM for com.bazarghar:bazarghar:0.0.1-SNAPSHOT: The following artifacts could not be resolved: org.springframework.boot:spring-boot-starter-parent:pom:4.1.2.RELEASE (absent): Could not find artifact … in central (https://repo.maven.apache.org/maven2) and 'parent.relativePath' points at no local POM @ line 5, column 10

বার্তাটি লম্বা, কিন্তু মূল কথা “absent”: এই নামের ফাইল ভাণ্ডারে নেই। Version থেকে .RELEASE মুছলেই build চলে। Error পড়ার এই অভ্যাস, অর্থাৎ প্রথম FATAL লাইনে কোন artifact আর কেন, খুঁজে নেওয়া, Java জগতে প্রতিদিন কাজে লাগে।

Auto-configuration: “classpath-এ কী আছে, দেখে সাজাও”

আমাদের কোডে কোথাও লেখা নেই “Tomcat চালাও” বা “H2-তে connection pool বানাও”। তবু দুটিই হয়। এর নাম auto-configuration. Spring Boot অনেকগুলো configuration class নিয়ে আসে, প্রতিটির গায়ে শর্ত লেখা: “এই class classpath-এ থাকলে”, “আপনি নিজে এই bean না বানালে”, “এই property থাকলে”। শর্ত মিললে সেটি চালু হয়। --debug দিয়ে চালালে Boot একটি রিপোর্ট ছাপে, কোনটি কেন মিলল আর কোনটি কেন মিলল না। নিচের stepper সেই রিপোর্ট আর startup log থেকে বানানো।

  1. main()run()শুরু
  2. scan@Componentআপনার class
  3. শর্ত@Conditional…auto-config
  4. DataSourceHikari + H2pool
  5. Tomcat:8126প্রস্তুত
BazargharApplication.javaপুরো ফাইল, package বাদে
@SpringBootApplication
public class BazargharApplication {
  public static void main(String[] args) {
    SpringApplication.run(BazargharApplication.class, args);
  }
}
startup logসত্যিকারের run, ছাঁটা
01:10:50.660 Starting BazargharApplication v0.0.1-SNAPSHOT using Java 25.0.5
01:10:51.125 Bootstrapping Spring Data JPA repositories in DEFAULT mode.
01:10:51.160 Finished Spring Data repository scanning in 28 ms.
             Found 2 JPA repository interfaces.
--debug রিপোর্টসত্যিকারের run, ছাঁটা
CONDITIONS EVALUATION REPORT
Positive matches:
   DataSourceAutoConfiguration matched:
      - @ConditionalOnClass found required classes 'javax.sql.DataSource', …
      - @ConditionalOnMissingBean (types: io.r2dbc.spi.ConnectionFactory; …) did not find any beans
Negative matches:
   H2ConsoleAutoConfiguration:
      Did not match:
         - @ConditionalOnBooleanProperty (spring.h2.console.enabled=true) did not find property …
DataSourceরিপোর্ট আর log, ছাঁটা
DataSourceConfiguration.Hikari matched:
   - @ConditionalOnClass found required class 'com.zaxxer.hikari.HikariDataSource'
DataSourceConfiguration.Tomcat:
   Did not match:
   - @ConditionalOnClass did not find required class 'org.apache.tomcat.jdbc.pool.DataSource'
01:10:51.822 HikariPool-1 - Added connection conn0: url=jdbc:h2:mem:bazarghar
Hibernateসত্যিকারের run, ছাঁটা
HibernateJpaConfiguration matched:
   - @ConditionalOnSingleCandidate (types: javax.sql.DataSource) found a single bean 'dataSource'
01:10:51.885 HHH000001: Hibernate ORM core version 7.4.13.Final
01:10:52.747 Initialized JPA EntityManagerFactory for persistence unit 'default'
প্রস্তুতসত্যিকারের run
01:10:51.541 Starting Servlet engine: [Apache Tomcat/11.0.27]
01:10:53.434 Tomcat started on port 8126 (http) with context path '/'
01:10:53.443 Started BazargharApplication in 3.077 seconds
             (process running for 3.396)
  1. একটি লাইন। @SpringBootApplication তিনটি কাজ একসাথে চালু করে: configuration, component scan আর auto-configuration. পুরো অ্যাপ শুরু হয় SpringApplication.run থেকে।
  2. আপনার class খোঁজা। একই package-এর নিচে যত @Component, @RestController আর repository interface আছে, সব তালিকায় ওঠে। আমাদের দুটি repository পাওয়া গেল, ২৮ ms-এ।
  3. শর্ত মেলানো। আমাদের jar-এর ১৪টি Boot module মিলে ৫০টি auto-configuration class-এর নাম দেয়। প্রতিটি নিজের শর্ত দেখে। DataSource মিলল, কারণ JDBC-র class আছে। H2-র web console মিলল না, কারণ property চালু করা হয়নি।
  4. Connection pool. Classpath-এ HikariCP আছে, Tomcat-এর pool নেই, তাই Hikari বেছে নেওয়া হলো। URL এসেছে আমাদের application.properties থেকে।
  5. Hibernate. ঠিক একটি DataSource আছে, তাই JPA-র কারখানা (EntityManagerFactory) তৈরি হলো, আর entity দেখে টেবিল বানানো হলো।
  6. Server প্রস্তুত। Tomcat jar-এর ভেতরেই বসানো, তাই আলাদা server install করতে হয়নি। এই run-এ মোট ৩.০৭৭ সেকেন্ড। আপনার কম্পিউটারে সংখ্যাটি আলাদা হবে।
মূল ধারণা

Auto-configuration জাদু নয়, শর্তসহ ডিফল্ট। আপনি নিজে একটি DataSource bean বানালে @ConditionalOnMissingBean সেটি দেখে Boot-এর ডিফল্ট সরে দাঁড়ায়। তাই নিয়ম হলো: ডিফল্ট পছন্দ না হলে property বদলান, তাতেও না হলে নিজের bean দিন।

একটি jar, তার ওজন আর চালু হওয়ার সময়

mvn package একটি fat jar বানায়: আপনার class আর সব dependency এক ফাইলে। java -jar দিলেই চলে, তাই container image বানানোও সহজ (Docker Compose পর্ব দেখুন)।

সত্যিকারের run · একটি local কম্পিউটারে · ২০২৬-১১-০১ আর ২০২৬-১১-০৩ · সাধারণ ফল নয়

যা মাপা হলোমান
পুরো jar54,291,676 byte (প্রায় ৫৪ MB)
ভেতরের library jar71টি, সবচেয়ে বড় hibernate-core
আমাদের নিজের class23,209 byte
“Started … in”, পাঁচটি run2.75, 3.077, 3.191, 3.2, 3.24 সেকেন্ড

অর্থাৎ jar-এর প্রায় ০.০৪% আমাদের কোড, বাকিটা framework. তুলনায় পর্ব ২৪-এর (Node.js server) একটি সাধারণ Node server প্রায় তৎক্ষণাৎ চালু হয়। ২–৩ সেকেন্ড দিনে একবার চালু হওয়া server-এর জন্য কিছুই না। কিন্তু যেখানে প্রতিটি request-এ নতুন করে চালু হতে হয় (serverless-এর cold start), সেখানে এটি গুরুত্বপূর্ণ। এই কারণে Java জগতে GraalVM native image আর CDS-এর মতো কৌশল আছে, যা এই পর্বে আমরা চালাইনি।

৩ · Controller, constructor injection আর IoC container

এবার বাজারঘরের API. Java-র ভাষা আর record জানতে Java কোর্সের Spring REST পর্ব দেখুন। এখানে আমরা নকশায় মন দেব।

@RestController
@RequestMapping("/api/products")
public class ProductController {
    private final ProductRepository products;
    private final CategoryRepository categories;

    public ProductController(ProductRepository products, CategoryRepository categories) {
        this.products = products;          // কেউ new করে দেবে, আমরা নই
        this.categories = categories;
    }

    @GetMapping
    public List<ProductView> list() {
        return products.findByStockGreaterThanOrderByPrice(0).stream().map(ProductView::of).toList();
    }

    @GetMapping("/{id}")
    public ProductView get(@PathVariable Long id) {
        return products.findWithCategoryById(id).map(ProductView::of)
                .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
    }

    @PostMapping
    @ResponseStatus(HttpStatus.CREATED)
    public ProductView create(@Valid @RequestBody ProductRequest body) {
        Category category = categories.findById(body.categoryId())
                .orElseThrow(() -> new ResponseStatusException(HttpStatus.UNPROCESSABLE_CONTENT, "unknown category"));
        return ProductView.of(products.save(new Product(category, body.name(), body.price(), body.stock())));
    }
}

@RestController বলে “এই class-এর method-এর ফল JSON হয়ে যাবে”। @GetMapping("/{id}") URL আর method জোড়ে, Express-এর router.get('/:id', …)-এর মতো (পর্ব ২৫)। লক্ষ করুন, controller entity সরাসরি ফেরত দেয় না, ছোট একটি ProductView record দেয়। এতে ক্লায়েন্ট কোন ঘর দেখবে তা আমরা ঠিক করি, আর Hibernate-এর অর্ধেক লোড হওয়া object JSON-এ যাওয়ার ঝুঁকি থাকে না।

সবচেয়ে গুরুত্বপূর্ণ লাইন constructor. Controller নিজে repository বানায় না, চায়। কে দেয়? Spring-এর IoC container. পর্ব ২৯-এ (Backend-এর গঠন) আমরা compose.ts-এ হাতে dependency injection করেছিলাম, আর বলেছিলাম object অনেক হলে container লাগে। Spring ঠিক সেই container. একে inversion of control বলে, কারণ object বানানোর নিয়ন্ত্রণ আপনার হাত থেকে framework-এর হাতে যায়। Constructor-এ চাওয়াই এখনকার পরামর্শ: field final রাখা যায়, আর test-এ নকল repository হাতে দিয়ে new ProductController(…) লেখা যায়।

Container ভেতরে কী করে? Constructor-এর parameter-এর type পড়ে, আগে সেগুলো বানায়, তারপর নিজেকে। আর একবার বানানো object মনে রাখে, ফলে পুরো অ্যাপে প্রতিটি bean সাধারণত একটিই , যাকে বলে singleton. নিচে reflection দিয়ে ৩০ লাইনের একটি শেখানোর মডেল, আসল Spring নয়:

import java.lang.reflect.Constructor;
import java.util.*;
class Db { }
class ProductRepository { final Db db; ProductRepository(Db db) { this.db = db; } }
class CategoryRepository { final Db db; CategoryRepository(Db db) { this.db = db; } }
class ProductController { ProductController(ProductRepository p, CategoryRepository c) { } }
class Container {
    private final Map<Class<?>, Object> beans = new LinkedHashMap<>();
    private int depth = 0;
    <T> T get(Class<T> type) throws Exception {
        String pad = "  ".repeat(depth);
        if (beans.containsKey(type)) { System.out.println(pad + "reuse  " + type.getSimpleName()); return type.cast(beans.get(type)); }
        Constructor<?> ctor = type.getDeclaredConstructors()[0];
        Class<?>[] needs = ctor.getParameterTypes();
        System.out.println(pad + "need   " + type.getSimpleName() + " (" + needs.length + " args)");
        Object[] args = new Object[needs.length];
        depth++;
        for (int i = 0; i < needs.length; i++) args[i] = get(needs[i]);
        depth--;
        Object bean = ctor.newInstance(args);
        beans.put(type, bean);
        System.out.println(pad + "create " + type.getSimpleName());
        return type.cast(bean);
    }
    int size() { return beans.size(); }
}
class Main {
    public static void main(String[] args) throws Exception {
        Container c = new Container();
        c.get(ProductController.class);
        System.out.println("beans: " + c.size() + ", same Db? " + (c.get(ProductRepository.class).db == c.get(CategoryRepository.class).db));
    }
}

আউটপুট গাছের মতো: need ProductController (2 args), তার নিচে need ProductRepository (1 args), আরও নিচে create Db. দ্বিতীয় repository-র সময় লেখা আসে reuse Db, নতুন Db নয়। শেষ লাইন beans: 4, same Db? true: চারটি object, আর দুই repository একই Db ভাগ করে। আসল Spring এর সাথে আরও অনেক কিছু করে: interface থেকে আসল class খোঁজা, একাধিক প্রার্থী হলে error, circular dependency ধরা, আর proxy বানিয়ে transaction জোড়া। Annotation আর reflection-এর ভিত্তি আছে Java কোর্সের annotation পর্বে.

৪ · Spring Data JPA, Hibernate আর N+1

Java-য় ডেটাবেসের মান হলো JPA (Jakarta Persistence): annotation আর interface-এর একটি নিয়মপত্র। সেটি বাস্তবায়ন করে Hibernate, যা SQL বানায়। আর Spring Data JPA তার ওপর repository দেয়। পর্ব ৩৮-এর (ORM-এর ধারণা) ভাষায় Hibernate একটি data mapper: entity class সাধারণ Java object, নিজে নিজেকে save করে না।

@Entity
public class Product {
    @Id @GeneratedValue
    private Long id;
    private String name;
    private int price;
    private int stock;
    @ManyToOne(fetch = FetchType.LAZY, optional = false)
    private Category category;     // টেবিলে category_id কলাম

    protected Product() { }        // JPA-র জন্য খালি constructor
}
public interface ProductRepository extends JpaRepository<Product, Long> {
    @EntityGraph(attributePaths = "category")
    List<Product> findByStockGreaterThanOrderByPrice(int stock);

    @EntityGraph(attributePaths = "category")
    Optional<Product> findWithCategoryById(Long id);

    @Query("select p from Product p join fetch p.category order by p.price")
    List<Product> findAllWithCategory();
}

Repository-তে কোনো method body নেই! findByStockGreaterThanOrderByPrice নামটি পড়েই Spring Data query বানায়: where stock > ? order by price. এটিও এক ধরনের convention, Rails-এর মতোই। আর JpaRepository থেকে findAll, findById, save বিনা খরচে আসে। Spring Data-র বিস্তারিত আছে Java কোর্সের Spring Data JPA পর্বে.

N+1: আমাদের run-এ ৭ নয়, ৪

পর্ব ৪২-এ (N+1 সমস্যা) Prisma-য় লুপটি হাতে লিখতে হতো। Hibernate-এ lazy loading আছে: p.getCategory().getName() লিখলেই গোপনে SQL যায়। একটি transaction-এর ভেতরে তিনটি পথ মাপা হলো, Hibernate-এর statistics দিয়ে SQL গুনে:

count("Lazy loading", () -> products.findAll().forEach(p -> p.getCategory().getName()));
count("@EntityGraph", () -> products.findByStockGreaterThanOrderByPrice(-1).forEach(p -> p.getCategory().getName()));
count("join fetch",   () -> products.findAllWithCategory().forEach(p -> p.getCategory().getName()));

সত্যিকারের run · Hibernate 7.4.13 · H2 · ৩টি ক্যাটাগরি, ৬টি পণ্য · spring.jpa.show-sql=true · ছাঁটা

Hibernate: select p1_0.id,p1_0.category_id,p1_0.name,p1_0.price,p1_0.stock from product p1_0
Hibernate: select c1_0.id,c1_0.name from category c1_0 where c1_0.id=?
Hibernate: select c1_0.id,c1_0.name from category c1_0 where c1_0.id=?
Hibernate: select c1_0.id,c1_0.name from category c1_0 where c1_0.id=?
Lazy loading: 4 queries
Hibernate: select p1_0.id,p1_0.category_id,c1_0.id,c1_0.name,p1_0.name,p1_0.price,p1_0.stock from product p1_0 join category c1_0 on c1_0.id=p1_0.category_id where p1_0.stock>? order by p1_0.price
@EntityGraph: 1 queries
Hibernate: select … from product p1_0 join category c1_0 on c1_0.id=p1_0.category_id order by p1_0.price
join fetch: 1 queries

৬টি পণ্যে ১ + ৬ = ৭ আশা করেছিলেন? এসেছে ৪. কারণ Hibernate-এর persistence context. একটি transaction-এর ভেতরে একই id-র ক্যাটাগরি একবার আনা হলে পরেরবার মেমরি থেকে দেয়। এটি একটি identity map. তাই সংখ্যাটি ১ + (আলাদা ক্যাটাগরির সংখ্যা) = ১ + ৩. সান্ত্বনা পাবেন না: ১,০০০ পণ্য ২০০ ক্যাটাগরিতে ছড়ানো থাকলে ২০১টি SQL, আর প্রতিটি পণ্যের আলাদা বিক্রেতা থাকলে আবার পুরো ১ + N.

@EntityGraph বলে “এই query-তে category-ও আনুন”, আর Hibernate একটি JOIN লেখে। optional = false থাকায় এটি inner join; ক্যাটাগরি ঐচ্ছিক হলে left join হতো। join fetch একই কাজ JPQL-এ হাতে লেখা। দুটিই ১টি SQL.

Transaction-এর বাইরে lazy সম্পর্ক ছুঁলে: আমরা spring.jpa.open-in-view=false রেখেছি। তখন transaction-এর বাইরে getCategory().getName() চালালে আসল run-এ এলো LazyInitializationException, বার্তা Could not initialize proxy [com.bazarghar.Category#1] - no session. এটি ভালো খবর: গোপন SQL চুপচাপ না চলে চোখে পড়ে। Spring Boot-এ এই setting-এর ডিফল্ট চালু, যাতে view-তেও lazy load চলে। এর পক্ষে-বিপক্ষে community-তে বিতর্ক আছে; আমরা বন্ধ রাখার দিকে, কারণ তাতে ডেটাবেসের কাজ controller-এর বাইরে ছড়ায় না।

৫ · Validation — ডিফল্ট 400 থেকে মাঠভিত্তিক 422

Spring-এ ইনপুটের নিয়ম লেখা হয় Bean Validation-এর annotation দিয়ে, request-এর record-এর গায়ে। Controller-এ @Valid লিখলে Spring body পড়ার পরে নিয়মগুলো যাচাই করে।

public record ProductRequest(
        @NotNull Long categoryId,
        @NotBlank String name,
        @Min(1) int price,
        @Min(0) int stock) { }

এবার একটি ভুল body পাঠানো হলো: নাম ফাঁকা, দাম ০. কোনো বাড়তি কোড ছাড়া Spring যা ফেরত দিল:

সত্যিকারের run · Spring Boot 4.1.2 · ডিফল্ট setting · 127.0.0.1

$ POST /api/products  {"categoryId":2,"name":"","price":0,"stock":1}
{"timestamp":"2026-11-02T19:11:03.725Z","status":400,"error":"Bad Request","path":"/api/products"}
HTTP 400 application/json

এই উত্তরে কোন ঘরে সমস্যা, তা নেই। React-এর form কোন input-এর নিচে লাল লেখা দেখাবে, জানবে না। Laravel, FastAPI আর Rails-এর lab-এ একই ভুল body 422 আর ঘরভিত্তিক বার্তা পেয়েছিল। পর্ব ৩৩-এর (Status code আর error format) নিয়মে: body পড়া গেছে কিন্তু নিয়ম ভেঙেছে, তাই 422. দুই ধাপে ঠিক করা যায়।

ধাপ ১, spring.mvc.problemdetails.enabled=true. এতে error-এর আকৃতি RFC 9457-এর problem details হয়, কিন্তু status আর বিস্তারিত বদলায় না:

সত্যিকারের run · একই body · property চালু

{"detail":"Invalid request content.","instance":"/api/products","status":400,"title":"Bad Request"}
HTTP 400 application/problem+json

ধাপ ২, একটি ছোট @RestControllerAdvice, যা validation-এর exception ধরে নিজের ProblemDetail বানায়:

@RestControllerAdvice
public class ValidationErrors {
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ProblemDetail invalid(MethodArgumentNotValidException ex) {
        ProblemDetail problem = ProblemDetail.forStatusAndDetail(
                HttpStatus.UNPROCESSABLE_CONTENT, "The product has invalid fields.");
        Map<String, String> errors = new TreeMap<>();
        ex.getBindingResult().getFieldErrors()
          .forEach(e -> errors.put(e.getField(), e.getDefaultMessage()));
        problem.setProperty("errors", errors);
        return problem;
    }
}

সত্যিকারের run · advice যোগ করার পরে · একই body

{"detail":"The product has invalid fields.","instance":"/api/products","status":422,"title":"Unprocessable Content",
 "errors":{"name":"must not be blank","price":"must be greater than or equal to 1"}}
HTTP 422 application/problem+json

এখন ক্লায়েন্ট errors.name পড়ে ঠিক ঘরে বার্তা দেখাতে পারে। HttpStatus.UNPROCESSABLE_CONTENT নামটি HTTP-র এখনকার মান RFC 9110-এর শব্দ; পুরোনো কোডে UNPROCESSABLE_ENTITY দেখবেন। Annotation পড়ে যাচাই কীভাবে হয়, নিচে reflection দিয়ে একটি শেখানোর মডেল:

import java.lang.annotation.*;
import java.lang.reflect.RecordComponent;
import java.util.*;

@Retention(RetentionPolicy.RUNTIME) @Target(ElementType.RECORD_COMPONENT) @interface NotBlank { }
@Retention(RetentionPolicy.RUNTIME) @Target(ElementType.RECORD_COMPONENT) @interface Min { int value(); }

record ProductRequest(@NotBlank String name, @Min(1) int price, @Min(0) int stock) { }

class Validator {
    static Map<String, String> check(Object body) throws Exception {
        Map<String, String> errors = new TreeMap<>();
        for (RecordComponent rc : body.getClass().getRecordComponents()) {
            Object value = rc.getAccessor().invoke(body);
            if (rc.isAnnotationPresent(NotBlank.class) && (value == null || value.toString().isBlank()))
                errors.put(rc.getName(), "must not be blank");
            Min min = rc.getAnnotation(Min.class);
            if (min != null && (Integer) value < min.value())
                errors.put(rc.getName(), "must be greater than or equal to " + min.value());
        }
        return errors;
    }
}
class Main {
    public static void main(String[] args) throws Exception {
        List<ProductRequest> bodies = List.of(
            new ProductRequest("Darjeeling Tea", 350, 4),
            new ProductRequest("   ", 0, -2));
        for (ProductRequest body : bodies) {
            Map<String, String> errors = Validator.check(body);
            if (errors.isEmpty()) System.out.println("201 " + body);
            else System.out.println("422 " + errors);
        }
    }
}

প্রথম লাইন 201 ProductRequest[name=Darjeeling Tea, price=350, stock=4]. দ্বিতীয় লাইন 422 {name=must not be blank, price=must be greater than or equal to 1, stock=must be greater than or equal to 0}. লক্ষ করুন, শুধু ফাঁকা স্থান দেওয়া নামও isBlank()-এ ধরা পড়েছে, আর তিনটি ভুলই একসাথে এসেছে, প্রথমটিতে থেমে যায়নি। আসল Hibernate Validator (এখানে 9.1.5) একই ভাবনায় চলে, তবে nested object, group আর অনুবাদ করা বার্তাও সামলায়।

দুটি ছোট নোট: virtual thread আর Spring Security

Virtual thread. Spring MVC প্রতিটি request-কে একটি thread দেয়। ডেটাবেসের উত্তরের অপেক্ষায় সেই thread বসে থাকে, আর সাধারণ OS thread দামি, তাই একটি thread pool-এ সীমিত। Java 21 থেকে virtual thread স্থায়ী বৈশিষ্ট্য: খুব হালকা thread, অপেক্ষার সময় আসল OS thread ছেড়ে দেয়। Spring Boot-এর docs অনুযায়ী spring.threads.virtual.enabled=true দিলেই চালু হয় (Java 21 লাগে, docs Java 24 বা পরের সংস্করণ জোর দিয়ে সুপারিশ করে)। আমরা একটি endpoint দিয়ে যাচাই করলাম, যে Thread.currentThread() ফেরত দেয়:

সত্যিকারের run · JDK 25.0.5 · দুবার চালানো, setting ছাড়া আর setting সহ

$ curl -s /api/products/thread
Thread[#69,http-nio-127.0.0.1-8126-exec-8,5,main]                       # ডিফল্ট: pool-এর thread
$ curl -s /api/products/thread        # --spring.threads.virtual.enabled=true
VirtualThread[#74,tomcat-handler-5]/runnable@ForkJoinPool-1-worker-1

এটি Node.js-এর event loop-এর মতো সুবিধা দেয়, কিন্তু কোড থাকে সাধারণ blocking ধাঁচে, async ছাড়াই। তবে docs নিজেই সতর্ক করে: “pinned” virtual thread-এ throughput কমতে পারে, আর thread pool-এর setting তখন আর খাটে না। লোড test ছাড়া এটি চালু করে দ্রুত হবে ভাববেন না। ভেতরের কাজ জানতে Java কোর্সের virtual thread পর্ব দেখুন।

Spring Security. Login, role আর CSRF সামলায় এই আলাদা project. Docs অনুযায়ী starter যোগ করলেই সব endpoint ডিফল্টে লগইন চায়, যা নিরাপদ শুরু। আমরা এই lab-এ এটি চালাইনি। ধারণাগুলো পর্ব ৪৫–৪৮-এ, Spring-এর কোড Java কোর্সের Spring Security পর্বে, আর তত্ত্ব Cybersecurity কোর্সে.

৬ · Rails — generator, migration আর নামের নিয়ম

এবার পাড়ার পুরোনো হোটেল। Rails লেখা হয় Ruby-তে। সাইটে Ruby কোর্স নেই, তাই কোড পড়ার মতো সামান্যটুকু:

Ruby পড়ার ন্যূনতম চাবি: class Product < ApplicationRecord মানে Product, ApplicationRecord-এর উত্তরসূরি। :name একটি symbol, স্থির নামের মতো ছোট string. %i[id name] মানে [:id, :name]. { name: "Tea" } একটি hash (JavaScript object-এর মতো), আর "id" => 5 একই জিনিসের পুরোনো লেখা। do |p| … end বা { |p| … } একটি block, JavaScript-এর (p) => …-এর মতো। -> { … } একটি lambda. Method ডাকতে বন্ধনী ঐচ্ছিক: validates :name, presence: true আসলে validates(:name, { presence: true }). Method-এর শেষ লাইনের মানই ফেরত যায়, return লাগে না।

Rails-এ কাজ শুরু হয় generator দিয়ে। এক লাইনে model, migration, test আর fixture ফাইল তৈরি:

সত্যিকারের run · Rails 8.1.4 · Ruby 4.0.9 · rails new bazarghar --minimal --api থেকে · ~/bazarghar

$ rails generate model Product category:references name:string price:integer stock:integer
      invoke  active_record
      create    db/migrate/20261031202016_create_products.rb
      create    app/models/product.rb
      invoke    test_unit
      create      test/models/product_test.rb
      create      test/fixtures/products.yml

$ rails db:migrate
== 20261031202016 CreateProducts: migrating ===================================
-- create_table(:products)
   -> 0.0062s
== 20261031202016 CreateProducts: migrated (0.0062s) ==========================
class CreateProducts < ActiveRecord::Migration[8.1]
  def change
    create_table :products do |t|
      t.references :category, null: false, foreign_key: true
      t.string :name
      t.integer :price
      t.integer :stock
      t.timestamps
    end
  end
end

কোথাও লেখা নেই টেবিলের নাম products হবে, বা category_id কলাম categories টেবিলকে নির্দেশ করবে। নাম থেকেই Rails বোঝে। t.references index আর foreign key দুটিই দিয়েছে। পর্ব ৪২-এ দেখেছিলেন, PostgreSQL-এ Prisma-র migration foreign key-তে নিজে index দেয়নি; Rails-এর schema.rb-এ এখানে index_products_on_category_id আছে। Migration-এর সাধারণ নিয়ম পর্ব ৪০-এ (Migration আর seed) পাবেন।

Rails-এর নামের নিয়ম কেমন, নিচে Python-এ একটি ছোট শেখানোর মডেল. আসল Rails-এর ActiveSupport::Inflector অনেক বেশি নিয়ম জানে।

# শেখানোর মডেল: Rails-এর নামের convention, খুব ছোট করে
import re

IRREGULAR = {"person": "people", "child": "children"}

def snake(name):                       # OrderItem -> order_item
    return re.sub(r"(?<!^)([A-Z])", r"_\1", name).lower()

def plural(word):                      # category -> categories
    if word in IRREGULAR:
        return IRREGULAR[word]
    if re.search(r"[^aeiou]y$", word):
        return word[:-1] + "ies"
    if re.search(r"(s|x|z|ch|sh)$", word):
        return word + "es"
    return word + "s"

def conventions(model):
    one = snake(model)
    many = "_".join(one.split("_")[:-1] + [plural(one.split("_")[-1])])
    camel = "".join(part.capitalize() for part in many.split("_"))
    return {
        "table": many,
        "file": f"app/models/{one}.rb",
        "controller": f"{camel}Controller",
        "foreign key": f"{one}_id",
        "routes": f"/{many}  /{many}/:id",
    }

for model in ["Product", "Category", "OrderItem", "Box", "Person"]:
    c = conventions(model)
    print(f"{model:<9} table={c['table']:<12} fk={c['foreign key']:<14} {c['controller']}")
print(conventions("OrderItem")["file"], "|", conventions("OrderItem")["routes"])

Category থেকে table=categories, fk=category_id, CategoriesController. Person থেকে people, কারণ অনিয়মিত শব্দের আলাদা তালিকা আছে। শেষ লাইন app/models/order_item.rb | /order_items /order_items/:id. Rails ঠিক এভাবেই ফাইল খোঁজে: OrderItem নাম দেখলে order_item.rb লোড করে। নাম ভুল বানানে লিখলে কোনো configuration ভুল দেখাবে না, শুধু “uninitialized constant” ধরনের error আসবে। Convention-এর এটাই দাম।

একটি request, router থেকে JSON পর্যন্ত

Rails.application.routes.draw do
  resources :products, only: %i[index show create]
end
  1. PumaGETrequest এলো
  2. router#indexনাম মেলানো
  3. controllerindexআপনার কোড
  4. model২টি SQLActive Record
  5. render200JSON
server logসত্যিকারের run
* Puma version: 8.0.2 ("Into the Arena")
* Listening on http://127.0.0.1:8127
Started GET "/products" for 127.0.0.1 at 2026-11-01 02:20:38 +0600
rails routes -c productsসত্যিকারের run
  Prefix Verb URI Pattern             Controller#Action
products GET  /products(.:format)     products#index
         POST /products(.:format)     products#create
 product GET  /products/:id(.:format) products#show
app/controllers/products_controller.rbindex অংশ
Processing by ProductsController#index as */*

def index
  render json: Product.includes(:category).in_stock.order(:price)
    .as_json(only: %i[id name price], include: { category: { only: :name } })
end
server logসত্যিকারের run, comment ছাঁটা
Product Load (0.1ms)  SELECT "products".* FROM "products"
  WHERE (stock > 0) ORDER BY "products"."price" ASC
Category Load (0.1ms)  SELECT "categories".* FROM "categories"
  WHERE "categories"."id" IN (3, 2, 1)
log আর উত্তরসত্যিকারের run, JSON ছাঁটা
Completed 200 OK in 98ms (Views: 0.1ms | ActiveRecord: 2.5ms (2 queries, 0 cached) | GC: 0.0ms)

[{"id":5,"name":"Turmeric 200g","price":95,"category":{"name":"Spices"}},
 {"id":6,"name":"Red Chilli 200g","price":110,"category":{"name":"Spices"}}, …]
  1. Server. Puma request নেয়। Development-এ সে এখানে ৩টি thread নিয়ে চলছিল।
  2. Router. resources :products এক লাইনে তিনটি route বানিয়েছে। GET /products মানে products#index: ProductsController-এর index method. নামটি convention থেকে এসেছে।
  3. Controller. ছোট একটি method. in_stock model-এ লেখা একটি scope, includes(:category) সম্পর্ক আগেভাগে আনার নির্দেশ।
  4. Model. দুটি SQL: পণ্যের তালিকা, তারপর পাওয়া ক্যাটাগরি id দিয়ে একটি IN. স্টকে না থাকা Soybean Oil বাদ পড়েছে, তাই ৫টি পণ্য।
  5. Render. as_json শুধু বেছে নেওয়া ঘর রেখেছে। Log বলছে মোট ৯৮ ms, যার ActiveRecord অংশ ২.৫ ms আর ২টি query. প্রথম request বলে সময় বেশি; development-এ code লোড হয় প্রথমবার।

৭ · Active Record, includes আর strong parameters

class Product < ApplicationRecord
  belongs_to :category
  validates :name, presence: true
  validates :price, numericality: { only_integer: true, greater_than: 0 }
  scope :in_stock, -> { where("stock > 0") }
end

Model-এ কলামের তালিকা নেই! Active Record চালু হওয়ার সময় ডেটাবেস থেকে কলাম পড়ে নেয়। এখানে লেখা থাকে শুধু সম্পর্ক, নিয়ম আর scope. Hibernate-এর Product.java-র সাথে তুলনা করুন: সেখানে প্রতিটি ঘর, constructor আর getter আলাদা লেখা।

N+1: ৭ বনাম ২, আর query cache-এর মোচড়

Hibernate-এর মতো একই পরীক্ষা, একটি rake task-এ SQL event গুনে:

সত্যিকারের run · Rails 8.1.4 · SQLite · একই ৬টি পণ্য, ৩টি ক্যাটাগরি

$ rails queries
Lazy loading: 7 queries             # Product.all.each { |p| p.category.name }
includes(:category): 2 queries     # Product.includes(:category).each { … }

$ rails queries_cache              # একই কাজ ActiveRecord::Base.cache { } -এর ভেতরে
Lazy loading, query cache on: 4 sent to SQLite, 3 from query cache
includes(:category), query cache on: 2 sent to SQLite, 0 from query cache

Rails-এ identity map নেই, তাই প্রতিটি পণ্যের ক্যাটাগরি আলাদা SQL: ১ + ৬ = ৭. Laravel আর Django-র lab-এও একই fixture-এ ৭ এসেছে। কিন্তু controller-এর ভেতরে Rails ডিফল্টে query cache চালু রাখে। তখন হুবহু একই SQL দ্বিতীয়বার ডেটাবেসে যায় না, মেমরি থেকে আসে। ফল: ৪টি SQLite পর্যন্ত গেল, ৩টি cache থেকে। অর্থাৎ বাস্তবে Hibernate-এর মতোই ৪টি যাত্রা। তবু cache ওষুধ নয়: ভিন্ন id-র জন্য query যাবেই, আর log-এ CACHE লাইনের ভিড় নিজেই N+1-এর লক্ষণ। includes সংখ্যাটিকে N থেকে মুক্ত করে ২-এ রাখে। Rails কখনো includes-কে JOIN-এও বদলায়, যদি আপনি সম্পর্কের টেবিলে where দেন, তাই log দেখে নিশ্চিত হন।

Strong parameters: params.expect

ক্লায়েন্টের পাঠানো সব ঘর সরাসরি model-এ দিলে কেউ is_admin: true পাঠিয়ে নিজেকে admin বানাতে পারে। একে বলে mass assignment, পর্ব ২৭-এ দেখেছেন। Rails-এর উত্তর strong parameters. Rails 8.0 থেকে নতুন, কড়া রূপটি params.expect; পুরোনো কোডে params.require(:product).permit(…) দেখবেন।

def create
  product = Product.new(product_params)
  if product.save
    render json: product, status: :created
  else
    render json: { errors: product.errors.full_messages }, status: :unprocessable_content
  end
end

private

def product_params
  params.expect(product: %i[category_id name price stock])
end

সত্যিকারের run · rails runner-এ চারটি ভিন্ন body · Rails 8.1.4

{"category_id" => 2, "name" => "Darjeeling Tea", "price" => 350, "stock" => 4}
{"category_id" => 2, "name" => "Free Tea", "price" => 1, "stock" => 1}      # is_admin চুপচাপ বাদ
ActionController::ParameterMissing: param is missing or the value is empty or invalid: product
ActionController::ParameterMissing: param is missing or the value is empty or invalid: product

তৃতীয় body-তে product ছিল একটি string, চতুর্থটিতে product-ই ছিল না। দুটিতেই exception, যা Rails 400 Bad Request-এ বদলায়। আর আসল server-এ যাচাই-ব্যর্থ body (নাম ফাঁকা, দাম ০) এই উত্তর পেয়েছিল:

সত্যিকারের run · 127.0.0.1 · ২০২৬-১১-০১

$ POST /products  {"product":{"category_id":2,"name":"","price":0,"stock":1}}
{"errors":["Name can't be blank","Price must be greater than 0"]}
HTTP 422
log: Completed 422 Unprocessable Content in 10ms

লক্ষ করুন, errors এখানে বাক্যের তালিকা, ঘরের নাম আলাদা নয়। ক্লায়েন্টে ঘর ধরে দেখাতে চাইলে product.errors.to_hash বা errors.as_json পাঠানো ভালো। Symbol-এর নামটিও লক্ষণীয়। পুরোনো :unprocessable_entity লিখলে আমাদের run-এ Rack 3.2.8 সতর্ক করেছে: Status code :unprocessable_entity is deprecated and will be removed in a future version of Rack. Please use :unprocessable_content instead.

নিচে params.expect-এর আচরণ JavaScript-এ একটি শেখানোর মডেল. চারটি body-র তৃতীয়টি NoSQL injection-ধাঁচের একটি চেষ্টা।

// শেখানোর মডেল: Rails-এর params.expect(product: [...]) কী করে
class ParameterMissing extends Error {}

function expect(params, key, allowed) {
  const value = params[key];
  const isHash = value !== null && typeof value === "object" && !Array.isArray(value);
  if (!isHash || Object.keys(value).length === 0) {
    throw new ParameterMissing(`param is missing or the value is empty or invalid: ${key}`);
  }
  const kept = {};
  const dropped = [];
  for (const [k, v] of Object.entries(value)) {
    const scalar = ["string", "number", "boolean"].includes(typeof v) || v === null;
    if (allowed.includes(k) && scalar) kept[k] = v;
    else dropped.push(k);
  }
  return { kept, dropped };
}

const bodies = [
  { product: { category_id: 2, name: "Darjeeling Tea", price: 350, stock: 4 } },
  { product: { category_id: 2, name: "Free Tea", price: 1, stock: 1, is_admin: true } },
  { product: { name: { $gt: "" }, price: 1 } },
  { product: "Darjeeling Tea" },
];
for (const body of bodies) {
  try {
    const { kept, dropped } = expect(body, "product", ["category_id", "name", "price", "stock"]);
    console.log("kept", JSON.stringify(kept), dropped.length ? "dropped " + dropped : "");
  } catch (e) {
    console.log("400", e.message);
  }
}

দ্বিতীয় লাইন kept {"category_id":2,"name":"Free Tea","price":1,"stock":1} dropped is_admin. তৃতীয় লাইন kept {"price":1} dropped name: অনুমতি থাকলেও name বাদ, কারণ তার মান একটি object, সাধারণ মান (scalar) নয়। আমরা আসল Rails-এও এই body চালিয়েছি, ফল এসেছে {"price" => 1}, হুবহু এক। শেষ লাইন 400 param is missing or the value is empty or invalid: product. তত্ত্বের জন্য API security পর্ব দেখুন।

Test database-এর ছোট ফাঁদ: আমাদের lab-এ development ডেটাবেসে migration চালানোর পরেও rails test লিখল Migrations are pending. To resolve this issue, run: bin/rails db:migrate. rails db:test:prepare চালানোর পরে test চলল: 2 runs, 3 assertions, 0 failures, 0 errors, 0 skips. বার্তা যা বলছে তা অন্ধভাবে না করে ভাবুন কোন ডেটাবেসের কথা হচ্ছে। Test-এর ধারণা আসবে মডিউল ১১-এ।

৮ · Hotwire আর Rails 8-এর ডিফল্ট

এই কোর্সের বড় অংশ React-কেন্দ্রিক: server JSON দেয়, ব্রাউজারে JavaScript পাতা আঁকে (SPA পদ্ধতি)। Rails community-র একটি বড় অংশ অন্য পথে হাঁটে, যার নাম Hotwire (“HTML over the wire”): server HTML-ই পাঠায়, আর সামান্য JavaScript পাতার অংশ বদলে দেয়। এর তিন অংশ:

Turbo Drive

Link বা form চাপলে পুরো পাতা reload না করে নতুন HTML এনে <body> বদলায়। SPA-র মতো মসৃণ লাগে, কিন্তু সব HTML server-এ তৈরি।

Turbo

Turbo Frames আর Streams

পাতার একটি অংশ (যেমন ঝুড়ির সংখ্যা) আলাদা করে বদলায়। Stream দিয়ে server নিজেই বলে “এই তালিকায় এই সারিটি যোগ করুন”, WebSocket বা form-এর উত্তরে।

Turbo

Stimulus

HTML-এর data-controller attribute-এ ছোট JavaScript class জোড়ে, যেমন dropdown খোলা বা পরিমাণ বাড়ানো। পুরো পাতার state রাখে না।

Stimulus

আমাদের lab-এর rails new … --minimal --api আউটপুটে লেখা ছিল --skip-hotwire [due to --skip-javascript, --minimal]. অর্থাৎ সাধারণ rails new-এ Hotwire ডিফল্টে আসে। বাজারঘরের পণ্য তালিকা, ঝুড়ি আর checkout-এর মতো ফর্মভিত্তিক পাতার জন্য এটি দ্রুত আর হালকা পথ। কিন্তু খুব বেশি client state-এর অ্যাপে, যেমন offline-এ চলা app, জটিল drag-and-drop editor, বা একই API থেকে mobile app-ও চালানো, React বা অনুরূপ SPA বেশি স্বাভাবিক। কোনটি “আধুনিক” সেই বিতর্কের চেয়ে প্রশ্ন হওয়া উচিত: আপনার অ্যাপের state কোথায় থাকে? পর্ব ৩-এর (Rendering-এর মানচিত্র) MPA আর SPA-র আলোচনা এখানে মিলে যায়।

Rails 8: “Redis ছাড়াই”

Rails 8.0 (নভেম্বর ২০২৪)-এর release notes অনুযায়ী নতুন অ্যাপের ডিফল্ট:

অংশকাজআগে সাধারণত
Solid QueueBackground job-এর queue, ডেটাবেসেRedis-ভিত্তিক queue
Solid CacheCache, ডেটাবেসে (ডিস্কে)Redis বা Memcached
Solid CableAction Cable-এর pubsub, ডেটাবেসেRedis
Kamalনিজের Linux server-এ container দিয়ে deployহাতে script বা PaaS
Thruster আর PropshaftPuma-র সামনে ছোট proxy; নতুন asset pipelineNginx সেটআপ; Sprockets

আমাদের --minimal আউটপুটে --skip-solid, --skip-kamal আর --skip-thruster লেখা ছিল, যা থেকে বোঝা যায় 8.1.4-এও এগুলো ডিফল্টে থাকে। দর্শনটি স্পষ্ট: ছোট দল একটি ডেটাবেস আর একটি server দিয়েই অনেক দূর যেতে পারে। তবে খুব বেশি job বা cache-এর চাপে আলাদা Redis বা ব্যবস্থাপিত সেবা এখনো যুক্তিসঙ্গত। পর্ব ৪৪ (Cache) আর পর্ব ৬২-এর (Background job) প্রশ্নগুলো এখানেও খাটে। Spring জগতে এর সরাসরি একক উত্তর নেই; সেখানে সাধারণত আলাদা library আর সেবা জোড়া হয়।

৯ · সব framework, একই বাজারঘর — সৎ তুলনা আর বাছাই

প্রথমে N+1, তিনটি ORM-এ, একই fixture-এ। Hibernate আর Active Record-এর সংখ্যা এই পর্বের run থেকে, Django-র সংখ্যা একই lab থেকে, যা পর্ব ৬৮-এ বিস্তারিত আসবে।

1Hibernate  findAll() + p.getCategory()2Hibernate  @EntityGraph("category")3Rails      Product.all.each { p.category }4Rails      Product.includes(:category)5Django     Product.objects.all() + p.category6Django     .select_related("category")
এই মুহূর্তের মান
ORM
SQL সংখ্যা
হিসাব
  1. Hibernate, lazy. ৪টি SQL. Persistence context একই ক্যাটাগরি দ্বিতীয়বার আনে না, তাই ১ + ৩. সংখ্যা কম দেখালেও সমস্যা একই, ক্যাটাগরি বাড়লে বাড়বে।
  2. Hibernate, @EntityGraph. একটি JOIN, ১টি SQL. একই কাজ join fetch-এও।
  3. Rails, lazy. ৭টি SQL, প্রতিটি পণ্যে একটি, কারণ identity map নেই। (Request-এর ভেতরে query cache থাকলে ৪টি ডেটাবেসে যায়, ৩টি cache থেকে।)
  4. Rails, includes. ২টি SQL: পণ্য, তারপর id-গুলো দিয়ে একটি IN. Prisma-র query strategy-র মতো।
  5. Django, lazy. একই fixture-এ ৭টি, Rails-এর মতো।
  6. Django, select_related. একটি JOIN, ১টি SQL. বহু-থেকে-বহু সম্পর্কের জন্য Django-র আলাদা prefetch_related আছে, যা IN ব্যবহার করে।
  7. শিক্ষা। তিনটি সমাধানের সংখ্যা ১, ২, ১: আলাদা, কিন্তু কোনোটি N-এর সাথে বাড়ে না। ORM বদলালেও প্রশ্ন একই থাকে: “এই তালিকা কতটি SQL চালায়, আর N বাড়লে কী হয়?” পর্ব ৪২-এর query budget test সব ORM-এ লেখা যায়।

এবার পুরো ছবি। যেখানে কোনো ঘর এই lab-এ মাপা হয়নি, সেখানে তাই লেখা। ভবিষ্যৎ পর্বের সংখ্যা সেই পর্বের lab থেকে এসেছে আর সেখানেই বিস্তারিত থাকবে।

একই fixture (৩টি ক্যাটাগরি, ৬টি পণ্য) · লেখার সময়ের version · একটি local কম্পিউটার · সাধারণ benchmark নয়

Frameworkভুল ইনপুটেN+1: lazy → সারানোহাতে লেখা লাইনচালু হতে
Express 5 + Prisma 7 (পর্ব ২৭, ৪২)422, আমাদের নিজের Zod middlewarelazy নেই, লুপ হাতে লিখলে ১ + N; include-এ ১টি (join) বা টেবিলপ্রতি ১টিএই slice-এ মাপা হয়নিমাপা হয়নি
Next.js server action (পর্ব ৫৪)HTTP 200, ভুলের বার্তা ফেরত state-এORM-এর ওপর নির্ভর করে (যেমন Prisma)মাপা হয়নিমাপা হয়নি
Django 6.1.2 (পর্ব ৬৮)HTML form আবার দেখায়; JSON API নিজে লিখতে হয়৭ → select_related ১মাপা হয়নিমাপা হয়নি
FastAPI 0.162 (পর্ব ৬৯)422, ঘরভিত্তিক detaillab-এ ORM ছিল নামাপা হয়নিমাপা হয়নি
Laravel 13.39 (পর্ব ৭০)422, errors object৭ → with() ২মাপা হয়নিমাপা হয়নি
Spring Boot 4.1.2ডিফল্ট 400, ঘর নেই; advice দিয়ে 422৪ → @EntityGraph ১১৬৭ (import বাদে ১১৯)২.৭৫–৩.২৪ সেকেন্ড
Rails 8.1.4422 (controller-এ লেখা), বাক্যের তালিকা৭ → includes ২৩৩, সাথে generator-এর migration ২০মাপা হয়নি

লাইন গোনা হয়েছে ফাঁকা লাইন বাদে, শুধু model, data access, controller, validation আর route. Seed, test আর আমাদের মাপার কোড বাদ। Spring-এর সংখ্যার বড় অংশ getter, constructor আর import, যা IDE নিজেই লেখে। তাই “Rails-এ পাঁচ গুণ কম লাইন” মানে “পাঁচ গুণ কম কাজ” নয়। বরং Spring-এর লাইনগুলো তথ্য বহন করে, যেমন type, যা compiler যাচাই করে। Rails-এ সেই তথ্য নামের নিয়ম আর ডেটাবেসে লুকানো থাকে। Vue, Nuxt, Angular আর Svelte (পর্ব ৬৬–৬৭) frontend-এর পাশ, তাই এই সারণিতে নেই; তারা ওপরের যেকোনো backend-এর সাথে চলে।

কখন কোনটি বেছে নেবেন

Spring Boot বেছে নিন যখন

দল আগে থেকেই Java বা Kotlin জানে; বড় প্রতিষ্ঠানের নিয়ম, audit আর দীর্ঘ মেয়াদি রক্ষণাবেক্ষণ লাগে; অনেক দল একই codebase-এ কাজ করে আর কঠোর type ভুল ধরে।

দাম: বেশি লাইন, ভারী jar, ধীর startup

Rails বেছে নিন যখন

ছোট দল দ্রুত একটি পূর্ণ পণ্য (HTML পাতা, admin, job, mail) বানাতে চায়; ফর্মভিত্তিক ব্যবসায়িক অ্যাপ; Ruby শিখতে আপত্তি নেই। Hotwire থাকায় আলাদা SPA ছাড়াই অনেক দূর যাওয়া যায়।

দাম: convention না জানলে “জাদু”, আর দলে Ruby জানা মানুষ লাগে

Express বা Next.js-এ থাকুন যখন

পুরো দল TypeScript-এ, frontend React-এ, আর একই ভাষায় দুই দিক চালাতে চান। মনে রাখবেন, Express-এ structure, validation আর DI আপনাকে নিজেকে বেছে নিতে হয়, যা Spring আর Rails দিয়ে দেয়।

দাম: সিদ্ধান্তের ভার আপনার ঘাড়ে

Django, Laravel বা FastAPI

Python-এ ডেটা বা ML-এর কাজের পাশে API হলে FastAPI বা Django; PHP hosting সস্তা আর সহজলভ্য এমন জায়গায় Laravel. বিস্তারিত পর্ব ৬৮–৭০-এ, আর সব একসাথে পর্ব ৮৫-এ (Stack বাছাই)।

যে ভাষা দল জানে, সেটিই প্রায়ই সেরা উত্তর

আরও গভীরে: Spring Boot 4-এ auto-configuration ছোট ছোট module-এ ভাগ হয়েছে। প্রতিটি module jar-এর ভেতরে META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports ফাইলে তার class-এর তালিকা থাকে; আমাদের jar-এ ১৪টি এমন ফাইলে মোট ৫০টি নাম। Hibernate-এর persistence context আসলে Martin Fowler-এর বর্ণিত identity map আর unit of work নকশার বাস্তবায়ন: transaction শেষে বদলানো entity খুঁজে (dirty checking) নিজেই UPDATE লেখে। Rails ফাইল খোঁজার কাজটি করে Zeitwerk নামের loader, যা class নাম থেকে ফাইলের পথ বের করে, ঠিক আমাদের Python মডেলের মতো।

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

  • Spring-এ IoC আর DI কী? Object বানানো ও জোড়ার দায়িত্ব framework-এর container-এর; constructor-এ নির্ভরতা চাওয়াই পরামর্শ।
  • Auto-configuration কীভাবে কাজ করে? Classpath, বিদ্যমান bean আর property-র শর্ত মিললে ডিফল্ট bean তৈরি হয়; --debug রিপোর্ট কারণ দেখায়।
  • JPA-তে N+1 কীভাবে সারাবেন? @EntityGraph বা join fetch; সাথে SQL log বা statistics দিয়ে গুনে দেখা।
  • Convention over configuration মানে কী? নামের নিয়ম মানলে setting লিখতে হয় না, যেমন Product → products টেবিল।
  • Strong parameters কেন? Mass assignment ঠেকাতে; শুধু অনুমোদিত সাধারণ ঘর model-এ যায়।

১০ · AI-এর যুগে Spring আর Rails

দুই framework-ই পুরোনো আর জনপ্রিয়, তাই AI-এর শেখার ডেটায় এদের পুরোনো সংস্করণের কোড বিপুল। ফলে AI প্রায়ই নির্ভুল দেখতে কিন্তু পুরোনো কোড দেয়। এই পর্বেই আমরা কয়েকটি বাস্তব ফাঁদ দেখেছি:

AI যা লিখতে পারেএখনকার সংস্করণে কী হয়
Parent version 4.1.2.RELEASE“Non-resolvable parent POM”, build-ই হয় না
javax.validation.constraints.NotBlankSpring Boot 3 থেকে package jakarta.*; পুরোনো import compile হয় না
WebSecurityConfigurerAdapterSpring Security 6-এ সরানো হয়েছে; এখন SecurityFilterChain bean
@Autowired field injection, entity সরাসরি JSON-এচলে, কিন্তু test কঠিন, আর lazy proxy বা গোপন ঘর ফাঁস হতে পারে
params.require(:product).permit!permit! সব ঘর ছেড়ে দেয়; mass assignment-এর দরজা খোলা
status: :unprocessable_entityচলে, কিন্তু Rack 3.2-এ deprecation সতর্কবার্তা

AI-কে আগে version আর আসল আউটপুট দিন, তারপর কাজ চান:

আমি Spring Boot 4.1.2 (Spring Framework 7, Hibernate 7.4, Java 25) ব্যবহার করি। Starter: spring-boot-starter-webmvc, data-jpa, validation. নিচে আমার entity, repository, controller আর একটি request-এর SQL log দিলাম। [কোড আর log পেস্ট করো। কোনো password, token বা আসল গ্রাহকের ডেটা দিও না।] ১. N+1 কোথায়, log-এর লাইন ধরে দেখাও। @EntityGraph বা join fetch দিয়ে সারাও। ২. Validation ভুল হলে 422 আর ঘরভিত্তিক errors-সহ ProblemDetail ফেরত দাও। ৩. শুধু jakarta.* import ব্যবহার করবে। নতুন dependency যোগ করবে না। ৪. কোনো API-তে নিশ্চিত না হলে বলো; আমি Spring Boot 4.1-এর docs-এ মিলিয়ে নেব। ৫. Query সংখ্যা যাচাইয়ের জন্য একটি test-এর খসড়া দাও।

AI-এর Spring বা Rails কোড হাতে পেলে

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

প্রতিটি প্রশ্ন নিয়ে নিজে কিছুক্ষণ ভাবুন, তারপর “→ উত্তর” চাপুন।

প্র ০১Spring কেন জন্মাল, আর দশ বছর পরে Spring Boot কেন দরকার হলো?

২০০০-এর দশকের শুরুতে বড় Java অ্যাপের পথ ছিল J2EE আর তার EJB (Enterprise JavaBeans). ধারণা ভালো ছিল: transaction, নিরাপত্তা, দূরের object, সব application server দেবে। কিন্তু দাম ছিল ভারী। একটি সাধারণ business object লিখতেও কয়েকটি interface আর XML descriptor লাগত, আর কোড চলত শুধু ভারী server-এর ভেতরে। Unit test প্রায় অসম্ভব ছিল, কারণ object নিজে container ছাড়া বাঁচত না।

Rod Johnson ২০০২ সালের বইয়ে দেখান, সাধারণ Java object (যাকে পরে POJO বলা হয়) আর একটি হালকা container দিয়েই এই কাজ করা যায়। Container object বানাবে আর নির্ভরতা ঢুকিয়ে দেবে, আর transaction-এর মতো কাজ পাশ থেকে জুড়বে। সেই কোড থেকে Spring Framework, যার ১.০ আসে ২০০৪ সালের দিকে। এর বড় উপহার ছিল testability: আপনার class একটি সাধারণ class, test-এ নকল নির্ভরতা দিয়ে নিজেই new করা যায়।

কিন্তু Spring নিজেও ধীরে ধীরে ভারী হলো। একটি web অ্যাপ শুরু করতে XML বা Java config, servlet container-এ WAR ফাইল deploy, আর বিভিন্ন library-র মেলানো version লাগত। এদিকে ২০১০-এর দশকে Rails আর Node.js দেখাল যে এক কমান্ডে অ্যাপ চালু হতে পারে। Microservice-এর যুগে প্রতিটি ছোট সেবার জন্য এই সেটআপ করা অসহনীয় হয়ে উঠল। ২০১৪ সালের দিকে Spring Boot এর উত্তর দেয়: starter দিয়ে মেলানো dependency, শর্তসাপেক্ষ auto-configuration, আর server ভেতরে বসানো একটি jar. মজার বিষয়, এর অনেক ভাবনা Rails-এর convention-এর কাছে ঋণী, যদিও Spring স্পষ্ট type আর annotation ছাড়েনি।

আমাদের run-এর সংখ্যা এই ইতিহাসের দুই মুখ দেখায়। নিজের কোড মাত্র ২৩ KB, অথচ jar প্রায় ৫৪ MB, আর চালু হতে ৩ সেকেন্ড। সুবিধা হলো, সেই ৫৪ MB-র ভেতরে পরীক্ষিত server, pool, ORM আর validator তৈরি আছে।

মূল উপলব্ধি: প্রতিটি framework আগের প্রজন্মের যন্ত্রণার উত্তর। Spring এসেছিল EJB-র ভার কমাতে, Boot এসেছিল Spring-এর সেটআপ কমাতে। কোন যন্ত্রণা সারাতে টুলটি এসেছিল তা জানলে তার নকশা সহজে বোঝা যায়।

প্র ০২Convention over configuration কি সবসময় ভালো? এর লুকানো দাম কোথায়?

Rails যখন ২০০৪–২০০৫ সালে আসে, তখন Java-র ওয়েব অ্যাপে একটি টেবিলের সাথে একটি class জুড়তে XML-এ ম্যাপিং লিখতে হতো। DHH-এর যুক্তি ছিল সহজ: ৯০% ক্ষেত্রে মানুষ একই সিদ্ধান্ত নেয়, তাহলে সেটিই ডিফল্ট হোক। Product মানে products টেবিল, category_id মানে categories-এর সম্পর্ক। শুধু ব্যতিক্রমের ক্ষেত্রে লিখতে হবে। এই ভাবনা পরে প্রায় সর্বত্র ছড়িয়েছে: Laravel-এর Eloquent, Django-র app কাঠামো, Spring Data-র method নাম থেকে query, এমনকি Next.js-এর ফাইল দিয়ে routing (পর্ব ৫১)।

লাভ স্পষ্ট। আমাদের মাপে একই slice Rails-এ ৩৩ লাইন, Spring-এ import বাদেও ১১৯. নতুন সদস্য যেকোনো Rails project খুললে জানেন model কোথায়, test কোথায়। দল নিজেদের মধ্যে কাঠামো নিয়ে তর্কে সময় নষ্ট করে না।

দাম তিন জায়গায়। প্রথমত, শেখার প্রথম ধাপে সব কিছু “জাদু” মনে হয়। কোড পড়ে বোঝা যায় না কোন ফাইল কোথা থেকে লোড হলো, কারণ সেই তথ্য কোডে নেই, নিয়মে আছে। দ্বিতীয়ত, ভুল নীরব। Model-এর নাম বানানে ভুল হলে কোনো configuration error আসে না, আসে অস্পষ্ট “uninitialized constant”. টেবিলের নাম অনিয়মিত হলে (পুরোনো ডেটাবেসে tbl_product) convention-এর সাথে লড়তে হয়। তৃতীয়ত, tooling. Type যখন নামের নিয়মে লুকানো, IDE আর compiler কম সাহায্য করতে পারে। Ruby-তে এই কারণে Sorbet বা RBS-এর মতো type টুল তৈরি হয়েছে, আর Spring বিপরীত দিক থেকে বেশি লেখার দামে বেশি নিশ্চয়তা দেয়।

তাই প্রশ্নটি “convention ভালো না খারাপ” নয়। প্রশ্ন হলো, আপনার অ্যাপ কতটা সাধারণ ছাঁচে পড়ে, আর দল কতদিন ধরে কতজন মিলে কোড রক্ষণাবেক্ষণ করবে। সাধারণ ছাঁচে convention দারুণ গতি দেয়; অস্বাভাবিক ডোমেইনে স্পষ্টতা বেশি নিরাপদ।

মূল উপলব্ধি: Convention তথ্য মুছে দেয় না, কোড থেকে সরিয়ে মাথায় নিয়ে যায়। দলের সবার মাথায় সেই নিয়ম থাকলে এটি গতি, না থাকলে এটি ধাঁধা।

প্র ০৩একই fixture-এ Hibernate-এর lazy loading ৪টি SQL চালাল, Rails ৭টি। এর মানে কি Hibernate N+1-এ ভালো?

পার্থক্যের কারণ identity map. Hibernate-এর প্রতিটি session বা persistence context একটি ছক রাখে: “এই id-র Category মেমরিতে আছে”। একই transaction-এ দ্বিতীয় পণ্য একই ক্যাটাগরি চাইলে SQL যায় না। তাই ৬টি পণ্য ৩টি ক্যাটাগরিতে ছড়ানো থাকলে ১ + ৩ = ৪. Rails-এর Active Record-এ এমন কিছু ডিফল্টে নেই। আমাদের জানা মতে, Rails ৩.১-এ একটি IdentityMap পরীক্ষামূলকভাবে এসেছিল, কিন্তু সম্পর্কের সাথে নানা অসংগতির কারণে পরে বাদ দেওয়া হয়। তাই প্রতিটি পণ্যের জন্য একটি SQL, ১ + ৬ = ৭.

কিন্তু এটিকে “Hibernate ভালো” ভাবা ভুল হবে, তিন কারণে। প্রথমত, সমস্যার আকৃতি একই: SQL সংখ্যা এখনো ডেটার সাথে বাড়ে, শুধু আলাদা ক্যাটাগরির সংখ্যার সাথে। দ্বিতীয়ত, আমরা দেখেছি request-এর ভেতরে Rails-এর query cache একই রকম ফল দেয়: ৪টি ডেটাবেসে, ৩টি cache থেকে। তৃতীয়ত, identity map-এর নিজের দাম আছে। একটি দীর্ঘ transaction-এ হাজার হাজার entity মেমরিতে জমে থাকে, আর transaction শেষে Hibernate প্রতিটি বদলেছে কি না যাচাই করে, যাকে dirty checking বলে। Batch কাজে এজন্য নিয়মিত flush আর clear করতে হয়।

Hibernate-এর আরেকটি ধার আছে, যা আমরা নিজে দেখেছি: transaction-এর বাইরে lazy সম্পর্ক ছুঁলে LazyInitializationException. Spring Boot এর সহজ উত্তর হিসেবে open-in-view ডিফল্টে চালু রাখে, যাতে request শেষ না হওয়া পর্যন্ত session খোলা থাকে। এতে exception চলে যায়, কিন্তু view বা JSON লেখার সময়ও গোপনে SQL চলতে থাকে, যা N+1-কে আরও লুকিয়ে ফেলে। এই কারণে বহু অভিজ্ঞ Spring developer এটি বন্ধ রাখার পক্ষে, আর Boot নিজেও চালু অবস্থায় একটি সতর্কবার্তা লগে লেখে বলে জানা যায়।

মূল উপলব্ধি: দুই ORM-এর সংখ্যা আলাদা হলেও রোগ একই। তুলনার সঠিক মাপ “lazy-তে কতটি” নয়, বরং “সারানোর পরে N বাড়লে সংখ্যা স্থির থাকে কি না”। দুটিতেই উত্তর হ্যাঁ: ১ আর ২.

প্র ০৪Validation ব্যর্থ হলে 400 না 422? Spring-এর ডিফল্ট 400 কি ভুল?

422 code-টির জন্ম WebDAV-এর একটি RFC-তে (RFC 4918, ২০০৭), নাম ছিল “Unprocessable Entity”. মানে: request-এর গঠন ঠিক, server বুঝেছে, কিন্তু ভেতরের নির্দেশ মানা যাচ্ছে না। বহু বছর এটি WebDAV-এর নিজস্ব code ছিল, তবু validation error-এর জন্য একে জনপ্রিয় করায় Rails-এর মতো framework-এর বড় ভূমিকা ছিল বলে সাধারণত ধরা হয়। ২০২২ সালে HTTP-র মান RFC 9110 একে সাধারণ HTTP-র অংশ করে, আর নাম দেয় “Unprocessable Content”. এই কারণেই Rack পুরোনো symbol :unprocessable_entity-এ সতর্কবার্তা দিচ্ছে, আর Spring-এ HttpStatus.UNPROCESSABLE_CONTENT এসেছে।

Spring-এর ডিফল্ট 400 তাই ভুল নয়, সাধারণ। 400 মানে “ক্লায়েন্টের ভুল”, যা validation-কেও ঢেকে দেয়। Spring-এর exception resolver বহু বছর ধরে MethodArgumentNotValidException-কে 400-এ বদলায়, আর পুরোনো ক্লায়েন্ট ভেঙে দেওয়ার ভয়ে এই ডিফল্ট বদলানো কঠিন। আমাদের run-এ আসল সমস্যা status নয়, body: কোন ঘর ভুল তা নেই। এটি সম্ভবত নিরাপত্তার সাবধানতা থেকে, কারণ ভেতরের বিস্তারিত বাইরে না দেওয়াই নিরাপদ ডিফল্ট।

এই কোর্স পর্ব ৩৩-এ একটি নিয়ম ঠিক করেছে: পড়াই না গেলে (ভাঙা JSON) 400, পড়া গেছে কিন্তু নিয়ম ভেঙেছে 422. গুরুত্বপূর্ণ হলো আপনার API-তে নিয়মটি এক রকম রাখা, আর ক্লায়েন্টকে ঘরভিত্তিক বার্তা দেওয়া। আমাদের ছোট advice দুটিই করেছে, আর RFC 9457-এর ProblemDetail আকৃতিতে, যা অন্য টুলও বোঝে। Laravel, FastAPI আর Rails-এর lab ডিফল্টেই 422 দিয়েছে, তবে body-র আকৃতি তিনটিতে তিন রকম: errors object, detail তালিকা, আর বাক্যের তালিকা।

মূল উপলব্ধি: Status code-এর চেয়ে বেশি জরুরি হলো সামঞ্জস্য আর কাজে লাগার মতো body. Framework-এর ডিফল্ট আপনার API চুক্তির জায়গা নেয় না; চুক্তি লিখুন (পর্ব ৩৫) আর ডিফল্টকে সেই অনুযায়ী বদলান।

প্র ০৫Hotwire কি SPA-র বিকল্প? “HTML over the wire” ধারণাটি নতুন না পুরোনো?

ধারণাটি পুরোনো, মোড়ক নতুন। ওয়েবের শুরু থেকেই server HTML পাঠাত। ২০০০-এর দশকে AJAX দিয়ে পাতার টুকরো বদলানো শুরু হয়, Rails-এর পুরোনো সংস্করণেও এমন helper ছিল। Rails ৪-এর সময় (২০১৩ সালের দিকে) Turbolinks এল, যা link চাপলে পুরো পাতা reload না করে body বদলাত। ২০২০ সালের শেষে Basecamp-এর দল Hotwire ঘোষণা করে: Turbo (Turbolinks-এর উত্তরসূরি, সাথে Frames আর Streams) আর আগে থেকে থাকা Stimulus. Rails ৭ থেকে এগুলো নতুন অ্যাপের ডিফল্ট। প্রায় একই সময়ে htmx, Laravel Livewire আর Phoenix LiveView-এর মতো টুলও একই দর্শনে জনপ্রিয় হয়।

যুক্তি হলো, SPA-র দাম অনেক: দুই জায়গায় routing আর validation, বড় JavaScript bundle, hydration (পর্ব ৫৬), আর API-র চুক্তি সামলানো। অনেক ব্যবসায়িক অ্যাপের আসলে এত client state লাগে না। Server HTML বানালে logic এক জায়গায় থাকে, আর প্রথম পাতা দ্রুত আসে।

কিন্তু সীমাও আছে। প্রতিটি ইন্টারঅ্যাকশনে server-এ যাওয়া মানে network ধীর হলে UI ধীর। Offline-এ চলা, বা তাৎক্ষণিক প্রতিক্রিয়ার জটিল UI (spreadsheet, design tool) এই মডেলে কষ্টকর। আর একই backend থেকে mobile app চালাতে হলে JSON API তো লাগবেই। মজার বিষয়, React-এর জগতও উল্টো দিক থেকে কাছে এসেছে: Server Component (পর্ব ৫২) server-এ রেন্ডার করে client-এ কম JavaScript পাঠায়। দুই দর্শন এখন একই প্রশ্নের দুই উত্তর।

বাংলাদেশের প্রেক্ষাপটে একটি বাস্তব বিবেচনা: অনেক ব্যবহারকারী কম দামের ফোনে মোবাইল ডেটায় আসেন। কম JavaScript সেখানে সত্যিকারের লাভ, তবে প্রতিটি ক্লিকে round trip সেখানে ক্ষতিও। মেপে সিদ্ধান্ত নিন (পর্ব ৮৪)।

মূল উপলব্ধি: Hotwire সব SPA-র বিকল্প নয়, কিন্তু অনেক ফর্মভিত্তিক অ্যাপে SPA অপ্রয়োজনীয় তা মনে করিয়ে দেয়। প্রশ্ন সবসময়: state কোথায় থাকে, আর ব্যবহারকারীর network কেমন।

অনুশীলন

হিসাব, পূর্বাভাস, কোড বিচার আর একটি কমান্ডের কাজ।

  1. পূর্ণ numerical: একটি পাতায় ২০টি পণ্য, ৫টি ক্যাটাগরিতে ছড়ানো। প্রতিটি পণ্যের ক্যাটাগরির নাম দেখাতে হবে। অ্যাপ আর ডেটাবেসের মাঝে RTT ২ ms, প্রতিটি SQL-এ ডেটাবেসের কাজ ধরুন ০.২ ms. (ক) একটি transaction-এ Hibernate-এর lazy loading-এ কতটি SQL আর মোট সময়? (খ) Rails-এর lazy loading-এ, query cache ছাড়া? (গ) Rails-এ query cache চালু থাকলে কতটি ডেটাবেসে যাবে? (ঘ) @EntityGraph আর includes-এ?

    (ক) ১ + ৫ = ৬টি SQL (persistence context একই ক্যাটাগরি আবার আনে না)। সময় ≈ ৬ × ২ + ৬ × ০.২ = ১২ + ১.২ = ১৩.২ ms.

    (খ) ১ + ২০ = ২১টি SQL. সময় ≈ ২১ × ২ + ২১ × ০.২ = ৪২ + ৪.২ = ৪৬.২ ms.

    (গ) ডেটাবেসে যাবে ১ + ৫ = ৬টি, বাকি ১৫টি cache থেকে। ১৫টির জন্য network লাগে না।

    (ঘ) @EntityGraph: ১টি JOIN, ≈ ২ + ০.২ = ২.২ ms. includes: ২টি SQL, ≈ ২ × ২ + ২ × ০.২ = ৪.৪ ms. পণ্য ২০০ হলেও (ঘ)-এর সংখ্যা একই থাকবে, (খ) হবে ২০১.

  2. Output অনুমান করুন: Rails 8.1-এ controller-এ লেখা আছে params.expect(product: %i[name price]). ক্লায়েন্ট তিনবার পাঠাল: (১) {"product":{"name":"Tea","price":90,"stock":5}}, (২) {"product":{"name":["Tea","Rice"],"price":90}}, (৩) {"item":{"name":"Tea"}}. প্রতিবার কী হবে?

    (১) {"name" => "Tea", "price" => 90}. stock তালিকায় নেই, তাই বাদ (development-এ log-এ unpermitted লেখা থাকে)।

    (২) {"price" => 90}. name-এর মান array, অথচ তালিকা শুধু সাধারণ মান অনুমতি দেয়, তাই বাদ। এরপর model-এর presence নিয়ম নাম না পেয়ে 422 দেবে।

    (৩) ActionController::ParameterMissing, কারণ product চাবি নেই। Rails এটিকে 400 Bad Request-এ বদলায়।

  3. নকশা বিচার: একজন সহকর্মী এই Spring controller লিখেছেন: field-এ @Autowired private ProductRepository repo;, @PostMapping-এ public Product create(@RequestBody Product p) { return repo.save(p); }, আর তালিকার endpoint repo.findAll() ফেরত দেয়, যা JSON-এ প্রতিটি পণ্যের category.name দেখায়। অন্তত চারটি সমস্যা আর সমাধান লিখুন।

    ১. Entity সরাসরি body হিসেবে: ক্লায়েন্ট id বা অন্য যেকোনো ঘর পাঠিয়ে বিদ্যমান সারি বদলাতে পারে, অর্থাৎ mass assignment ঘটে। আলাদা ProductRequest record নিন।

    ২. @Valid নেই: ফাঁকা নাম বা ঋণাত্মক দাম ঢুকবে। Record-এ Bean Validation annotation আর controller-এ @Valid, সাথে 422 ProblemDetail advice.

    ৩. N+1 আর lazy proxy: findAll() তারপর JSON-এ category.name মানে প্রতিটি আলাদা ক্যাটাগরির জন্য একটি SQL, অথবা open-in-view বন্ধ থাকলে LazyInitializationException. @EntityGraph দেওয়া repository method আর ProductView DTO ফেরত দিন।

    ৪. Field injection: final করা যায় না, আর test-এ container ছাড়া object বানানো কঠিন। Constructor injection ব্যবহার করুন।

    বাড়তি: 201 status নেই (@ResponseStatus(HttpStatus.CREATED)), আর কে পণ্য যোগ করতে পারবেন সেই authorization নেই।

  4. কমান্ডের কাজ: বাজারঘরের Rails project-এ পণ্যের রিভিউ যোগ করতে হবে: প্রতিটি রিভিউ একটি পণ্যের, তাতে ১–৫ rating আর একটি মন্তব্য। কোন কমান্ডগুলো কোন ক্রমে চালাবেন, generator কোন ফাইল বানাবে, আর test চালানোর আগে কী মনে রাখবেন? Spring-এ একই কাজে কোন ফাইল লাগবে?

    Rails: rails generate model Review product:references rating:integer body:text. এটি বানাবে db/migrate/…_create_reviews.rb, app/models/review.rb (belongs_to :product-সহ), test/models/review_test.rb আর test/fixtures/reviews.yml. টেবিলের নাম reviews, foreign key product_id, নিজে থেকেই।

    তারপর app/models/product.rb-এ has_many :reviews, আর review.rb-এ validates :rating, inclusion: { in: 1..5 }. চালান rails db:migrate. Test-এর আগে rails db:test:prepare, কারণ আমাদের lab-এ এটি ছাড়া rails test “Migrations are pending” বলেছিল।

    Spring: একটি Review entity (@ManyToOne(fetch = LAZY) দিয়ে Product), একটি ReviewRepository interface, request record-এ @Min(1) @Max(5), আর schema বদলের জন্য Flyway বা Liquibase migration, কারণ production-এ Hibernate-কে টেবিল বানাতে দেওয়া নিরাপদ নয়। আমাদের lab-এ H2-তে Hibernate টেবিল বানিয়েছে, যা শুধু শেখার জন্য।

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

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

ক্রমRails-এ GET /products request-এর ধাপগুলো সঠিক ক্রমে সাজান (▲▼ চেপে সরান)

  1. Puma server request নেয়
  2. Router URL মিলিয়ে products#index বেছে নেয়
  3. ProductsController#index চলে
  4. Active Record SQL পাঠিয়ে পণ্য আর ক্যাটাগরি আনে
  5. render json: উত্তর বানায়
  6. Status 200 সহ উত্তর ক্লায়েন্টে যায়

Spring Boot 4.1.2-এর pom.xml-এ parent version 4.1.2.RELEASE লিখলে কী হবে?

  • Maven নিজে থেকে 4.1.2 ব্যবহার করবে
  • “Non-resolvable parent POM” error, build হবে না
  • Production mode-এ build হবে
  • Build হবে, চালানোর সময় শুধু warning দেবে

Spring Boot-এ auto-configuration কোন ভিত্তিতে একটি ডিফল্ট bean (যেমন DataSource) বানায়?

  • সব সম্ভাব্য bean সবসময় বানায়
  • শুধু আপনি XML-এ লিখে দিলে
  • Classpath-এর class, বিদ্যমান bean আর property-র শর্ত মিললে
  • Internet থেকে সেরা setting নামিয়ে

একটি transaction-এ Hibernate ৬টি পণ্য আনে, যারা ৩টি ক্যাটাগরিতে ছড়ানো, আর লুপে প্রতিটির lazy ক্যাটাগরি পড়ে। কতটি SQL চলবে?

  • ৪
  • ৭
  • ১
  • ২

Spring Boot-এ @Valid দেওয়া request body যাচাইয়ে ব্যর্থ হলে, কোনো বাড়তি কোড বা setting ছাড়া, ডিফল্ট উত্তর কী?

  • 422, প্রতিটি ঘরের বার্তাসহ
  • 500 Internal Server Error
  • 200, খালি body
  • 400, কোন ঘর ভুল তা ছাড়া

Rails 8-এ params.expect(product: %i[name price]) থাকলে, body {"product":{"name":"Tea","price":90,"is_admin":true}}-এর ফল কী?

  • পুরো request 400 দিয়ে বাতিল
  • name আর price থাকে, is_admin বাদ
  • তিনটি ঘরই model-এ যায়
  • ParameterMissing exception

“HTML over the wire” পদ্ধতির (যেমন Hotwire) মূল ভাবনা কী?

  • Server শুধু JSON দেয়, ব্রাউজার সব HTML বানায়
  • ব্রাউজারে কোনো JavaScript চলে না
  • Server HTML পাঠায়, ছোট JavaScript পাতার অংশ বদলায়
  • প্রতিটি ক্লিকে পুরো পাতা নতুন করে reload হয়

শব্দকোষ

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

মনে রাখুন

  1. Spring Boot = starter + শর্তসাপেক্ষ auto-configuration + IoC container. Boot 4-এ web starter spring-boot-starter-webmvc, parent version-এ .RELEASE নেই।
  2. Constructor injection: নির্ভরতা চান, বানাবেন না। Container type পড়ে object জোড়ে আর একটিই রাখে।
  3. N+1 দুই ORM-এ: Hibernate lazy ১ + আলাদা সম্পর্ক (৪), Rails ১ + N (৭); সমাধান @EntityGraph (১) আর includes (২)।
  4. Validation: Spring ডিফল্টে 400 আর ঘরহীন body; @RestControllerAdvice দিয়ে 422 ProblemDetail. Rails-এ params.expect আর :unprocessable_content.
  5. বাছাই: দল কোন ভাষা জানে, অ্যাপ কতটা সাধারণ ছাঁচে পড়ে, আর state কোথায় থাকে, এই তিন প্রশ্ন “কোনটি সেরা”-র চেয়ে বেশি জরুরি।

পরের পর্বে: এতগুলো framework-এ কোড লিখলেন। কিন্তু কাল কেউ বদলালে ভাঙবে কি না, কীভাবে জানবেন? পর্ব ৭২-এ ফুল-স্ট্যাক test কৌশল: pyramid, trophy আর কী test করবেন।

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

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