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

Spring Boot দিয়ে REST API — অনলাইন দোকানের ব্যাকএন্ড

Building REST APIs with Spring Boot — HTTP, Resource Design, Layered Architecture, Validation, ProblemDetail and Spring Data JPA

Taught in Bangla, this lesson builds an online shop backend with Spring Boot: what HTTP requests and responses contain, methods, status codes and idempotency, REST resource design, Controller, Service and Repository layers with DTO records, Spring MVC annotations and ResponseEntity, Bean Validation and ProblemDetail errors, Spring Data JPA, pagination, testing, security and deployment basics.

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

  • একটি HTTP request আর response-এর ভেতরে কী থাকে — আর JDK দিয়েই একটি ছোট সার্ভার চালিয়ে দেখা
  • HTTP method, status code, idempotency, আর REST-এর নিয়মে URL সাজানো
  • Controller → Service → Repository স্তর, DTO record বনাম entity
  • Spring MVC-র annotation, ResponseEntity, Bean Validation আর ProblemDetail দিয়ে error
  • Spring Data JPA, pagination, transaction, N+1 সমস্যা, টেস্ট, নিরাপত্তা আর deploy-এর প্রথম ধারণা

১ · অ্যাপ আর সার্ভারের কথোপকথন — HTTP-র ভেতরে কী থাকে

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

পর্ব ৩৯-এ দেখেছেন, দুই প্রোগ্রামের মধ্যে ডেটা যায় JSON হয়ে। HTTP হলো সেই JSON বহন করার খাম — কে পাঠাচ্ছে, কী চাইছে, উত্তর সফল কি না, সব লেখা থাকে খামের গায়ে। আর সার্ভারের যে দরজাগুলো দিয়ে অ্যাপ এভাবে কথা বলে, সেগুলো মিলেই সার্ভারের API. এই পর্বে আপনি Java আর Spring Boot দিয়ে সেই দরজাগুলো বানাবেন।

পোস্ট অফিসের একটি পার্সেলের কথা ভাবুন। খামের ওপরে লেখা থাকে কাজের ধরন (“রেজিস্টার্ড চিঠি”), ঠিকানা, আর কিছু নির্দেশ (“ভঙ্গুর”, “ভেতরে কাগজ”)। ভেতরে থাকে আসল জিনিস। ফেরত রসিদে থাকে একটি সংক্ষিপ্ত কোড — পৌঁছেছে, ঠিকানা পাওয়া যায়নি, বা প্রাপক নেননি। HTTP request-এ কাজের ধরন হলো method, ঠিকানা হলো URL, নির্দেশগুলো header, ভেতরের জিনিস body; আর রসিদের কোড হলো status code.

একটি আসল HTTP request লেখামাত্র — মানুষও পড়তে পারে। অ্যাপ নতুন পণ্য যোগ করতে চাইলে এমন কিছু পাঠায়:

POST /api/v1/products HTTP/1.1          ← request line: method, পথ, সংস্করণ
Host: shop.example.com                  ← header শুরু
Content-Type: application/json          ← body কোন ফরম্যাটে
Accept: application/json                ← উত্তর কোন ফরম্যাটে চাই
Authorization: Bearer eyJhbGciOi...     ← আমি কে (token)
                                        ← ফাঁকা লাইন: header শেষ
{"name":"চা-পাতা","category":"grocery","price":120,"stock":50}

প্রথম লাইনটি request line: method POST, পথ /api/v1/products, আর HTTP-র সংস্করণ। তারপর কয়েকটি header — প্রতিটি “নাম: মান” জোড়া। একটি ফাঁকা লাইনের পর আসে body. সার্ভারের উত্তরও একই গঠনের:

HTTP/1.1 201 Created                    ← status line: সংস্করণ, কোড, ছোট বর্ণনা
Location: /api/v1/products/42           ← নতুন পণ্যটি কোথায় পাওয়া যাবে
Content-Type: application/json

{"id":42,"name":"চা-পাতা","category":"grocery","price":120,"inStock":true}

দুটি header বিশেষ গুরুত্বপূর্ণ। Content-Type বলে body কোন ফরম্যাটে — JSON হলে application/json. এটা ভুল হলে সার্ভার body পড়তেই পারে না। আর Accept বলে client কোন ফরম্যাটে উত্তর চায়। পুরো ঠিকানাটি — https://shop.example.com/api/v1/products/42 — একটি URL.

এবার JDK দিয়েই একটি সত্যিকারের ছোট সার্ভার চালিয়ে দেখুন। Spring লাগবে না: JDK-র ভেতরে একটি সরল HTTP সার্ভার (com.sun.net.httpserver) আর Java 11 থেকে একটি HTTP client (java.net.http) আছে। প্রোগ্রামটি নিজেই সার্ভার চালু করে, তারপর নিজেই client হয়ে দুটি request পাঠায়:

import com.sun.net.httpserver.HttpServer;
import java.net.*;
import java.net.http.*;
import java.nio.charset.StandardCharsets;

class Main {
    public static void main(String[] args) throws Exception {
        var server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);   // 0 = যেকোনো খালি port
        server.createContext("/api/v1/products/", ex -> {
            String id = ex.getRequestURI().getPath().replace("/api/v1/products/", "");
            boolean found = id.equals("1");
            String body = found ? "{\"id\":1,\"name\":\"চা-পাতা\",\"price\":120}"
                                : "{\"status\":404,\"detail\":\"product " + id + " not found\"}";
            byte[] bytes = body.getBytes(StandardCharsets.UTF_8);
            ex.getResponseHeaders().set("Content-Type", "application/json");
            ex.sendResponseHeaders(found ? 200 : 404, bytes.length);
            try (var out = ex.getResponseBody()) { out.write(bytes); }
        });
        server.start();
        var client = HttpClient.newHttpClient();
        String base = "http://127.0.0.1:" + server.getAddress().getPort();
        for (String path : new String[] {"/api/v1/products/1", "/api/v1/products/99"}) {
            var req = HttpRequest.newBuilder(URI.create(base + path)).GET().build();
            var res = client.send(req, HttpResponse.BodyHandlers.ofString());
            System.out.println("GET " + path + " -> " + res.statusCode() + " "
                + res.headers().firstValue("Content-Type").orElse("?") + "\n    " + res.body());
        }
        server.stop(0);
    }
}

আউটপুট চার লাইন: GET /api/v1/products/1 -> 200 application/json, তার নিচে {"id":1,"name":"চা-পাতা","price":120}; তারপর GET /api/v1/products/99 -> 404 application/json আর {"status":404,"detail":"product 99 not found"}. এখানে সত্যিই একটি TCP সংযোগ খুলেছে, HTTP request গেছে, উত্তর এসেছে — একই কম্পিউটারের ভেতরে। port 0 দিলে অপারেটিং সিস্টেম একটি খালি port বেছে দেয়; সেই সংখ্যা প্রতিবার আলাদা, তাই আমরা ছাপাইনি। খেয়াল করুন, পাওয়া না গেলেও body আছে — status code বলে কী হয়েছে, body বলে কেন. আর কত কাজ হাতে করতে হলো: পথ কেটে id বের করা, JSON হাতে লেখা, byte গোনা, header বসানো। Spring Boot ঠিক এই কাজগুলো আপনার হয়ে করে।

১৯৮৯–১৯৯১ সালে CERN-এ Tim Berners-Lee ওয়েবের জন্য HTTP তৈরি করেন। প্রথম সংস্করণে (পরে নাম হয় HTTP/0.9) ছিল শুধু একটি কাজ: GET /page — কোনো header নেই, status code নেই। ১৯৯৬ সালে HTTP/1.0 (RFC 1945) header আর status code আনে, ১৯৯৭ সালে HTTP/1.1. এরপর HTTP/2 আর HTTP/3 এসেছে, কিন্তু method আর status code-এর অর্থ একই আছে। সেই গল্প ভাবনার প্রশ্ন ২-এ।

২ · method আর status code — কোনটি কী বোঝায়

HTTP-তে কাজের ধরন বোঝাতে কয়েকটি method আছে। ডেটা নিয়ে প্রায় সব কাজ চারটি মৌলিক কাজে পড়ে — তৈরি, পড়া, বদলানো, মোছা; ইংরেজিতে সংক্ষেপে CRUD. প্রতিটির জন্য একটি method:

Methodকাজউদাহরণsafe?idempotent?
GETপড়াGET /products/7হ্যাঁহ্যাঁ
POSTনতুন তৈরি (বা অন্য প্রক্রিয়া)POST /productsনানা
PUTপুরোটা বদলে দেওয়াPUT /products/7নাহ্যাঁ
PATCHআংশিক বদলPATCH /products/7নানিশ্চিত নয়
DELETEমুছে ফেলাDELETE /products/7নাহ্যাঁ

শেষের দুটি কলাম গুরুত্বপূর্ণ। safe method সার্ভারের কিছু বদলায় না — GET যতবার খুশি পাঠানো যায়। idempotent মানে একই request একবার পাঠানো আর দশবার পাঠানোর ফল সার্ভারে একই। PUT /products/7 দিয়ে দাম ১২০ করলে দশবারেও দাম ১২০। DELETE দুবার পাঠালে দ্বিতীয়বার হয়তো 404 আসবে, কিন্তু সার্ভারের অবস্থা একই — পণ্যটি নেই। কিন্তু POST /orders দশবার গেলে দশটি অর্ডার! মোবাইল নেটওয়ার্কে উত্তর হারিয়ে গেলে অ্যাপ আবার চেষ্টা করে, তাই এই পার্থক্য টাকার হিসাবে সরাসরি প্রভাব ফেলে (ভাবনার প্রশ্ন ৪)।

উত্তরে সার্ভার দেয় একটি তিন অঙ্কের status code. প্রথম অঙ্কটি দলের নাম বলে: 2xx সফল, 3xx অন্য জায়গায় যান, 4xx client-এর ভুল, 5xx সার্ভারের ভুল। প্রতিদিন যেগুলো লাগে:

Codeনামকখন দেবেন
200OKসফল, body-তে ফল আছে (GET, PUT)
201Createdনতুন resource তৈরি হলো; সাথে Location header
204No Contentসফল, কিন্তু ফেরত দেওয়ার কিছু নেই (DELETE)
400Bad Requestইনপুট ভুল — JSON ভাঙা, দাম ঋণাত্মক, নাম খালি
401Unauthorizedকে আপনি, জানা নেই — লগইন বা token নেই বা মেয়াদোত্তীর্ণ
403Forbiddenআপনাকে চিনি, কিন্তু এই কাজের অনুমতি নেই
404Not Foundএই resource নেই
409Conflictবর্তমান অবস্থার সাথে সংঘাত — একই নামের পণ্য আছে, স্টক নেই
500Internal Server Errorসার্ভারের ভেতরের অপ্রত্যাশিত ভুল — client-এর দোষ নয়

401-এর নাম “Unauthorized” হলেও আসল অর্থ “পরিচয় যাচাই হয়নি” (authentication). আর 403 মানে পরিচয় জানা, কিন্তু অনুমতি নেই (authorization). নামটি ঐতিহাসিক ভুল, অর্থটি মনে রাখুন। নিচের stepper-এ পণ্যের API-র আটটি পরিস্থিতিতে কোন code ঠিক, ধাপে ধাপে দেখুন:

পড়া — পাওয়া গেলGET
GET /api/v1/products/7
200 OK
{"id":7,"name":"মিনিকেট চাল ৫ কেজি","price":420,"inStock":true}
পড়া — নেইGET
GET /api/v1/products/99
404 Not Found
{"title":"Product not found","status":404,"detail":"Product 99 not found"}
200 দিয়ে body-তে "error" লিখবেন না
তৈরি — সফলPOST
POST /api/v1/products   {"name":"চা-পাতা", ...}
201 Created
Location: /api/v1/products/42
{"id":42,"name":"চা-পাতা", ...}
তৈরি — ইনপুট ভুলPOST
POST /api/v1/products   {"name":"","price":-5}
400 Bad Request
{"title":"Validation failed","errors":{"name":"...","price":"..."}}
তৈরি — একই নাম আগেই আছেPOST
POST /api/v1/products   {"name":"চা-পাতা", ...}
409 Conflict
{"title":"Duplicate product","detail":"'চা-পাতা' already exists"}
বদল — সফলPUT
PUT /api/v1/products/42   {"name":"চা-পাতা","price":130, ...}
200 OK
{"id":42,"price":130, ...}
একই PUT আবার পাঠালেও ফল একই — idempotent
মোছা — সফলDELETE
DELETE /api/v1/products/42
204 No Content
(body নেই)
মোছা — আবার পাঠানো হলোDELETE
DELETE /api/v1/products/42
404 Not Found
উত্তর আলাদা, কিন্তু সার্ভারের অবস্থা একই — পণ্যটি নেই
  1. পাওয়া গেল → 200. GET সফল হলে 200, আর body-তে resource-টি।
  2. নেই → 404. সবচেয়ে প্রচলিত ভুল: 200 দিয়ে body-তে {"error":"not found"} লেখা। তখন অ্যাপ, cache, monitoring — সবাই ভাবে সব ঠিক আছে। status code-ই প্রথম খবর।
  3. তৈরি হলো → 201 + Location. নতুন resource-এর ঠিকানা Location header-এ দিন, যাতে client পরে সেখানে GET করতে পারে। body-তে তৈরি হওয়া পণ্যটিও দেওয়া প্রচলিত।
  4. ইনপুট ভুল → 400. কোন field-এ কী ভুল, তা body-তে বলুন — অ্যাপ সেই বার্তা ফর্মের ঘরের নিচে দেখাতে পারবে। কেউ কেউ যাচাইয়ের ভুলে 422 দেন; Spring-এর ডিফল্ট 400.
  5. অবস্থার সাথে সংঘাত → 409. ইনপুট নিজে ঠিক, কিন্তু সার্ভারের বর্তমান অবস্থায় কাজটি সম্ভব নয় — একই নামের পণ্য আগেই আছে।
  6. বদল → 200. PUT পুরো resource-টি নতুন মানে বদলায়; ফল হিসেবে বদলানো পণ্য ফেরত দিলে 200 (কিছু না দিলে 204). id না থাকলে 404.
  7. মোছা → 204. মুছে ফেলার পর দেখানোর কিছু নেই, তাই body ছাড়া 204.
  8. আবার মোছা → 404. দ্বিতীয় DELETE-এ উত্তরের code বদলেছে, কিন্তু idempotency মাপা হয় সার্ভারের অবস্থা দিয়ে, উত্তর দিয়ে নয়। তাই DELETE এখনো idempotent.

৩ · REST-এর নিয়মে URL সাজানো

REST (Representational State Transfer) কোনো লাইব্রেরি বা প্রোটোকল নয় — একটি নকশার ধরন। ২০০০ সালে Roy Fielding তাঁর PhD গবেষণাপত্রে ওয়েব কেন এত বড় হয়েও টিকে আছে, তা ব্যাখ্যা করতে গিয়ে এর নাম দেন। মূল ভাবনা: সার্ভারের ডেটাকে কয়েকটি resource হিসেবে দেখুন — পণ্য, অর্ডার, গ্রাহক। প্রতিটি resource-এর একটি স্থায়ী ঠিকানা (URL). আর সেগুলোর ওপর কাজ হবে HTTP-র সাধারণ method দিয়েই। দৈনন্দিন কাজে “REST API” মানে সাধারণত এটুকুই: সুন্দর URL, সঠিক method, সঠিক status code আর JSON. Fielding-এর মূল সংজ্ঞা এর চেয়ে কঠোর ছিল; সেই গল্প ভাবনার প্রশ্ন ১-এ।

প্রতিটি URL আর method-এর জোড়া একটি endpoint — যেমন GET /api/v1/products/{id}. ভালো endpoint নকশার কয়েকটি নিয়ম:

নিয়মভালোএড়িয়ে চলুন
URL-এ বিশেষ্য, কাজ method-এPOST /productsPOST /createProduct
সংগ্রহের নাম বহুবচনে/products, /products/7/product/7 আর /productList মেশানো
নির্দিষ্ট একটি — পথের ভেতরে id/products/7/products?id=7
সম্পর্ক — এক স্তরের nesting/customers/5/orders/customers/5/orders/9/items/3/reviews
ছাঁকা, সাজানো, পাতা — query-তে/products?category=grocery&page=0&size=20&sort=price,asc/products/grocery/page/0
সংস্করণ সামনে/api/v1/productsনিঃশব্দে পুরোনো JSON-এর গঠন বদলানো
ছোট হাতের অক্ষর, শব্দের মাঝে হাইফেন/delivery-zones/DeliveryZones, /delivery_zones

পথের ভেতরের অংশ /products/7 একটি path parameter — কোন resource, তা বলে। ?-এর পরের অংশ category=grocery একটি query parameter — কেমন করে দেখাব (ছাঁকা, সাজানো, কোন পাতা)। আর /api/v1 হলো API versioning: অ্যাপের পুরোনো সংস্করণ মাসের পর মাস মানুষের ফোনে থেকে যায়, তাই JSON-এর গঠন ভাঙার মতো বদল এলে /api/v2 খুলুন, v1 কিছুদিন চালু রাখুন (ভাবনার প্রশ্ন ৫)।

মূল ধারণা

REST-এর একটি মৌলিক নিয়ম stateless: প্রতিটি request নিজেই সম্পূর্ণ — কে পাঠাচ্ছে (token), কী চাইছে, সব তার ভেতরে। সার্ভার আগের request মনে রেখে পরেরটির অর্থ বোঝে না। এর ফলে একই অ্যাপের দশটি কপি দশটি সার্ভারে চালানো যায়; যেকোনো request যেকোনো কপিতে গেলেও ফল একই। ঈদের আগে অর্ডার দশগুণ হলে সার্ভারের সংখ্যা বাড়ানো সহজ হয় এই নিয়মের জন্যই।

৪ · তিন স্তরের নকশা — Controller, Service, Repository

একটি অর্ডার নেওয়ার কাজে অনেক রকম চিন্তা মিশে থাকে: JSON পড়া, ইনপুট যাচাই, স্টক আছে কি না দেখা, স্টক কমানো, ডেটাবেসে লেখা, ঠিক status code দেওয়া। সব একটি method-এ লিখলে কোড দ্রুত জট পাকায়। তাই প্রায় সব Spring প্রজেক্টে একই স্তরভিত্তিক নকশা দেখবেন:

🌐 Controller

Controller শুধু HTTP-র ভাষা বোঝে: URL, method, JSON, status code. ব্যবসার কোনো সিদ্ধান্ত এখানে নয় — রেস্টুরেন্টের ওয়েটারের মতো, অর্ডার নেন আর খাবার পৌঁছে দেন।

⚙️ Service

Service-এ ব্যবসার নিয়ম: স্টক না থাকলে অর্ডার নয়, একই নামের দুটি পণ্য নয়। HTTP-র কিছুই জানে না, তাই একই নিয়ম অ্যাপ, অ্যাডমিন প্যানেল বা রাতের batch job — সবাই ব্যবহার করতে পারে। এটা রাঁধুনি।

🗄️ Repository

Repository ডেটা আনে আর রাখে। পেছনে PostgreSQL না H2, তা ওপরের স্তর জানে না — এটা ভাঁড়ারঘর।

স্তরগুলোর মধ্যে ডেটাও আলাদা রূপে চলে। ডেটাবেসের টেবিলের সাথে মেলানো class হলো entity — এতে থাকতে পারে কেনা দাম, সরবরাহকারীর ফোন নম্বর, ভেতরের নোট। আর বাইরের জগতের সাথে চুক্তি হলো DTO (Data Transfer Object) — শুধু যা পাঠানো বা নেওয়ার কথা। Java-তে DTO-র জন্য record একদম মানানসই: অপরিবর্তনীয়, ছোট, আর Jackson সরাসরি বোঝে (পর্ব ২৫, ৩৯)।

entity সরাসরি বাইরে পাঠাবেন না। তিনটি কারণ। (১) গোপন field ফাঁস হয় — costPrice দেখে প্রতিযোগী আপনার লাভ জেনে যাবে, আর User entity হলে পাসওয়ার্ডের hash. (২) উল্টো দিকেও বিপদ: entity সরাসরি @RequestBody-তে নিলে কেউ JSON-এ "role":"ADMIN" বা "price":1 পাঠিয়ে এমন field বদলে দিতে পারে, যা বদলানোর কথা নয়। (৩) টেবিলে একটি কলামের নাম বদলালেই অ্যাপের JSON ভেঙে যায়। DTO এই তিনটিই আটকায়।

Spring আসার আগে, শুধু Java দিয়ে এই তিন স্তর চালিয়ে দেখুন। এখানে ডেটাবেসের বদলে একটি TreeMap, আর HTTP-র বদলে method call — কিন্তু দায়িত্বের ভাগ আর status code-এর সিদ্ধান্ত হুবহু Spring প্রজেক্টের মতো:

import java.util.*;

record Product(long id, String name, int stock) {}
record OrderRequest(long productId, int qty) {}
class ApiError extends RuntimeException {                  // HTTP status-সহ ব্যর্থতা
    final int status;
    ApiError(int status, String msg) { super(msg); this.status = status; }
}
class ProductRepository {                                   // ডেটার স্তর (এখানে Map, বাস্তবে ডেটাবেস)
    private final Map<Long, Product> db = new TreeMap<>(Map.of(1L, new Product(1, "Tea", 5), 2L, new Product(2, "Rice", 0)));
    Optional<Product> findById(long id) { return Optional.ofNullable(db.get(id)); }
    void save(Product p) { db.put(p.id(), p); }
}
class OrderService {                                        // ব্যবসার নিয়মের স্তর
    private final ProductRepository repo;
    OrderService(ProductRepository repo) { this.repo = repo; }
    int place(OrderRequest r) {
        var p = repo.findById(r.productId()).orElseThrow(() -> new ApiError(404, "product " + r.productId() + " not found"));
        if (p.stock() < r.qty()) throw new ApiError(409, "only " + p.stock() + " in stock");
        repo.save(new Product(p.id(), p.name(), p.stock() - r.qty()));
        return p.stock() - r.qty();
    }
}
class OrderController {                                     // HTTP-র স্তর
    private final OrderService service;
    OrderController(OrderService service) { this.service = service; }
    String post(OrderRequest r) {
        try {
            if (r.qty() <= 0) throw new ApiError(400, "qty must be positive");   // যাচাই
            return "201 Created, stock left " + service.place(r);
        } catch (ApiError e) { return e.status + " " + e.getMessage(); }     // advice-এর কাজ
    }
}
class Main {
    public static void main(String[] args) {
        var api = new OrderController(new OrderService(new ProductRepository()));
        for (var r : List.of(new OrderRequest(1, 2), new OrderRequest(1, 0), new OrderRequest(9, 1),
                             new OrderRequest(2, 1), new OrderRequest(1, 4)))
            System.out.println("POST /orders " + r + " -> " + api.post(r));
    }
}

পাঁচটি অর্ডার, পাঁচ রকম ফল: OrderRequest[productId=1, qty=2] -> 201 Created, stock left 3; qty=0-তে 400 qty must be positive; অজানা পণ্য ৯-এ 404 product 9 not found; স্টকহীন চালে (২) 409 only 0 in stock; আর শেষে চা-পাতার ৪টি চাইলে 409 only 3 in stock — কারণ প্রথম অর্ডারের পর স্টক ৫ থেকে ৩ হয়েছে। লক্ষ করুন কে কী জানে: Repository শুধু Map চেনে; Service স্টকের নিয়ম জানে কিন্তু “HTTP” শব্দটিও জানে না (status সংখ্যাটি এখানে সরলতার জন্য exception-এ রাখা); Controller ইনপুটের আকার যাচাই করে আর exception-কে উত্তরে বদলায়। Service-এ কাউকে new করতে হয়নি — Repository বাইরে থেকে constructor-এ এসেছে, পর্ব ৫০-এর dependency injection. Spring ঠিক এই জোড়া লাগানোর কাজটি নিজে করে।

C/C++/Python জানলে

Python-এর FastAPI-তে @app.get("/products/{id}") লেখা function আর Spring-এর @GetMapping("/{id}") লেখা method একই ধারণা — decorator বনাম annotation. FastAPI-র Pydantic model এখানে record + Bean Validation-এর কাজ করে, আর FastAPI নিজেই OpenAPI ডকুমেন্ট বানায়; Spring-এ সেটা springdoc যোগ করে আসে (অংশ ৮)। C আর C++-এ ওয়েব API বাইরের লাইব্রেরি দিয়ে লেখা যায়, তবে ব্যবসায়িক API-তে কম দেখা যায়। Python-এর দিকটা দেখুন ওয়েব ব্যাকএন্ড — FastAPI-র শুরু পর্বে।

৫ · Spring MVC দিয়ে controller — method থেকে endpoint

পর্ব ৫০-এ দেখেছেন, @SpringBootApplication লেখা একটি class চালালেই Spring Boot অ্যাপ চালু হয়, starter দিয়ে লাইব্রেরি আসে, আর @Service, @Repository লেখা class-গুলো bean হয়ে constructor-এ জোড়া লাগে। REST API-র জন্য লাগে spring-boot-starter-web — এটি Spring MVC, Jackson আর একটি embedded server (ডিফল্টে Tomcat) নিয়ে আসে। ফলে আলাদা কোনো সার্ভার install করতে হয় না; অ্যাপ চালু হলেই port 8080-এ শোনা শুরু করে। এই পর্বের Spring কোড Spring Boot 3.x (Java 17 বা নতুন) ধরে লেখা। পরের বড় সংস্করণে (Spring Boot 4) কিছু starter বা package-এর নাম বদলাতে পারে — নতুন প্রজেক্টে start.spring.io আর অফিসিয়াল migration guide দেখে নিন; annotation আর ধারণা মূলত একই থাকে। এই লাইব্রেরি অনলাইন runner-এ নেই, তাই Spring কোডগুলো চালানো যাবে না; পাশে প্রত্যাশিত ফল লেখা আছে।

প্রথমে DTO দুটি — একটি আসা ডেটার জন্য, একটি যাওয়ার জন্য:

public record CreateProductRequest(
        @NotBlank(message = "নাম দিতে হবে") @Size(max = 100) String name,
        @NotBlank(message = "ক্যাটাগরি দিতে হবে") String category,
        @NotNull @Positive(message = "দাম শূন্যের বেশি হতে হবে") BigDecimal price,
        @PositiveOrZero int stock,
        @Pattern(regexp = "^01[3-9]\\d{8}$", message = "সঠিক বাংলাদেশি মোবাইল নম্বর দিন")
        String supplierPhone) {}

public record ProductResponse(Long id, String name, String category,
                              BigDecimal price, boolean inStock) {
    static ProductResponse from(Product p) {                 // entity → DTO
        return new ProductResponse(p.getId(), p.getName(), p.getCategory(),
                                   p.getPrice(), p.getStock() > 0);
    }
}

লক্ষ করুন, response-এ costPrice বা supplierPhone নেই, আর স্টকের সঠিক সংখ্যার বদলে শুধু inStock. annotation-গুলোর অর্থ অংশ ৬-এ। এবার controller:

@RestController
@RequestMapping("/api/v1/products")
public class ProductController {
    private final ProductService service;
    public ProductController(ProductService service) { this.service = service; }

    @GetMapping                                               // GET /api/v1/products?category=grocery&page=0
    public PageResponse<ProductResponse> list(
            @RequestParam(required = false) String category,
            @PageableDefault(size = 20, sort = "name") Pageable pageable) {
        return PageResponse.of(service.list(category, pageable));
    }

    @GetMapping("/{id}")                                      // GET /api/v1/products/7 → 200
    public ProductResponse get(@PathVariable long id) {
        return service.get(id);
    }

    @PostMapping                                              // POST → 201 + Location
    public ResponseEntity<ProductResponse> create(@Valid @RequestBody CreateProductRequest req) {
        ProductResponse saved = service.create(req);
        URI location = ServletUriComponentsBuilder.fromCurrentRequest()
                .path("/{id}").buildAndExpand(saved.id()).toUri();
        return ResponseEntity.created(location).body(saved);
    }

    @PutMapping("/{id}")                                      // PUT → 200
    public ProductResponse update(@PathVariable long id,
                                  @Valid @RequestBody UpdateProductRequest req) {
        return service.update(id, req);
    }

    @DeleteMapping("/{id}")                                   // DELETE → 204
    public ResponseEntity<Void> delete(@PathVariable long id) {
        service.delete(id);
        return ResponseEntity.noContent().build();
    }
}
Annotation / classকাজ
@RestController@Controller + @ResponseBody: প্রতিটি method-এর ফেরত মান সরাসরি HTTP body-তে (JSON) যাবে, HTML পাতা খোঁজা হবে না
@RequestMapping("/api/v1/products")class-এর সব endpoint-এর সাধারণ পথের শুরু
@GetMapping, @PostMapping, @PutMapping, @PatchMapping, @DeleteMappingকোন method আর পথে এই Java method চলবে
@PathVariableপথের {id} অংশ parameter-এ; /products/abc এলে long-এ রূপ দেওয়া যায় না, তাই 400
@RequestParamquery parameter; required = false হলে না থাকলে null
@RequestBodyJSON body Jackson দিয়ে record-এ; JSON ভাঙা হলে 400
ResponseEntitystatus, header আর body — তিনটিই নিজে ঠিক করতে চাইলে

JSON ⇄ record রূপান্তরের জন্য কোথাও ObjectMapper ডাকতে হয়নি। Spring Boot নিজেই একটি Jackson ObjectMapper বানিয়ে রাখে, java.time-এর সমর্থনসহ; record ফেরত দিলে সেটি JSON হয়, @RequestBody record-এ JSON ঢোকে (পর্ব ৩৯)। একটি পার্থক্য মনে রাখুন: পর্ব ৩৯-এর সাধারণ ObjectMapper অজানা key পেলে exception দেয়, কিন্তু Spring Boot-এর ObjectMapper ডিফল্টে অজানা key উপেক্ষা করে। তাই কেউ "costPrice":1 পাঠালেও DTO-তে সেই component না থাকলে তা নীরবে বাদ পড়ে — DTO-র আরেকটি সুবিধা।

এবার দেখুন, একটি POST request আসার পর থেকে উত্তর যাওয়া পর্যন্ত Spring-এর ভেতরে কী কী ঘটে:

  1. ClientPOSTঅ্যাপ
  2. DispatcherServletপ্রধান ফটক
  3. Controllercreate()HTTP
  4. Servicecreate()নিয়ম
  5. Repositorysave()ডেটাবেস
ধাপ ১request রওনা দিল
POST /api/v1/products HTTP/1.1
Content-Type: application/json

{"name":"চা-পাতা","category":"grocery","price":120,"stock":50}
ধাপ ২Tomcat → DispatcherServlet
Tomcat: সংযোগ নিল, একটি thread দিল
  └─▶ DispatcherServlet.service(request, response)
সব request একই দরজা দিয়ে ঢোকে
ধাপ ৩handler mapping
POST /api/v1/products   →  ProductController.create(..)
GET  /api/v1/products/{id} →  ProductController.get(..)
DELETE /api/v1/products/{id} → ProductController.delete(..)
অ্যাপ চালুর সময়েই এই তালিকা তৈরি
ধাপ ৪JSON → record, তারপর @Valid
Jackson: CreateProductRequest[name=চা-পাতা, category=grocery,
         price=120, stock=50, supplierPhone=null]
Validator: @NotBlank ✓  @Positive ✓  @Pattern ✓ (null চলবে)
ধাপ ৫Service — transaction শুরু
@Transactional  → BEGIN
existsByName("চা-পাতা")?  না  → চালিয়ে যাও
new Product(...)   (entity, id এখনো null)
ধাপ ৬Repository → SQL
insert into products (category, cost_price, name, price, stock)
values (?, ?, ?, ?, ?)
→ ডেটাবেস id দিল: 42      COMMIT
ধাপ ৭entity → DTO → JSON → response
ProductResponse.from(entity)  → ProductResponse[id=42, ...]
Jackson →
HTTP/1.1 201
Location: http://localhost:8080/api/v1/products/42
Content-Type: application/json
{"id":42,"name":"চা-পাতা","category":"grocery","price":120,"inStock":true}
  1. Client পাঠাল। অ্যাপ JSON body-সহ একটি POST পাঠাল, Content-Type: application/json দিয়ে।
  2. প্রধান ফটক। Tomcat সংযোগটি নিয়ে request-টিকে একটি thread দেয় (ডিফল্টে সর্বোচ্চ ২০০টি thread-এর pool থেকে — পর্ব ৪২-এর thread-per-request). তারপর পাঠায় DispatcherServlet-এ — Spring MVC-র একমাত্র প্রবেশদ্বার।
  3. কে সামলাবে? অ্যাপ চালুর সময় Spring সব @RestController ঘুরে method আর পথের একটি তালিকা বানিয়ে রেখেছে। POST + /api/v1/products মেলে create-এর সাথে। কিছু না মিললে 404, পথ মেলে কিন্তু method না মিললে 405.
  4. Parameter তৈরি আর যাচাই। Jackson body থেকে record বানায়, তারপর @Valid থাকায় Validator প্রতিটি annotation পরীক্ষা করে। এখানে সব ঠিক। ভুল হলে controller-এর method চলতই না — অংশ ৬-এ সেই পথ।
  5. ব্যবসার নিয়ম। Service method-এ @Transactional থাকায় একটি transaction শুরু হয় (পর্ব ৪৯)। একই নামের পণ্য আছে কি না দেখা হলো; থাকলে 409-এর exception.
  6. ডেটাবেসে লেখা। Repository-র save একটি SQL INSERT চালায়। ডেটাবেস নতুন id দেয়, method শেষ হলে transaction COMMIT হয়।
  7. ফেরার পথ। entity থেকে DTO বানানো হলো — costPrice বাদ। Controller ResponseEntity.created(location) দিয়ে 201 আর Location header দিল, Jackson DTO-কে JSON বানাল, আর Tomcat উত্তরটি পাঠাল।

চালু অ্যাপকে terminal থেকে curl দিয়ে চেষ্টা করা যায় (Postman-এর মতো GUI টুলেও একই কাজ)। -i দিলে header-ও দেখায়:

curl -i "http://localhost:8080/api/v1/products?category=grocery&page=0&size=2"
HTTP/1.1 200
Content-Type: application/json

{"items":[{"id":3,"name":"এলাচ ১০০ গ্রাম","category":"grocery","price":180,"inStock":true},
          {"id":42,"name":"চা-পাতা","category":"grocery","price":120,"inStock":true}],
 "page":0,"size":2,"totalItems":17,"totalPages":9}

এখানে PageResponse আমাদের নিজের একটি ছোট record (items, page, size, totalItems, totalPages). Spring Data-র Page object সরাসরি ফেরত দিলে তার JSON-এর গঠন স্থিতিশীল নয় বলে নতুন Spring Data সংস্করণ সতর্কবার্তা দেয়; নিজের DTO-ই নিরাপদ চুক্তি।

৬ · ইনপুট যাচাই আর এক জায়গায় error সামলানো

অ্যাপ বা ওয়েবসাইটের ফর্মে যাচাই থাকলেও সার্ভারে আবার যাচাই করতেই হবে। কারণ যে কেউ curl দিয়ে সরাসরি আপনার API-তে যা খুশি পাঠাতে পারে — অ্যাপের যাচাই তখন পাশ কাটানো। Java-তে এর মানক উপায় Jakarta Bean Validation: field-এর ওপর annotation দিয়ে নিয়ম লেখা, আর একটি validator (সাধারণত Hibernate Validator) সেগুলো পরীক্ষা করে। Spring Boot-এ এর জন্য spring-boot-starter-validation যোগ করতে হয় — web starter-এর সাথে নিজে থেকে আসে না।

Annotationনিয়মলক্ষ করুন
@NotNullnull নয়"" চলবে
@NotBlanknull নয়, আর শুধু ফাঁকা জায়গাও নয়String-এর জন্য
@Size(max = 100)দৈর্ঘ্যের সীমাString, List-এ চলে
@Positive, @PositiveOrZero> 0, ≥ 0null হলে পাস — সাথে @NotNull দিন
@Pattern(regexp = "^01[3-9]\\d{8}$")বাংলাদেশি মোবাইল নম্বর: 01, তারপর 3–9, তারপর আরও ৮ অঙ্কnull হলে পাস — নম্বরটি ঐচ্ছিক হলে সুবিধা
@Emailইমেইলের মতো দেখতেঠিকানাটি আসল কি না, তা নয়

শুধু annotation লিখলে কিছু হয় না; controller-এর parameter-এ @Valid লিখলে তবেই যাচাই চলে। যাচাই ব্যর্থ হলে Spring MethodArgumentNotValidException ছোড়ে, আর controller-এর method শুরুই হয় না।

এক জায়গায় সব error — @RestControllerAdvice আর ProblemDetail

প্রতিটি controller method-এ try-catch লিখলে কোড ঘোলা হয়, আর প্রতিটি endpoint-এর error JSON আলাদা চেহারার হয়ে যায়। তাই একটি @RestControllerAdvice class লিখুন — সব controller-এর exception এখানে আসে, আর @ExceptionHandler লেখা method ঠিক করে কোন exception কোন উত্তরে বদলাবে। উত্তরের ফরম্যাটের জন্য একটি ইন্টারনেট মান আছে: “Problem Details for HTTP APIs” — প্রথমে RFC 7807 (২০১৬), এখন তার জায়গায় RFC 9457 (২০২৩)। এতে আছে কয়েকটি নির্দিষ্ট field — type, title, status, detail, instance — আর media type application/problem+json. Spring Framework 6 (Spring Boot 3) থেকে এর জন্য তৈরি class: ProblemDetail.

public class ProductNotFoundException extends RuntimeException {
    public ProductNotFoundException(long id) { super("Product " + id + " not found"); }
}

@RestControllerAdvice
public class GlobalErrorHandler {

    @ExceptionHandler(ProductNotFoundException.class)
    public ProblemDetail notFound(ProductNotFoundException ex) {
        ProblemDetail pd = ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
        pd.setTitle("Product not found");
        pd.setType(URI.create("https://shop.example.com/problems/product-not-found"));
        return pd;
    }

    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ProblemDetail invalid(MethodArgumentNotValidException ex) {
        ProblemDetail pd = ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, "ইনপুটে ভুল আছে");
        pd.setTitle("Validation failed");
        Map<String, String> errors = new TreeMap<>();
        ex.getBindingResult().getFieldErrors()
          .forEach(fe -> errors.put(fe.getField(), fe.getDefaultMessage()));
        pd.setProperty("errors", errors);
        return pd;
    }

    @ExceptionHandler(OutOfStockException.class)
    public ProblemDetail outOfStock(OutOfStockException ex) {
        ProblemDetail pd = ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT, ex.getMessage());
        pd.setTitle("Out of stock");
        pd.setProperty("available", ex.available());
        return pd;
    }
}

নিচের stepper-এ একটি ভুল ইনপুট — নাম খালি, দাম ঋণাত্মক, ফোন নম্বর ভুল — কোন পথে 400 উত্তর হয়ে ফেরে, দেখুন:

1@PostMapping2ResponseEntity<..> create(3    @Valid @RequestBody CreateProductRequest req) {4  var saved = service.create(req);5  return ResponseEntity.created(..).body(saved);6}7// ---- @RestControllerAdvice ----8@ExceptionHandler(MethodArgumentNotValidException.class)9ProblemDetail invalid(MethodArgumentNotValidException ex) {10  var pd = ProblemDetail.forStatusAndDetail(BAD_REQUEST, ..);11  pd.setProperty("errors", fieldErrors(ex));12  return pd;13}
অবস্থা
ঘটনা
req
errors
  1. JSON এলো। body: {"name":"","category":"grocery","price":-5,"stock":3,"supplierPhone":"12345"}. JSON হিসেবে এটা বৈধ, তাই Jackson আপত্তি করে না।
  2. record তৈরি। Jackson CreateProductRequest বানাল। record-এর compact constructor-এ নিজের যাচাই থাকলে সেটাও এখানে চলত (পর্ব ২৫)।
  3. @Valid পরীক্ষা। Validator তিনটি নিয়ম ভাঙা পেল: @NotBlank (name), @Positive (price), @Pattern (supplierPhone). সব ভুল একসাথে জমা হয় — প্রথমটিতে থামে না, তাই ব্যবহারকারী একবারেই সব ঠিক করতে পারেন।
  4. Exception, controller বাদ। Spring MethodArgumentNotValidException ছুড়ল। লাইন ৪ আর ৫ কখনো চলল না — service-এ ভুল ডেটা পৌঁছানোর কোনো পথ নেই।
  5. Advice খোঁজা। DispatcherServlet দেখল কোন @ExceptionHandler এই exception নেয়, পেল invalid method.
  6. ProblemDetail বানানো। 400 status, title, detail, আর প্রতিটি field-এর বার্তা নিয়ে একটি errors map. TreeMap-এ রাখায় field-গুলো বর্ণানুক্রমে — প্রতিবার একই ক্রম।
  7. উত্তর। ProblemDetail ফেরত দিলে Spring তার status (400) উত্তরে বসায়, আর Content-Type হয় application/problem+json. প্রত্যাশিত body নিচে।
{
  "type": "about:blank",
  "title": "Validation failed",
  "status": 400,
  "detail": "ইনপুটে ভুল আছে",
  "instance": "/api/v1/products",
  "errors": {
    "name": "নাম দিতে হবে",
    "price": "দাম শূন্যের বেশি হতে হবে",
    "supplierPhone": "সঠিক বাংলাদেশি মোবাইল নম্বর দিন"
  }
}

type না দিলে মান হয় about:blank — RFC অনুযায়ী এর মানে “status code-এর বাইরে আলাদা কোনো অর্থ নেই”। instance না দিলে Spring সাধারণত request-এর পথ বসিয়ে দেয়। errors আর available-এর মতো বাড়তি field RFC অনুমোদন করে (extension member). চাইলে spring.mvc.problemdetails.enabled=true দিয়ে Spring-এর নিজের exception-গুলোর (ভাঙা JSON, ভুল method, অসমর্থিত Content-Type) উত্তরও একই ফরম্যাটে আনা যায়।

অর্ডার — স্টক না থাকলে 409

একই নিয়মে অর্ডারের endpoint. service স্টক কম পেলে OutOfStockException ছোড়ে, advice সেটাকে 409 বানায় — ঠিক অংশ ৪-এর চালানো উদাহরণের মতো:

curl -i -X POST http://localhost:8080/api/v1/orders \
     -H "Content-Type: application/json" \
     -d '{"productId":7,"quantity":3,"customerPhone":"01712345678"}'
HTTP/1.1 409
Content-Type: application/problem+json

{"type":"about:blank","title":"Out of stock","status":409,
 "detail":"Only 1 left of product 7","instance":"/api/v1/orders","available":1}

500-এর উত্তরে ভেতরের কথা ফাঁস করবেন না। stack trace, SQL-এর লেখা বা টেবিলের নাম আক্রমণকারীর জন্য মানচিত্র। Spring Boot ডিফল্টে error উত্তরে stack trace দেয় না — এই ডিফল্ট বদলাবেন না। অপ্রত্যাশিত exception-এর বিস্তারিত log-এ লিখুন, আর client-কে দিন একটি সাধারণ বার্তা। advice-এ Exception.class ধরে সব কিছু 500 বানালে সাবধান: তখন Spring-এর নিজের 404, 405, 415-এর exception-ও 500 হয়ে যেতে পারে।

৭ · Spring Data JPA — ডেটাবেসে রাখা আর আনা

পর্ব ৪৯-এ JDBC দিয়ে হাতে SQL লিখেছেন। JPA এক ধাপ ওপরে: Java class-কে টেবিলের সাথে মিলিয়ে দেয়, SQL-এর বেশিরভাগ নিজে তৈরি করে (Spring Boot-এ ডিফল্ট বাস্তবায়ন Hibernate). আর Spring Data JPA আরও এক ধাপ: শুধু একটি interface লিখলেই repository-র পুরো বাস্তবায়ন চলার সময় তৈরি হয়ে যায়।

@Entity
@Table(name = "products")
public class Product {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)      // ডেটাবেস id দেবে
    private Long id;

    @Column(nullable = false, unique = true) private String name;
    @Column(nullable = false)                private String category;
    @Column(nullable = false)                private BigDecimal price;
    private int stock;
    private BigDecimal costPrice;                            // কেনা দাম — বাইরে যাবে না

    protected Product() {}                                   // JPA-র জন্য খালি constructor
    public Product(String name, String category, BigDecimal price, int stock) { ... }
    // getter, আর যেগুলো বদলানোর কথা শুধু সেগুলোর setter …
}

public interface ProductRepository extends JpaRepository<Product, Long> {
    Page<Product> findByCategory(String category, Pageable pageable);
    boolean existsByName(String name);
}

entity কেন record নয়? JPA-র দরকার একটি খালি constructor, বদলানো যায় এমন field, আর lazy loading-এর জন্য subclass বানানোর সুযোগ — record-এ এর কোনোটিই নেই। তাই entity সাধারণ class, আর DTO record. JpaRepository<Product, Long> (entity টাইপ, id-এর টাইপ) থেকে বিনা পয়সায় পাওয়া যায় save, findById, findAll(Pageable), existsById, deleteById, count. আর findByCategory-এর মতো method-এর শরীর কেউ লেখেনি — Spring Data নাম পড়ে query বানায়: find…By + field-এর নাম Category. এগুলোকে বলে derived query. নামে বানান ভুল হলে (findByCategroy) অ্যাপ চালুর সময়েই error — চলার মাঝে নয়।

ডেটাবেসের ঠিকানা থাকে application.properties-এ। শেখা আর টেস্টের জন্য মেমরির ভেতরের H2, আসল সার্ভারে PostgreSQL:

# শেখা ও টেস্ট — মেমরিতে H2 (অ্যাপ বন্ধ হলে ডেটা মুছে যায়)
spring.datasource.url=jdbc:h2:mem:shop

# আসল সার্ভার — PostgreSQL (পাসওয়ার্ড environment variable থেকে)
#spring.datasource.url=jdbc:postgresql://localhost:5432/shop
#spring.datasource.username=shop_app
#spring.datasource.password=${DB_PASSWORD}

spring.jpa.hibernate.ddl-auto=validate     # টেবিল বানাবে Flyway বা schema.sql; Hibernate শুধু মিলিয়ে দেখবে
spring.jpa.open-in-view=false              # lazy loading controller পর্যন্ত গড়াবে না
spring.mvc.problemdetails.enabled=true

service-এর ভেতরে findById ব্যবহারের প্রচলিত রূপটি ধাপে ধাপে দেখুন। findById null নয়, ফেরত দেয় Optional (পর্ব ৩৬) — “পণ্য না-ও থাকতে পারে” কথাটি টাইপেই লেখা:

1@Transactional(readOnly = true)2public ProductResponse get(long id) {3  return repo.findById(id)4      .map(ProductResponse::from)5      .orElseThrow(() -> new ProductNotFoundException(id));6}
অবস্থা
id
SQL
Optional
  1. id = 1 এলো। controller @PathVariable থেকে 1 পেয়ে service ডাকল। readOnly = true-সহ একটি transaction শুরু — শুধু পড়া, তাই কিছু বাড়তি কাজ বাঁচে।
  2. খোঁজা। Repository একটি SELECT চালাল, একটি সারি পেল, সেটা থেকে entity বানিয়ে Optional-এ ভরে দিল।
  3. map. বাক্সে মান আছে, তাই map চলল: entity থেকে DTO. costPrice এখানেই বাদ পড়ল।
  4. orElseThrow — মান আছে। বাক্স খুলে DTO ফেরত দিল। controller সেটা ফেরত দেয়, Spring দেয় 200 আর JSON.
  5. এবার id = 99. একই method, নতুন request.
  6. খোঁজা — কিছু নেই। SELECT কোনো সারি পায়নি। null নয়, ফেরত এলো খালি বাক্স: Optional.empty.
  7. map বাদ। খালি Optional-এ map-এর function চলেই না — খালি বাক্স খালিই থাকে। তাই null-এর ওপর method ডেকে NullPointerException-এর ভয় নেই।
  8. orElseThrow — খালি। ProductNotFoundException(99) ছোড়া হলো। এটা RuntimeException, তাই transaction rollback হয় (পড়ার কাজে বদলানোর কিছু ছিলও না)। advice সেটাকে 404 ProblemDetail বানাল।

Transaction আর pagination

service method-এ @Transactional লিখলে method-এর ভেতরের সব ডেটাবেস কাজ একটি transaction-এ যায়: সব সফল হলে COMMIT, মাঝে RuntimeException উঠলে সব ROLLBACK (পর্ব ৪৯)। অর্ডারে “স্টক কমানো” আর “অর্ডার লেখা” হয় দুটোই হবে, নয়তো কোনোটিই নয়। Spring এটা করে একটি AOP proxy দিয়ে, তাই একই class-এর ভেতর থেকে আরেকটি @Transactional method ডাকলে সেটার নিজস্ব সেটিং কাজ করে না — প্রচলিত একটি ফাঁদ। আর @Transactional controller-এ নয়, service-এ রাখুন; ব্যবসার একটি পুরো কাজ, একটি transaction.

হাজার পণ্যের তালিকা একবারে পাঠালে অ্যাপ ধীর হয়, ডেটাবেসেও চাপ পড়ে। pagination-এ client পাতা ধরে চায়: ?page=0&size=20&sort=price,asc. Spring এই query parameter থেকে নিজেই Pageable object বানায় (পাতার নম্বর ০ থেকে শুরু), আর repository ডেটাবেসে LIMIT/OFFSET-এর মতো SQL চালায়, সাথে মোট সংখ্যার জন্য একটি count query.

N+1 সমস্যা। ধরুন Order entity-তে @OneToMany List<OrderItem> items, যা lazy — দরকার হলে তবেই আনা হয়। আপনি ৫০টি অর্ডার আনলেন (১টি query), তারপর loop-এ প্রতিটির getItems() ডাকলেন — প্রতিবার আরেকটি query. মোট ৫১টি query, যেখানে ১–২টিতেই হতো। কোডে দেখা যায় না, ছোট টেস্ট ডেটায় টেরও পাওয়া যায় না; আসল ডেটায় পাতা খুলতে কয়েক সেকেন্ড। প্রতিকার: join fetch-ওয়ালা query, @EntityGraph, বা সরাসরি DTO-তে query. ডেভেলপমেন্টে spring.jpa.show-sql=true দিয়ে কয়টি SQL যাচ্ছে, দেখে নিন। এই সমস্যার নাম N+1 problem.

৮ · টেস্ট, ডকুমেন্টেশন, নিরাপত্তা আর deploy

টেস্ট — তিন মাপের

পর্ব ৪৭-এর JUnit এখানেও ভিত্তি; Spring শুধু অ্যাপের দরকারি অংশটুকু চালু করে দেয়। @WebMvcTest শুধু web স্তর চালায় — controller, advice, Jackson — ডেটাবেস ছাড়া। service-এর জায়গায় বসে একটি mock, আর MockMvc আসল সার্ভার ছাড়াই HTTP request-এর অনুকরণ করে:

@WebMvcTest(ProductController.class)
class ProductControllerTest {
    @Autowired MockMvc mvc;
    @MockitoBean ProductService service;          // Spring Boot 3.4-এর আগে: @MockBean

    @Test
    void unknownProductGives404() throws Exception {
        when(service.get(99)).thenThrow(new ProductNotFoundException(99));
        mvc.perform(get("/api/v1/products/99"))
           .andExpect(status().isNotFound())
           .andExpect(jsonPath("$.title").value("Product not found"));
    }

    @Test
    void blankNameGives400() throws Exception {
        mvc.perform(post("/api/v1/products").contentType(MediaType.APPLICATION_JSON)
                .content("""
                    {"name":"","category":"grocery","price":120,"stock":5}
                    """))
           .andExpect(status().isBadRequest())
           .andExpect(jsonPath("$.errors.name").exists());
        verifyNoInteractions(service);             // ভুল ইনপুট service পর্যন্ত যায়নি
    }
}

@DataJpaTest শুধু JPA অংশ চালায়, সাধারণত মেমরির ডেটাবেসে — derived query সত্যিই ঠিক SQL বানাচ্ছে কি না দেখতে। আর @SpringBootTest পুরো অ্যাপ চালু করে — সবচেয়ে বাস্তব, সবচেয়ে ধীর; অল্প কয়েকটি end-to-end টেস্টে ব্যবহার করুন। আসল PostgreSQL-এ টেস্ট করতে অনেকে Testcontainers ব্যবহার করেন।

ডকুমেন্টেশন — OpenAPI আর Swagger UI

অ্যাপ বানাচ্ছেন যিনি, তাঁকে জানতে হবে কোন endpoint কী নেয়, কী দেয়। OpenAPI হলো API বর্ণনার একটি মানক ফরম্যাট। springdoc-openapi-starter-webmvc-ui লাইব্রেরি যোগ করলে springdoc আপনার controller আর DTO পড়ে নিজেই বর্ণনা বানায় (/v3/api-docs), আর /swagger-ui.html-এ একটি ওয়েব পাতা দেয়, যেখান থেকে প্রতিটি endpoint ব্রাউজারেই চেষ্টা করা যায়। validation annotation-গুলোও সেই বর্ণনায় নিয়ম হিসেবে দেখায়।

নিরাপত্তার প্রাথমিক কথা

  • লগইন আর অনুমতি: Spring Security নামে আলাদা একটি বড় প্রকল্প আছে — token যাচাই, কে কোন endpoint-এ যেতে পারবে (যেমন শুধু অ্যাডমিন DELETE করতে পারবেন)। এটা নিজে একটি পুরো বিষয়; শুধু জেনে রাখুন, নিজে নিরাপত্তার কোড বানানোর চেয়ে প্রতিষ্ঠিত framework ব্যবহার করা নিরাপদ।
  • শুধু DTO বাইরে: entity, পাসওয়ার্ড hash, কেনা দাম, ভেতরের id কখনো response-এ নয় (অংশ ৪)।
  • CORS: ব্রাউজার নিরাপত্তার জন্য এক domain-এর পাতাকে অন্য domain-এর API ডাকতে দেয় না, যদি না API অনুমতি দেয়। shop.example.com-এর ওয়েবসাইট api.example.com ডাকলে সার্ভারে নির্দিষ্ট origin-এর অনুমতি দিন (@CrossOrigin বা global config). * দিয়ে সবাইকে খুলে দেবেন না। মোবাইল অ্যাপ ব্রাউজার নয়, তাই তার জন্য CORS প্রযোজ্য নয়।
  • Rate limiting: একজন client মিনিটে কতবার ডাকতে পারবে, তার সীমা — OTP পাঠানো বা লগইনের মতো endpoint-এ অপব্যবহার ঠেকায়। সীমা পার হলে 429 Too Many Requests. সাধারণত API gateway বা আলাদা লাইব্রেরি এ কাজ করে।
  • HTTPS: সাধারণ HTTP-তে token আর পাসওয়ার্ড খোলা লেখায় যায় — একই Wi-Fi-তে থাকা কেউ পড়ে ফেলতে পারে। আসল সার্ভারে শুধু HTTPS.

Deploy-এর এক ঝলক

mvn package চালালে Spring Boot একটি executable JAR বানায় — আপনার কোড, সব লাইব্রেরি আর embedded Tomcat একটি ফাইলে। যে কম্পিউটারে Java আছে, সেখানে java -jar shop.jar লিখলেই সার্ভার চালু। আজকাল প্রায়ই এটাকে Docker image-এ ভরা হয় — অ্যাপ আর তার Java runtime একসাথে একটি বাক্সে, যা যেকোনো সার্ভার বা ক্লাউডে একইভাবে চলে। একটি ন্যূনতম Dockerfile মাত্র কয়েক লাইনের:

FROM eclipse-temurin:21-jre
COPY target/shop-0.0.1-SNAPSHOT.jar /app/shop.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "/app/shop.jar"]

অনেক request একসাথে সামলাতে Spring Boot 3.2 থেকে spring.threads.virtual.enabled=true দিয়ে প্রতিটি request-এ একটি virtual thread দেওয়া যায় — পর্ব ৪২-এর ধারণা এখানেই কাজে লাগে।

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

REST কী? resource-কে URL দিয়ে চিহ্নিত করে HTTP method দিয়ে তাদের ওপর কাজ করার একটি stateless নকশার ধরন।
PUT আর PATCH-এর পার্থক্য? PUT পুরো resource বদলায় আর idempotent; PATCH আংশিক বদল, idempotent হওয়ার নিশ্চয়তা নেই।
@Controller আর @RestController? @RestController = @Controller + @ResponseBody; ফেরত মান সরাসরি JSON body.
401 আর 403? 401 — পরিচয় যাচাই হয়নি; 403 — পরিচয় জানা, কিন্তু অনুমতি নেই।
N+1 সমস্যা কী? ১টি query-তে N সারি আনার পর প্রতিটির সম্পর্কিত ডেটার জন্য আলাদা query — মোট N+1; সমাধান join fetch বা entity graph.

৯ · AI-এর যুগে — AI-এর লেখা API যাচাই

AI-কে “Spring Boot দিয়ে product API বানাও” বললে কয়েক সেকেন্ডে চলার মতো কোড আসে। সমস্যা হলো, “চলে” আর “ঠিক” এক নয়। AI-এর লেখা API-তে বারবার যে ভুলগুলো দেখা যায়:

AI যা করেকেন সমস্যাআপনি যা করবেন
সব কিছুতে 200, body-তে "success": falseঅ্যাপ, cache, monitoring ভুল বোঝে201/204/400/404/409 — অংশ ২-এর টেবিল
entity সরাসরি ফেরত দেয় বা @RequestBody-তে নেয়গোপন field ফাঁস, অনুমতিহীন field বদলআলাদা request ও response record
ব্যবসার নিয়ম controller-এটেস্ট কঠিন, অন্য জায়গায় নিয়ম পুনরায় লিখতে হয়নিয়ম service-এ, controller পাতলা
@Valid নেই, বা annotation আছে কিন্তু validation starter নেইঋণাত্মক দাম, খালি নাম ডেটাবেসেstarter + @Valid + ভুল ইনপুটের টেস্ট
প্রতিটি method-এ try-catch, বা কোনো error handling নেইএলোমেলো error JSON, বা stack trace ফাঁসএকটি @RestControllerAdvice, ProblemDetail
findById(id).get()না পেলে NoSuchElementException → 500orElseThrow → 404
বানানো annotation বা পুরোনো javax.* importcompile হয় না; Spring Boot 3-এ jakarta.*প্রতিটি annotation অফিশিয়াল ডকুমেন্টে মিলিয়ে দেখুন

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

আমি Java 21, Spring Boot 3.x, Spring Data JPA আর PostgreSQL ব্যবহার করছি। একটি অনলাইন দোকানের Product API লিখে দাও: তালিকা (category ফিল্টার + pagination), id দিয়ে একটি, তৈরি, বদল, মোছা। শর্ত: ১. তিন স্তর: Controller (শুধু HTTP), Service (ব্যবসার নিয়ম, @Transactional), Repository (JpaRepository). controller-এ কোনো ব্যবসার নিয়ম রাখবে না। ২. entity কখনো বাইরে যাবে না; request আর response-এর জন্য আলাদা record DTO. costPrice response-এ থাকবে না। ৩. status code: তৈরি হলে 201 + Location header, মোছা হলে 204, না পেলে 404, ভুল ইনপুটে 400, একই নামের পণ্যে 409. ৪. Jakarta Bean Validation (@NotBlank, @Positive, বাংলাদেশি মোবাইলের জন্য @Pattern "^01[3-9]\\d{8}$") আর @Valid; কোন starter লাগবে, বলো। ৫. একটি @RestControllerAdvice, যা ProblemDetail (RFC 9457) ফেরত দেয়; stack trace বা SQL কখনো উত্তরে নয়। ৬. findById-এর পর .get() নয়, orElseThrow. ৭. শুধু আসল Spring annotation আর jakarta.* import; কোনটি কোন সংস্করণ থেকে আছে, অনিশ্চিত হলে বলো। ৮. শেষে @WebMvcTest আর MockMvc দিয়ে তিনটি টেস্ট: 404, ভুল ইনপুটে 400, সফল POST-এ 201 ও Location.

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

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

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

প্র ০১Roy Fielding ২০০০ সালের গবেষণাপত্রে REST-এর সংজ্ঞা দিয়েছিলেন। আজ যাকে “REST API” বলা হয়, সেটা কি আসলে Fielding-এর REST? HATEOAS কী, আর সেটা কেন প্রায় কেউ ব্যবহার করে না?

Roy Fielding ছিলেন ওয়েবের ভিত্তি গড়ার মানুষদের একজন — HTTP/1.1-এর মান লেখার প্রধান লেখকদের একজন, আর Apache HTTP Server প্রকল্পের সহ-প্রতিষ্ঠাতা। ২০০০ সালে University of California, Irvine-এ তাঁর PhD গবেষণাপত্র “Architectural Styles and the Design of Network-based Software Architectures”-এর পঞ্চম অধ্যায়ে তিনি REST-এর বর্ণনা দেন। এটা কোনো API বানানোর নির্দেশিকা ছিল না; ছিল ব্যাখ্যা — ওয়েব কোন নিয়মগুলো মেনে চলে বলে কোটি পাতা আর কোটি ব্যবহারকারী নিয়েও টিকে থাকে।

তাঁর REST কয়েকটি শর্তের সমষ্টি: client আর server আলাদা; প্রতিটি request stateless; উত্তর cache করা যায় কি না, তা স্পষ্ট; মাঝে proxy বা gateway-এর মতো স্তর থাকতে পারে; আর একটি “uniform interface”. শেষটির একটি অংশের নাম HATEOAS — Hypermedia As The Engine Of Application State. মানে, client আগে থেকে URL-এর তালিকা মুখস্থ রাখবে না; প্রতিটি উত্তরের ভেতরেই পরের সম্ভাব্য কাজের লিংক থাকবে, ঠিক যেমন একটি ওয়েব পাতায় লিংক থাকে। অর্ডারের JSON-এ থাকবে “বাতিল করুন” আর “পেমেন্ট করুন”-এর লিংক, আর পেমেন্ট হয়ে গেলে “পেমেন্ট”-এর লিংক আর থাকবে না।

বাস্তবে শিল্পজগৎ শব্দটি নিয়েছে, সবটুকু শর্ত নয়। ২০০০-এর দশকের মাঝামাঝি ভারী SOAP-এর বিরুদ্ধে “REST” হয়ে দাঁড়ায় একটি সহজ বিকল্পের নাম: সুন্দর URL, HTTP method, JSON. ২০০৮ সালে Fielding নিজের ব্লগে বিরক্তি নিয়ে লেখেন, hypertext ছাড়া কোনো API-কে REST বলা যায় না। একই সময়ে Leonard Richardson একটি “maturity model” প্রস্তাব করেন, যা পরে Martin Fowler-এর লেখায় জনপ্রিয় হয়: স্তর ০ — এক URL-এ সব (SOAP-এর মতো), স্তর ১ — আলাদা resource, স্তর ২ — সঠিক HTTP method আর status code, স্তর ৩ — hypermedia. আজকের বেশিরভাগ “REST API” স্তর ২-এ থামে; এই পর্বের API-ও তাই।

HATEOAS কেন চলেনি? ওয়েব ব্রাউজারের পেছনে থাকে একজন মানুষ, যিনি নতুন লিংক দেখে বুঝে নেন কী করতে হবে। কিন্তু একটি মোবাইল অ্যাপের স্ক্রিন আগে থেকেই নকশা করা — অ্যাপ নতুন লিংক দেখে নিজে থেকে নতুন বোতাম বানায় না। ফলে লিংক পাঠানোর খরচ আছে, লাভ কম। কিছু API আংশিকভাবে ব্যবহার করে (GitHub-এর API উত্তরে সম্পর্কিত resource-এর URL দেয়), আর Spring-এর Spring HATEOAS নামে একটি প্রকল্পও আছে।

মূল উপলব্ধি: একটি নাম জনপ্রিয় হলে তার অর্থ বদলে যায়। ইন্টারভিউতে “REST” বললে সাধারণত স্তর ২ বোঝায় — কিন্তু মূল ধারণা জানা থাকলে বুঝবেন, stateless আর cache-এর নিয়মগুলোই আসলে API-কে বড় হতে দেয়।

প্র ০২HTTP ১৯৯১ সালের এক লাইনের প্রোটোকল থেকে HTTP/3 পর্যন্ত এসেছে। প্রতিটি সংস্করণ কোন সমস্যার সমাধান করেছে, আর আপনার Spring controller-কে কেন এর কিছুই বদলাতে হয়নি?

Tim Berners-Lee CERN-এ যে প্রথম HTTP চালান (১৯৯১ সালের দিকে প্রকাশ্য বর্ণনা), তাতে client শুধু লিখত GET /page, আর সার্ভার একটি HTML পাতা পাঠিয়ে সংযোগ বন্ধ করত। কোনো header নেই, status code নেই, ছবি বা অন্য ফরম্যাটের কথাও নেই। পরে এর নাম হয় HTTP/0.9. ওয়েব দ্রুত বাড়ায় ব্রাউজার আর সার্ভার নিজেদের মতো বাড়তি জিনিস যোগ করতে থাকে; ১৯৯৬ সালে RFC 1945 সেই প্রচলিত রীতিগুলো লিখে রাখে — HTTP/1.0: header, status code, Content-Type, POST.

HTTP/1.0-তে প্রতিটি request-এর জন্য নতুন TCP সংযোগ — একটি পাতায় বিশটি ছবি মানে বিশবার সংযোগ খোলা-বন্ধ। ১৯৯৭ সালে RFC 2068 আর ১৯৯৯ সালে RFC 2616-এ HTTP/1.1 আনে স্থায়ী সংযোগ (একটি সংযোগে অনেক request), Host header (একটি সার্ভারে অনেক ওয়েবসাইট), chunked পাঠানো আর আরও ভালো caching. এই সংস্করণ প্রায় দুই দশক ওয়েব চালিয়েছে। কিন্তু একটি দুর্বলতা রয়ে যায়: এক সংযোগে উত্তর আসে পরপর; একটি ধীর উত্তর পেছনের সবাইকে আটকে রাখে (head-of-line blocking). ব্রাউজার তাই একটি সাইটে কয়েকটি সমান্তরাল সংযোগ খুলত।

Google ২০০৯ সালে SPDY নামে একটি পরীক্ষামূলক প্রোটোকল চালায়; তার ওপর ভিত্তি করে ২০১৫ সালে HTTP/2 (RFC 7540) আসে: লেখার বদলে binary frame, একটি সংযোগে অনেকগুলো স্রোত একসাথে (multiplexing), আর header সংকোচন। তবু নিচে TCP থাকায় একটি packet হারালে সব স্রোত অপেক্ষা করে। তাই Google-এর আরেকটি পরীক্ষা QUIC — UDP-র ওপর নতুন পরিবহন, যাতে এনক্রিপশন (TLS 1.3) ভেতরেই। IETF ২০২১ সালে QUIC (RFC 9000) আর ২০২২ সালে HTTP/3 (RFC 9114) মান করে। মোবাইলে এর একটি বড় সুবিধা: Wi-Fi থেকে মোবাইল ডেটায় গেলে সংযোগ নতুন করে শুরু করতে হয় না — দুর্বল নেটওয়ার্কের দেশে যা গুরুত্বপূর্ণ।

আর আপনার controller? ২০২২ সালেই RFC 9110 “HTTP Semantics” প্রকাশিত হয় — method, status code, header-এর অর্থ এক জায়গায়, তারের ওপর byte কীভাবে যায় তা থেকে আলাদা করে। GET, POST, 201, 404-এর অর্থ HTTP/1.1, 2 আর 3-এ একই। সংস্করণের পার্থক্য সামলায় Tomcat, load balancer বা CDN; @GetMapping জানতেও পারে না request কোন সংস্করণে এসেছে।

মূল উপলব্ধি: ভালো স্তরভিত্তিক নকশায় নিচের স্তর পুরো বদলে গেলেও ওপরের চুক্তি অটুট থাকে। HTTP-র অর্থ আর পরিবহন আলাদা ছিল বলেই ত্রিশ বছরের পুরোনো ধারণার ওপর আজকের দ্রুত ওয়েব চলছে।

প্র ০৩REST ছাড়াও GraphQL আর gRPC জনপ্রিয়। এগুলো কেন তৈরি হলো, আর একটি নতুন প্রজেক্টে কোনটি কখন বেছে নেবেন?

REST-এর দুটি পরিচিত সমস্যা আছে। বেশি আনা (over-fetching): অ্যাপের তালিকায় শুধু নাম আর দাম দরকার, কিন্তু endpoint পুরো পণ্যের বিশটি field পাঠায়। কম আনা (under-fetching): একটি স্ক্রিনে অর্ডার, তার পণ্য আর গ্রাহকের ঠিকানা দেখাতে তিন-চারটি request — ধীর মোবাইল নেটওয়ার্কে প্রতিটির অপেক্ষা যোগ হয়।

GraphQL এই সমস্যা থেকেই এসেছে। ২০১২ সালে Facebook তাদের মোবাইল অ্যাপের news feed-এর জন্য এটি ভেতরে তৈরি করে, ২০১৫ সালে প্রকাশ্যে আনে; পরে এটি Linux Foundation-এর অধীনে GraphQL Foundation-এ যায়। এখানে সাধারণত একটিই endpoint; client একটি query-তে ঠিক কোন field আর কোন সম্পর্কিত ডেটা চায়, তা লিখে পাঠায়, আর ঠিক সেটুকুই পায়। একটি schema পুরো API-র টাইপ বর্ণনা করে। দাম: সব query একই URL-এ যায় বলে সাধারণ HTTP cache কাজে লাগানো কঠিন; একজন client খুব গভীর বা ভারী query পাঠিয়ে সার্ভার বসিয়ে দিতে পারে, তাই query-র জটিলতার সীমা লাগে; আর সার্ভারে N+1 সমস্যা (অংশ ৭) আরও সহজে ঢোকে, যার জন্য batching কৌশল লাগে। Spring-এ এর জন্য Spring for GraphQL প্রকল্প আছে।

gRPC এসেছে Google থেকে, ২০১৫ সালে open source — তাদের ভেতরের Stubby নামের ব্যবস্থার উত্তরসূরি। এখানে আপনি পর্ব ৩৯-এর Protocol Buffers দিয়ে .proto ফাইলে service আর message লেখেন, আর টুল দুই দিকের কোড তৈরি করে দেয়; দূরের method ডাকা মনে হয় সাধারণ method call-এর মতো। নিচে HTTP/2, binary message, দুই দিকের streaming — দ্রুত আর কঠোর টাইপের। দাম: ব্রাউজার সরাসরি gRPC বলতে পারে না (gRPC-Web আর একটি proxy লাগে), message মানুষ পড়তে পারে না, curl দিয়ে চট করে পরীক্ষা করা যায় না।

সাধারণ নির্দেশিকা: বাইরের ডেভেলপার বা নানা ধরনের client-এর জন্য পাবলিক API — REST, কারণ সবাই চেনে, সব টুলে চলে, cache সহজ। অনেক রকম স্ক্রিনওয়ালা অ্যাপ, যেখানে প্রতিটি স্ক্রিনের ডেটার চাহিদা আলাদা — GraphQL ভাবার মতো। একই কোম্পানির ভেতরের microservice-গুলোর মধ্যে বিপুল সংখ্যক ছোট কল — gRPC. অনেক বড় সিস্টেম একসাথে তিনটিই ব্যবহার করে: বাইরে REST বা GraphQL, ভেতরে gRPC.

মূল উপলব্ধি: কোনো API-ধরন “সেরা” নয়; প্রতিটি একটি নির্দিষ্ট খরচ কমায় আর অন্য একটি খরচ বাড়ায়। প্রশ্ন হলো — আপনার client কারা, আর কোন খরচটি আপনার জন্য সবচেয়ে দামি।

প্র ০৪মোবাইল নেটওয়ার্কে “টাকা পাঠান” request-এর উত্তর হারিয়ে গেলে অ্যাপ আবার পাঠালে দুবার টাকা কাটতে পারে। পেমেন্ট API-গুলো “idempotency key” দিয়ে এই সমস্যা কীভাবে সমাধান করে?

দৃশ্যটি ভাবুন। একজন গ্রাহক বাসে বসে অ্যাপে ৫০০ টাকা পাঠাচ্ছেন। অ্যাপ POST /payments পাঠাল, সার্ভার টাকা কেটে লেনদেন সম্পন্ন করল — কিন্তু উত্তরটি ফেরার পথে নেটওয়ার্ক কেটে গেল। অ্যাপের কাছে এটা timeout; সে জানে না টাকা গেছে কি না। আবার চেষ্টা করলে হয়তো দ্বিতীয়বার ৫০০ টাকা কাটবে; না করলে হয়তো টাকা যায়ইনি। বিতরণকৃত সিস্টেমে এই অনিশ্চয়তা এড়ানো যায় না — নেটওয়ার্কে বার্তা হারাবেই।

RFC 9110 অনুযায়ী GET, PUT, DELETE idempotent — আবার পাঠানো নিরাপদ। কিন্তু POST নয়, আর পেমেন্ট স্বভাবতই POST: প্রতিটি নতুন লেনদেন। সমাধানটি হলো POST-কেও idempotent বানানো। Stripe-এর মতো পেমেন্ট কোম্পানি এটা জনপ্রিয় করেছে: client প্রতিটি যৌক্তিক কাজের জন্য একটি অনন্য key বানায় (সাধারণত একটি random UUID) আর Idempotency-Key header-এ পাঠায়। সার্ভার প্রথমবার কাজটি করে আর সেই key-এর সাথে ফলটি জমা রাখে। একই key-তে আবার request এলে কাজটি আবার করে না, জমানো ফলটিই ফেরত দেয়। Stripe-এর ডকুমেন্ট অনুযায়ী key কমপক্ষে ২৪ ঘণ্টা পর মুছে ফেলা হতে পারে। IETF-এ এই header মানক করার একটি খসড়া (draft) নিয়েও কাজ চলছে।

বাস্তবায়নে সূক্ষ্মতা আছে। (১) key-এর জন্য ডেটাবেসে unique constraint রাখুন, যাতে একই key-এর দুটি request প্রায় একসাথে এলে একটিই জেতে — অন্যটিকে 409 দিন বা প্রথমটি শেষ হওয়া পর্যন্ত অপেক্ষা করান। (২) একই key-এর সাথে আলাদা body এলে (প্রথমবার ৫০০, এবার ৫০০০) সেটা ভুল — প্রত্যাখ্যান করুন। (৩) key-টি নতুন করে বানাতে হবে প্রতিটি নতুন কাজে, প্রতিটি retry-তে নয়; অ্যাপ “পাঠান” বোতাম চাপার মুহূর্তে key বানিয়ে রাখে, আর retry-তে একই key পাঠায়। (৪) ফল জমা রাখা আর টাকা কাটা একই transaction-এ হওয়া উচিত।

এই ধারণা পেমেন্টের বাইরেও কাজে লাগে: message queue-তে একই বার্তা দুবার এলে, SMS পাঠানোর কাজ আবার চললে, অর্ডার জমা দেওয়ার বোতাম দুবার চাপলে। বিতরণকৃত সিস্টেমে “ঠিক একবার পৌঁছানো” প্রায় অসম্ভব; ব্যবহারিক পথ হলো “অন্তত একবার পৌঁছানো” আর পাশে idempotent গ্রহণকারী — ফল হয় কার্যত একবার। মোবাইল ব্যাংকিং-এর মতো যেকোনো সিস্টেমে, যেখানে কোটি মানুষ দুর্বল নেটওয়ার্কে টাকা পাঠান, এই নকশা-চিন্তা অপরিহার্য।

মূল উপলব্ধি: নেটওয়ার্ক কখনো বলে না “কাজ হয়নি” — শুধু বলে “জানি না”। তাই যে কাজ দুবার হলে ক্ষতি, তাকে এমনভাবে নকশা করুন যাতে আবার চেষ্টা সবসময় নিরাপদ হয়।

প্র ০৫পাবলিক API-তে কোন বদল “ভাঙা” (breaking), কোনটি নয়? /api/v1 ছাড়া versioning-এর আর কী উপায় আছে, আর বড় কোম্পানিগুলো পুরোনো client-কে কীভাবে বাঁচিয়ে রাখে?

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

সাধারণত ভাঙে না: নতুন endpoint যোগ, response-এ নতুন field যোগ (যদি client অজানা field উপেক্ষা করে — পর্ব ৩৯-এর “সহনশীল পাঠক”), request-এ নতুন ঐচ্ছিক field. ভাঙে: field-এর নাম বদল বা বাদ দেওয়া, টাইপ বদল (price number থেকে string), ঐচ্ছিক field-কে বাধ্যতামূলক করা, status code বা error ফরম্যাট বদল, enum-এ নতুন মান — পুরোনো অ্যাপের switch সেটা চেনে না। Google-এর প্রকৌশলী Hyrum Wright-এর নামে একটি পর্যবেক্ষণ আছে, “Hyrum's Law”: যথেষ্ট ব্যবহারকারী থাকলে আপনার সিস্টেমের প্রতিটি দেখা যায় এমন আচরণের ওপর কেউ না কেউ নির্ভর করবে — চুক্তিতে যা-ই লেখা থাকুক। এমনকি JSON-এ field-এর ক্রম বা error বার্তার ভাষাও।

versioning-এর প্রচলিত উপায়: URL-এ (/api/v1) — সবচেয়ে দৃশ্যমান আর সহজ, তাই সবচেয়ে প্রচলিত; header বা media type-এ (Accept: application/vnd.shop.v2+json) — URL পরিষ্কার থাকে, কিন্তু ব্রাউজারে বা curl-এ চট করে দেখা কঠিন; আর query parameter-এ। Stripe একটি ভিন্ন পথ নিয়েছে: তারিখভিত্তিক সংস্করণ। প্রতিটি অ্যাকাউন্ট যে তারিখের সংস্করণে শুরু করেছে, সেটাতেই আটকে থাকে, আর সার্ভারের ভেতরে ছোট ছোট রূপান্তর-মডিউল নতুন উত্তরকে পুরোনো আকারে বদলে দেয়। তাদের প্রকৌশল ব্লগে এর বর্ণনা আছে।

পুরোনো সংস্করণ বন্ধ করাও একটি প্রক্রিয়া। আগে থেকে ঘোষণা, উত্তরে সতর্কবার্তার header (Sunset header-এর জন্য RFC 8594 আছে), কোন client এখনো পুরোনো সংস্করণ ডাকছে তার পরিসংখ্যান, আর অ্যাপে “আপডেট করুন” বার্তা। হঠাৎ বন্ধের মূল্য বড় — ২০১৩ সালে Twitter তাদের পুরোনো API v1 বন্ধ করলে যেসব অ্যাপ সময়মতো বদলায়নি, সেগুলো কাজ করা বন্ধ করে দেয়।

মূল উপলব্ধি: API প্রকাশ করা মানে একটি প্রতিশ্রুতি দেওয়া, যা ভাঙার খরচ আপনি নন, আপনার ব্যবহারকারী দেন। তাই শুরুতেই DTO দিয়ে চুক্তিকে ভেতরের নকশা থেকে আলাদা রাখুন, আর শুধু যোগ করে বদলান।

অনুশীলন

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

  1. status code বেছে নিন: একটি বাস-টিকিটের API-তে প্রতিটির জন্য method আর status code বলুন — (ক) যাত্রী একটি আসন বুক করলেন; (খ) একই আসন আরেকজন একই সময়ে বুক করতে চাইলেন; (গ) লগইন না করে বুকিংয়ের তালিকা চাওয়া হলো; (ঘ) একজন যাত্রী অন্যের বুকিং বাতিল করতে চাইলেন; (ঙ) নিজের বুকিং বাতিল হলো।

    (ক) POST /api/v1/bookings → 201, সাথে Location: /api/v1/bookings/{id}. (খ) একই POST → 409 Conflict — ইনপুট ঠিক, কিন্তু আসনটি এখন আর খালি নেই। (গ) GET /api/v1/bookings → 401, কারণ কে চাইছেন জানা নেই। (ঘ) DELETE /api/v1/bookings/{id} → 403, কারণ পরিচয় জানা, কিন্তু অনুমতি নেই। কেউ কেউ এখানে 404 দেন, যাতে অন্যের বুকিং আছে কি না সেটাও ফাঁস না হয় — এটাও যুক্তিসংগত। (ঙ) DELETE → 204. বাতিল বুকিং মুছে না ফেলে অবস্থা বদলাতে চাইলে PATCH দিয়ে "status":"CANCELLED" করে 200 দেওয়াও প্রচলিত।

  2. যাচাই সরিয়ে দেখুন: অংশ ৪-এর চালানো যায় এমন প্রোগ্রামে controller-এর if (r.qty() <= 0) লাইনটি মুছুন, আর তালিকার শেষে new OrderRequest(1, -2) যোগ করুন। না চালিয়ে বলুন দ্বিতীয় আর শেষ লাইন কী হবে। তারপর চালিয়ে মিলিয়ে নিন। ভুলটি কোন স্তরে ধরা উচিত ছিল?

    দ্বিতীয় লাইন (qty=0) এখন 201 Created, stock left 3 — শূন্য পণ্যের একটি “সফল” অর্ডার। শেষ লাইন আরও খারাপ: তার আগে চা-পাতার স্টক ৩ (চতুর্থটি ৪টি চেয়ে 409 পেয়েছে), আর 3 < -2 মিথ্যা বলে স্টকের যাচাই পার হয়ে যায়; নতুন স্টক 3 - (-2) = 5. ফল 201 Created, stock left 5 — অর্ডার দিয়ে স্টক বাড়ানো! আকারের যাচাই (“পরিমাণ ধনাত্মক”) controller-এ (বাস্তবে @Positive + @Valid) হওয়া উচিত। তবে service-এরও নিজের পাহারা রাখা ভালো — অন্য কোনো পথ (batch job, অন্য controller) থেকেও service ডাকা হতে পারে।

  3. URL নকশা করুন: একটি কোচিং সেন্টারের API-তে আছে কোর্স, প্রতিটি কোর্সে কয়েকটি ব্যাচ, আর শিক্ষার্থীরা ব্যাচে ভর্তি হন। এই কাজগুলোর endpoint লিখুন: সব কোর্স (বিষয় দিয়ে ছাঁকা, পাতা ধরে); একটি কোর্সের সব ব্যাচ; একটি ব্যাচে ভর্তি; একজন শিক্ষার্থীর সব ভর্তি; একটি ভর্তি বাতিল।

    একটি সম্ভাব্য নকশা: GET /api/v1/courses?subject=physics&page=0&size=20; GET /api/v1/courses/{courseId}/batches; POST /api/v1/batches/{batchId}/enrollments (body-তে শিক্ষার্থীর id; সফল হলে 201, আসন শেষ হলে 409); GET /api/v1/students/{studentId}/enrollments; DELETE /api/v1/enrollments/{enrollmentId} → 204. খেয়াল করুন, ভর্তি নিজেই একটি resource (enrollments) — “enroll” ক্রিয়াটি URL-এ নেই, সেটা POST বোঝায়। আর nesting এক স্তরে থেমেছে: /courses/1/batches/3/enrollments/9-এর বদলে ভর্তির নিজের id দিয়ে সরাসরি /enrollments/9.

  4. নিজে লিখুন — update: অংশ ৫-এর controller যে service.update(id, req) ডাকে, সেই service method আর UpdateProductRequest record লিখুন। শর্ত: পণ্য না থাকলে 404; নতুন নাম অন্য কোনো পণ্যের নামের সাথে মিললে 409; কেনা দাম এই endpoint দিয়ে বদলানো যাবে না।

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

    public record UpdateProductRequest(
            @NotBlank String name,
            @NotBlank String category,
            @NotNull @Positive BigDecimal price,
            @PositiveOrZero int stock) {}          // costPrice নেই — তাই বদলানোর পথও নেই
    
    // ProductRepository-তে: boolean existsByNameAndIdNot(String name, Long id);
    
    @Transactional
    public ProductResponse update(long id, UpdateProductRequest req) {
        Product p = repo.findById(id)
                        .orElseThrow(() -> new ProductNotFoundException(id));      // 404
        if (repo.existsByNameAndIdNot(req.name(), id))
            throw new DuplicateProductException(req.name());                      // 409
        p.setName(req.name());
        p.setCategory(req.category());
        p.setPrice(req.price());
        p.setStock(req.stock());
        return ProductResponse.from(p);      // save() না ডাকলেও transaction শেষে বদল লেখা হয়
    }

    লক্ষ করুন: existsByNameAndIdNot একটি derived query — “এই নাম আছে, কিন্তু এই id-এর নয়”; নিজের নাম না বদলালে 409 হবে না। transaction-এর ভেতরে আনা entity JPA “নজরে রাখে”, তাই setter দিয়ে বদলালে transaction শেষে Hibernate নিজেই UPDATE চালায় (dirty checking). advice-এ DuplicateProductException-এর জন্য একটি 409 handler যোগ করতে ভুলবেন না। আর দুই অনুরোধ একসাথে একই নাম নিতে চাইলে শেষ পাহারা ডেটাবেসের unique constraint.

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

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

ক্রমSpring Boot অ্যাপে একটি POST request এলে ঘটনাগুলো সঠিক ক্রমে সাজান (▲▼ চেপে সরান)

  1. 🚪 embedded Tomcat request নিয়ে DispatcherServlet-এ দেয়
  2. 🗺️ handler mapping method আর পথ মিলিয়ে controller method খুঁজে পায়
  3. 🔄 Jackson JSON body থেকে record বানায়, @Valid যাচাই চালায়
  4. ⚙️ service ব্যবসার নিয়ম দেখে, repository ডেটাবেসে লেখে
  5. 📦 entity থেকে DTO বানিয়ে ResponseEntity-তে 201 দেওয়া হয়
  6. 📨 Jackson DTO-কে JSON বানায়, উত্তর client-এ যায়

একটি REST API-তে POST request সফলভাবে নতুন একটি resource তৈরি করলে কোন status code সবচেয়ে উপযুক্ত?

  • 200 OK
  • 201 Created
  • 204 No Content
  • 302 Found

HTTP-র কোন method idempotent, কিন্তু safe নয়?

  • GET
  • POST
  • PUT
  • PATCH

Spring MVC-তে @RestController annotation কোন দুটির সমন্বয়?

  • @Controller আর @ResponseBody
  • @Service আর @RequestBody
  • @Component আর @RequestMapping
  • @Repository আর @ResponseStatus

Spring Data JPA-তে interface ProductRepository extends JpaRepository<Product, Long> থাকলে repo.findById(7L) কী ফেরত দেয়?

  • Product, না পেলে null
  • Product, না পেলে EntityNotFoundException
  • List<Product>
  • Optional<Product>

Spring Boot-এ @Valid @RequestBody লেখা parameter-এর যাচাই ব্যর্থ হলে কী ঘটে?

  • controller method চলে, ভুল field-গুলো null পায়
  • MethodArgumentNotValidException, ডিফল্টে 400
  • NullPointerException, 500
  • compile error হয়

একজন ব্যবহারকারী সঠিক token দিয়ে লগইন করা অবস্থায় শুধু অ্যাডমিনের জন্য রাখা একটি endpoint ডাকলেন। কোন status code উপযুক্ত?

  • 400 Bad Request
  • 401 Unauthorized
  • 403 Forbidden
  • 409 Conflict

শব্দকোষ

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

মনে রাখুন

  1. HTTP request = method + URL + header + body; response = status code + header + body. JSON হলে Content-Type: application/json.
  2. REST: URL-এ বহুবচন বিশেষ্য (/api/v1/products/7), কাজ method-এ, ছাঁকা ও পাতা query-তে। GET পড়া, POST তৈরি → 201 + Location, PUT বদল, DELETE → 204; না পেলে 404, ভুল ইনপুটে 400, সংঘাতে 409. GET, PUT, DELETE idempotent; POST নয়।
  3. তিন স্তর: Controller (HTTP) → Service (নিয়ম, @Transactional) → Repository (ডেটা)। বাইরে শুধু DTO record, entity কখনো নয়।
  4. Spring MVC: @RestController, @GetMapping…, @PathVariable, @RequestParam, @RequestBody, ResponseEntity; @Valid ব্যর্থ হলে MethodArgumentNotValidException → @RestControllerAdvice → 400 ProblemDetail (RFC 9457).
  5. Spring Data JPA: JpaRepository<Product, Long>, derived query, findById → Optional → orElseThrow → 404; Pageable দিয়ে pagination; lazy সম্পর্কে N+1-এর দিকে চোখ রাখুন।

পরের পর্বে: এই পর্বে JPA-র ঝলক দেখেছেন; পরের পর্বে তার গভীরে — Spring Data JPA আর Hibernate: entity-র জীবনচক্র, relationship, N+1 সমস্যা আর transaction-এর সীমা।

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

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