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

ক্যাপস্টোন — “দোকানদার”: একটা আসল Java সিস্টেম

Capstone — Building “Dokandar”, an Inventory and Order Backend in Java with Ports and Adapters

Taught in Bangla, this project lesson builds Dokandar, an inventory and order backend for a neighbourhood shop: scoping with user stories, a domain model using Money, enums, records and sealed results, hexagonal ports and adapters, an order service with compensation and a state machine, JDBC and Spring REST adapters, concurrency for the last item, deployment and a portfolio.

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

  • user story আর “কী বানাব না” দিয়ে একটি সিস্টেমের পরিধি ঠিক করা
  • record, enum, sealed interface আর value object দিয়ে এমন domain model, যেখানে অবৈধ অবস্থা তৈরিই কঠিন
  • port আর adapter দিয়ে core-কে database, web framework আর SMS কোম্পানি থেকে আলাদা রাখা
  • অর্ডারের পুরো পথ: যাচাই → stock আটকানো → পেমেন্ট → নিশ্চিত বা ফেরত; আর state machine দিয়ে বাতিলের নিয়ম
  • race condition, মানের চেকলিস্ট, deployment আর প্রজেক্টটি portfolio-তে তুলে ধরা

১ · সমস্যা আর চাহিদা — “দোকানদার” কী করবে, কী করবে না

পর্ব ২২-এর মাঝপথের প্রজেক্টে একজন মানুষের জন্য একটি CLI টুল বানিয়েছিলেন। এবার পরিধি বড়: একটি ছোট ব্যবসা, অনেক গ্রাহক, টাকার লেনদেন, আর বাইরের কয়েকটি সেবা (পেমেন্ট, SMS). এখানে প্রশ্নগুলোও বদলে যায়। “কোডটা চলে কি না” যথেষ্ট নয়। জানতে হবে, পেমেন্ট মাঝপথে ব্যর্থ হলে কী হবে? দুজন একসাথে শেষ পিসটি কিনলে কী হবে? আগামী বছর database বদলাতে হলে কতটা কোড ভাঙবে?

মিরপুরের রহিম ভাইয়ের মুদি দোকান। চাল, ডাল, তেল, চা-পাতা। আগে সব হিসাব ছিল খাতায়। এখন অনেক গ্রাহক ফোনে বা ফেসবুক পেজে অর্ডার দেন, কেউ bKash-এ টাকা পাঠান, কেউ Nagad-এ, কেউ দোকানে এসে নগদ দেন। সমস্যা তিনটি: কোন মাল কতটা আছে, তা ঠিক জানা যায় না; টাকা এসেছে কি না মেলাতে সন্ধ্যা পার হয়ে যায়; আর গ্রাহক জানতে চান অর্ডারটা হলো কি না। “দোকানদার” হবে এই দোকানের backend — যে সফটওয়্যার পেছনে বসে নিয়ম মেনে হিসাব রাখে, আর মোবাইল অ্যাপ বা ওয়েবসাইট তার সাথে কথা বলে।

User story — কার, কী, কেন

চাহিদা লেখার একটি জনপ্রিয় ছক হলো user story: “একজন [ভূমিকা] হিসেবে আমি [কাজ] চাই, যাতে [লাভ]।” প্রতিটি story-র পাশে একটি গ্রহণযোগ্যতার শর্ত থাকে — যা পরে সরাসরি টেস্ট হয়ে যায়।

#User storyগ্রহণযোগ্যতার শর্ত
১গ্রাহক হিসেবে কয়েকটি পণ্য একসাথে অর্ডার দিতে চাই, যাতে একবারেই বাজার হয়stock না থাকলে অর্ডার হবে না, আর কোনো পণ্যের stock বদলাবে না
২গ্রাহক হিসেবে bKash, Nagad বা নগদে টাকা দিতে চাইপেমেন্ট ব্যর্থ হলে আটকে রাখা মাল আবার বিক্রির জন্য ফেরত যাবে
৩গ্রাহক হিসেবে অর্ডার নিশ্চিত হলে SMS পেতে চাইSMS বাংলায়, অর্ডার নম্বর আর মোট টাকাসহ
৪দোকানদার হিসেবে ডেলিভারির আগে অর্ডার বাতিল করতে চাইবাতিলে stock ফেরত; ডেলিভারি হয়ে গেলে বাতিল নিষেধ
৫দোকানদার হিসেবে দিনের শেষে পেমেন্টের ধরন অনুযায়ী বিক্রির হিসাব চাইবাতিল অর্ডার হিসাবে আসবে না; VAT-সহ মোট দেখাবে

কী বানাব না — non-goal

ক্যাপস্টোনের সবচেয়ে বড় বিপদ হলো সব কিছু বানানোর লোভ। তাই সচেতনভাবে বাদ: একাধিক শাখা, বাকির খাতা, ছাড়/কুপন, পণ্য ফেরত, ডেলিভারি ট্র্যাকিং, আর কোনো frontend. পেমেন্ট আর SMS কোম্পানির আসল API-র খুঁটিনাটিও এই পর্বে নেই — তাদের জায়গায় থাকবে একটি interface, যার পেছনে আসল বা নকল যেকোনো সেবা বসানো যায়। এগুলোই পরে “বাড়ানোর ধারণা” হবে (অংশ ৯)।

মূল ধারণা

একটি সিস্টেমের নকশা শুরু হয় তিনটি লেখা দিয়ে: কে কী চায় (user story), কখন কাজটা “হয়েছে” বলব (গ্রহণযোগ্যতার শর্ত), আর কী বানাব না (non-goal). এই তিনটি ছাড়া কোড লিখলে প্রতিটি সিদ্ধান্ত হয় অনুমানে।

২ · Domain model — Money, enum, record আর sealed result

পর্ব ০২-এ শিখেছিলেন, সমস্যার দুনিয়ার ধারণাগুলো দিয়েই class বানাতে হয় — একে বলে domain model. দোকানের ভাষায় কথা বলুন: পণ্য, মজুত, অর্ডার, অর্ডারের লাইন, পেমেন্ট, রসিদ। Eric Evans এই অভ্যাসের নাম দিয়েছিলেন ubiquitous language — দোকানদার, প্রোগ্রামার আর কোড, তিনজনই একই শব্দ ব্যবহার করবে (ভাবনার প্রশ্ন ২)।

দোকানের শব্দকোডেJava-র কোন হাতিয়ার, কেন
টাকা-পয়সাMoneyrecord, ভেতরে long পয়সা — double-এর দশমিক গরমিল নেই (পর্ব ০৭)
পণ্যProductrecord — SKU কোড আর দাম
অর্ডারের লাইন (২ কেজি চাল)Linerecord, compact constructor-এ পরিমাণ যাচাই (পর্ব ২৫)
পেমেন্টের মাধ্যমPayMethodenum: BKASH, NAGAD, CASH (পর্ব ২৪)
অর্ডারের অবস্থাOrderStatusenum, ভেতরে কোন বদল বৈধ তার নিয়ম — state machine
পেমেন্টের ফলPaymentResultsealed interface: Paid বা Declined — তৃতীয় কিছু নয় (পর্ব ২৬)

টাকা কেন পয়সায়

টাকা double-এ রাখলে 0.1 + 0.2 হয় 0.30000000000000004 — হাজার লেনদেনে এই ক্ষুদ্র গরমিল জমে হিসাব মেলে না। দুটি নিরাপদ পথ: পুরো হিসাব পয়সায় long-এ রাখা, অথবা BigDecimal. আমরা প্রথমটি নিচ্ছি, কারণ টাকার ক্ষুদ্রতম একক পয়সা, আর long দ্রুত ও সহজ। Money একটি value object: দুটি ৫০০ টাকা একই জিনিস, তার আলাদা পরিচয় নেই, আর একবার তৈরি হলে বদলায় না। যোগ করলে নতুন Money আসে।

record Money(long paisa) {
    static final Money ZERO = new Money(0);
    Money { if (paisa < 0) throw new IllegalArgumentException("negative money"); }
    static Money tk(long taka) { return new Money(taka * 100); }
    Money plus(Money o)      { return new Money(paisa + o.paisa); }
    Money times(int n)       { return new Money(paisa * n); }
    Money percent(int p)     { return new Money((paisa * p + 50) / 100); }  // half-up
}

percent-এর + 50 হলো অর্ধেক বা তার বেশি হলে ওপরে তোলার (half-up) কৌশল: ১০ পয়সার ১৫% হলো ১.৫ পয়সা, যা হবে ২ পয়সা। VAT-এর হার ধরা হয়েছে ১৫%, যা বাংলাদেশে VAT-এর সাধারণ হার; তবে অনেক পণ্যে কম হার বা অব্যাহতি আছে, তাই আসল সিস্টেমে হার আসবে পণ্য অনুযায়ী কনফিগারেশন থেকে, কোডে বসানো সংখ্যা থেকে নয়।

Exception নাকি result — কোন ভুল কীভাবে জানাব

পেমেন্ট ব্যর্থ হওয়া কোনো প্রোগ্রামিং ভুল নয় — গ্রাহকের ব্যালান্স কম থাকতেই পারে, এটা ব্যবসার স্বাভাবিক একটি ফল। তাই তাকে exception না বানিয়ে ফেরত-মান বানানো হয়েছে: sealed interface PaymentResult, যার ঠিক দুটি রূপ। এটি অন্য ভাষার result type-এর Java রূপ।

sealed interface PaymentResult permits Paid, Declined {}
record Paid(String txId) implements PaymentResult {}
record Declined(String reason) implements PaymentResult {}

সুবিধাটা ধরা পড়ে switch expression-এ। Compiler জানে PaymentResult-এর আর কোনো রূপ নেই, তাই default ছাড়াই switch সম্পূর্ণ (পর্ব ২৬-এর exhaustiveness). কাল যদি কেউ তৃতীয় রূপ Pending যোগ করেন, যত switch তাকে সামলায় না, সবগুলোতে Java 21 compile error দেয় — Wandbox-এ যাচাই করা বার্তা:

prog.java:71: error: the switch expression does not cover all possible input values
        return switch (gateway.charge(method, phone, total)) {     // 3. charge
               ^
1 error

অন্যদিকে, ডেলিভারি হয়ে যাওয়া অর্ডার বাতিলের চেষ্টা হলো চুক্তি ভাঙা — যে কোড এটা করছে, সে নিয়ম মানেনি। এখানে IllegalStateException ছোড়াই ঠিক। নিয়মটা সহজ: প্রত্যাশিত ব্যবসায়িক ফল → result; নিয়ম ভাঙা বা অসম্ভব অবস্থা → exception.

৩ · স্থাপত্য — core, port আর adapter

সবচেয়ে সহজ পথ হলো Spring controller থেকে সরাসরি SQL চালানো, আর একই method-এ bKash-কে HTTP request পাঠানো। ছোট ডেমোতে এটা চলে। কিন্তু তখন অর্ডারের নিয়ম টেস্ট করতে database আর ইন্টারনেট লাগে, আর SMS কোম্পানি বদলালে ব্যবসার নিয়মের কোডে হাত দিতে হয়। প্রচলিত Controller → Service → Repository ভাগ, অর্থাৎ layered architecture, এ থেকে এক ধাপ এগিয়ে; কিন্তু তাতেও service প্রায়ই database-এর class সরাসরি চেনে। এর পূর্ণ সমাধান hexagonal architecture, যার আরেক নাম ports and adapters.

  • Core (domain): Money, Order, OrderService — শুধু JDK. কোনো Spring, JDBC বা HTTP import নেই।
  • Port: core যা চায়, তার interface — ProductRepository, OrderRepository, PaymentGateway, SmsSender, আর সময়ের জন্য JDK-র নিজের Clock.
  • Adapter: port-এর বাস্তব রূপ — টেস্টে in-memory আর নকল, production-এ JDBC (পর্ব ৪৯), bKash/Nagad API, SMS API; আর সামনে Spring REST controller (পর্ব ৫১), যে বাইরের অনুরোধকে core-এর ডাকে রূপ দেয়।
   চালায় (driving)                    CORE                         চালিত (driven)
┌──────────────────┐        ┌───────────────────────────┐        ┌──────────────────────┐
│ OrderController  │──ডাকে──▶│ OrderService              │        │ JdbcProductRepository│
│ (Spring REST)    │        │  Money · Order · Line     │◀─মানে──│ BkashGateway         │
│ CLI / JUnit test │        │  OrderStatus · results    │        │ SmsApiSender         │
└──────────────────┘        │ «port» ProductRepository  │        │ InMemory* / Fake*    │
                            │ «port» PaymentGateway ... │        └──────────────────────┘
                            └───────────────────────────┘
     দুই দিকের সব source-code dependency ভেতরের দিকে: core কাউকে চেনে না

এটাই dependency rule: dependency সবসময় বাইরে থেকে ভেতরে। JdbcProductRepository core-এর interface implements করে, তাই সে core-কে চেনে; core তাকে চেনে না। পর্ব ২১-এর dependency inversion এখানে পুরো সিস্টেমের মাপে কাজ করছে। কোন অংশ কাকে চেনে, ধাপে ধাপে দেখুন:

OrderControllerSpring REST · পর্ব ৫১
CLIদোকানের কম্পিউটারে
JUnit testপর্ব ৪৭
OrderServiceঅর্ডারের নিয়ম
Money · Order · OrderStatusdomain
«port» ProductRepositoryinterface
«port» PaymentGateway · SmsSenderinterface
JdbcProductRepositoryপর্ব ৪৯
BkashGateway · SmsApiSenderHTTP client
InMemoryProducts · FakeWalletsটেস্ট ও ডেমো
  1. শুধু core. প্রথমে শুধু ব্যবসার নিয়ম: অর্ডারের মোট, VAT, state বদলের নিয়ম। এই অংশের import-এ শুধু java.time আর java.util. এই কোড ২০ বছর পরেও একই থাকতে পারে, framework বদলালেও।
  2. Port — core কী চায়, তার চুক্তি। Core-এর stock জানা আর পেমেন্ট নেওয়া দরকার, কিন্তু কীভাবে, তা সে জানতে চায় না। তাই সে নিজের ভাষায় interface লেখে: reserve(sku, qty), charge(method, phone, amount). লক্ষ করুন, interface-টি core-এর ভেতরে থাকে — বাইরের লাইব্রেরিতে নয়।
  3. চালিত adapter (ডানে)। JdbcProductRepository port-টি implements করে। তীরটা দেখায় ডাক যায় core থেকে ডানে, কিন্তু source code-এর dependency উল্টো — adapter core-এর interface import করে। Database বদলালে শুধু এই একটি class বদলায়।
  4. চালনাকারী adapter (বাঁয়ে)। REST controller HTTP-র JSON পড়ে, যাচাই করে, OrderService.place ডাকে, আর ফলকে HTTP status-এ রূপ দেয়। ব্যবসার কোনো নিয়ম এখানে নেই — controller শুধু অনুবাদক।
  5. Adapter বদলানো — টেস্টের সুবিধা। JUnit টেস্ট core-কে ডাকে in-memory repository আর নকল wallet দিয়ে। কোনো database, নেটওয়ার্ক বা Spring নেই, তাই হাজারটা টেস্ট কয়েক সেকেন্ডে চলে। একই core, ভিন্ন adapter — এটাই পুরো নকশার উদ্দেশ্য।
C/C++/Python জানলে

আমাদের Python ক্যাপস্টোনেও টাকা পয়সায় রাখা হয়েছে আর storage একটি Protocol-এর পেছনে লুকানো — Java-র interface-এর কাজটাই সেখানে duck typing আর Protocol করে। C++ ক্যাপস্টোনে ব্যর্থতা ফেরত আসে std::expected দিয়ে, যা আমাদের sealed PaymentResult-এর কাছাকাছি ধারণা। পার্থক্য হলো, Java-র sealed type আর pattern matching switch-এ compiler নিজেই দেখে কোনো রূপ বাদ পড়ল কি না।

৪ · OrderService — অর্ডারের যাত্রা, compensation আর state machine

সিস্টেমের হৃদয় একটি domain service: OrderService. তার place method চারটি কাজ করে: (১) প্রতিটি লাইনের পণ্য আছে কি না দেখে stock আটকায় (reserve); (২) VAT-সহ মোট হিসাব করে; (৩) পেমেন্ট নেয়; (৪) সফল হলে অর্ডার নিশ্চিত করে SMS পাঠায়, ব্যর্থ হলে আটকানো stock ফেরত দেয়।

পেমেন্ট কীভাবে নেওয়া হবে — bKash, Nagad না নগদ — তা OrderService জানে না। সে শুধু PaymentGateway interface-এর charge ডাকে, আর পেছনে যে adapter বসানো আছে, সে কাজ করে। একটি কাজের অনেক বদলযোগ্য কৌশল একটি interface-এর পেছনে রাখা — এটাই strategy pattern (পর্ব ৪৮)।

যখন পেমেন্ট ব্যর্থ — compensation

Stock আটকানো আর পেমেন্ট নেওয়া দুটি আলাদা সিস্টেমে ঘটে: stock আমাদের database-এ, টাকা bKash-এর সার্ভারে। দুটিকে এক database transaction-এ বাঁধা যায় না। তাই পেমেন্ট ব্যর্থ হলে আগের কাজটি উল্টাতে একটি পাল্টা কাজ চালাতে হয় — আটকানো stock ফেরত। একে বলে compensating action, আর এভাবে ধাপে ধাপে কাজ করে ব্যর্থতায় উল্টো পথে ফেরার নকশাকে বলে saga (ভাবনার প্রশ্ন ৩)। করিম সাহেব Nagad-এ ৩ কেজি ডাল আর ১ প্যাকেট চা অর্ডার দিলেন, কিন্তু ব্যালান্স মাত্র ৫০০ টাকা — দেখুন কী ঘটে:

1for (Line l : lines) {2    if (!products.reserve(l.sku(), l.qty())) { releaseAll(reserved); return new Rejected(...); }3    reserved.add(l); subtotal = subtotal.plus(price × qty);4}5Money total = subtotal.plus(subtotal.percent(15));6switch (gateway.charge(method, phone, total)) {7    case Paid ok      -> { save(PAID); sms.send(...); }8    case Declined no  -> { releaseAll(reserved); save(CANCELLED); }9}
অর্ডার #২ · Nagad · ব্যালান্স ৫০০ টাকা
stock DAL
stock TEA
reserved
টাকা
status
  1. শুরু। দুটি লাইন: ৩ কেজি ডাল (প্রতি কেজি ১৩৫ টাকা) আর ১ প্যাকেট চা (২৬০ টাকা)। দোকানে ডাল ৮, চা মাত্র ১টি। reserved তালিকা খালি — এখানে লেখা থাকবে কী কী আটকানো হলো, যাতে দরকারে ফেরত দেওয়া যায়।
  2. প্রথম লাইন আটকানো। reserve("DAL", 3) সফল, stock ৫. লাইনটি reserved-এ গেল, subtotal ৪০৫ টাকা। Stock এখনই কমানো হলো কেন, পেমেন্টের পরে কেন নয়? কারণ পেমেন্ট চলার কয়েক সেকেন্ডে অন্য কেউ একই মাল কিনে ফেলতে পারতেন।
  3. দ্বিতীয় লাইন। চায়ের শেষ পিসটিও আটকানো হলো। এখানে যদি stock কম পড়ত, তাহলে releaseAll(reserved) আগের ডাল ফেরত দিত — “সব অথবা কিছুই না”, user story ১-এর শর্ত।
  4. মোট আর VAT. ৬৬৫ টাকার ১৫% = ৯৯.৭৫, মোট ৭৬৪.৭৫. পয়সায় হিসাব বলে কোনো দশমিক গরমিল নেই। অর্ডার তৈরি হলো PENDING অবস্থায়।
  5. পেমেন্ট ব্যর্থ। Gateway Declined ফেরত দিল। কোনো exception নয় — switch-এর একটি শাখা। এই মুহূর্তে চায়ের stock ০, অথচ কেউ কেনেননি; এখানে থামলে দোকান মাল থাকতেও বিক্রি করতে পারত না।
  6. Compensation. releaseAll(reserved) ডাল আর চা ফেরত দিল; অর্ডার CANCELLED হয়ে খাতায় রইল — কেন ব্যর্থ, তার প্রমাণ হিসেবে। পরের গ্রাহক শেষ চায়ের প্যাকেটটি কিনতে পারবেন। অংশ ৫-এর প্রোগ্রামে ঠিক এটাই ঘটে।

অর্ডারের state machine

একটি অর্ডার চারটি অবস্থার একটিতে থাকে: PENDING (stock আটকানো, টাকার অপেক্ষা), PAID, DELIVERED, CANCELLED. কোন অবস্থা থেকে কোথায় যাওয়া যায়, সেই নিয়ম enum-এর ভেতরেই — এটাই state machine:

enum OrderStatus {
    PENDING, PAID, DELIVERED, CANCELLED;
    boolean canMoveTo(OrderStatus n) {
        return switch (this) {
            case PENDING -> n == PAID || n == CANCELLED;
            case PAID -> n == DELIVERED || n == CANCELLED;
            case DELIVERED, CANCELLED -> false;          // শেষ অবস্থা
        };
    }
}

Order record-এর moveTo(next) প্রথমে canMoveTo জিজ্ঞেস করে; উত্তর না হলে IllegalStateException, হ্যাঁ হলে নতুন অবস্থার একটি নতুন Order — পুরোনোটি বদলায় না। নিয়ম এক জায়গায় থাকায় controller, CLI বা টেস্ট — কেউ ভুল করে ডেলিভারি হওয়া অর্ডার বাতিল করতে পারবে না।

  1. PENDINGreserveটাকার অপেক্ষা
  2. PAIDPaidটাকা পাওয়া গেছে
  3. DELIVEREDdeliverশেষ অবস্থা
  4. CANCELLEDcancelশেষ অবস্থা
ধাপ ১নতুন অর্ডার
new Order(..., PENDING, ...)   // stock আটকানো, পেমেন্ট চলছে
ধাপ ২PENDING → PAID
PENDING.canMoveTo(PAID)  →  true
order.moveTo(PAID)       // নতুন Order, পুরোনোটি অক্ষত
ধাপ ৩PAID → DELIVERED
PAID.canMoveTo(DELIVERED) → true
shop.deliver(3)           // অর্ডার #৩ গ্রাহকের হাতে
ধাপ ৪দুটি বৈধ বাতিল
PENDING.canMoveTo(CANCELLED) → true   // পেমেন্ট ব্যর্থ
PAID.canMoveTo(CANCELLED)    → true   // গ্রাহক বাতিল করলেন
দুই ক্ষেত্রেই stock ফেরত
ধাপ ৫নিষিদ্ধ বদল
shop.cancel(3)   // #৩ আগেই DELIVERED
IllegalStateException: DELIVERED -> CANCELLED
CANCELLED.canMoveTo(PAID) → false
  1. জন্ম PENDING-এ। Stock আটকানো সফল হলেই অর্ডার তৈরি হয়, কিন্তু টাকা এখনো আসেনি। এই অবস্থা খুব অল্প সময়ের — পেমেন্টের উত্তর পর্যন্ত।
  2. টাকা এলো। Paid ফল পেলে moveTo(PAID). record immutable, তাই “বদল” মানে নতুন object — পুরোনো অবস্থা কোথাও চুপচাপ বদলে যায় না, যা পর্ব ৪০-এর thread-এর দুনিয়ায় বড় স্বস্তি।
  3. ডেলিভারি। রাইডার মাল পৌঁছালে দোকানদার deliver চাপেন। DELIVERED থেকে আর কোথাও যাওয়ার পথ নেই — মাল ফেরতের ব্যবস্থা আমাদের non-goal.
  4. বাতিলের দুই পথ। পেমেন্ট ব্যর্থ হলে PENDING → CANCELLED; আর টাকা দেওয়ার পরে গ্রাহক মত বদলালে PAID → CANCELLED. দুই ক্ষেত্রেই releaseAll stock ফেরত দেয়। PAID-এর ক্ষেত্রে টাকাও ফেরত দিতে হবে — সেটি অনুশীলন ১.
  5. নিষিদ্ধ বদল — exception. ডেলিভারি হওয়া অর্ডার বাতিল বা বাতিল অর্ডারে টাকা নেওয়া অর্থহীন। এগুলো ব্যবসার স্বাভাবিক ফল নয়, কোডের ভুল; তাই exception, আর REST adapter একে HTTP 409 Conflict-এ রূপ দেবে (অংশ ৬)। ষোলোটি সম্ভাব্য বদলের মধ্যে মাত্র চারটি বৈধ — অংশ ৫-এর টেস্ট সেটাই প্রমাণ করে।

৫ · এক ফাইলে পুরো core — চালিয়ে দেখুন, তারপর প্রমাণ করুন

এবার পুরো core এক ফাইলে: domain, port, OrderService, আর তিনটি সহজ adapter — TreeMap-ভিত্তিক দুটি in-memory repository আর একটি fake wallet, যেখানে দুটি নম্বরের ব্যালান্স আগে থেকে বসানো। SMS যায় একটি তালিকায় (outbox), আর সময় আসে Clock.fixed থেকে, তাই প্রতিবার একই আউটপুট। নিচে একটি লিখিত দৃশ্য চলে: পাঁচটি অর্ডারের চেষ্টা, একটি ডেলিভারি, একটি বাতিল, একটি নিষিদ্ধ বাতিল, তারপর দিনের রিপোর্ট।

ক্যাপস্টোন বলে কোডটি লম্বা — প্রায় দেড়শো লাইন। ঘাবড়াবেন না: প্রতিটি অংশ আগের অংশগুলোতে আলাদা করে দেখেছেন, আর মন্তব্যের ===== দাগগুলো hexagonal-এর স্তর চিনিয়ে দেবে।

import java.time.*;
import java.util.*;
import java.util.stream.Collectors;
record Money(long paisa) {                       // ===== domain core: no framework =====
    static final Money ZERO = new Money(0);
    Money { if (paisa < 0) throw new IllegalArgumentException("negative money"); }
    static Money tk(long taka) { return new Money(taka * 100); }
    Money plus(Money o) { return new Money(paisa + o.paisa); }
    Money times(int n) { return new Money(paisa * n); }
    Money percent(int p) { return new Money((paisa * p + 50) / 100); }   // half-up
    public String toString() { return String.format("%d.%02d", paisa / 100, paisa % 100); }
}
enum PayMethod { BKASH, NAGAD, CASH }
enum OrderStatus {
    PENDING, PAID, DELIVERED, CANCELLED;
    boolean canMoveTo(OrderStatus n) {
        return switch (this) {
            case PENDING -> n == PAID || n == CANCELLED;
            case PAID -> n == DELIVERED || n == CANCELLED;
            case DELIVERED, CANCELLED -> false;          // final states
        };
    }
}
record Product(String sku, Money price) {}
record Line(String sku, int qty) { Line { if (qty <= 0) throw new IllegalArgumentException("bad qty"); } }
record Order(int id, String phone, List<Line> lines, Money total,
             PayMethod method, OrderStatus status, LocalDate day) {
    Order { lines = List.copyOf(lines); }
    Order moveTo(OrderStatus next) {
        if (!status.canMoveTo(next)) throw new IllegalStateException(status + " -> " + next);
        return new Order(id, phone, lines, total, method, next, day);
    }
}
sealed interface PaymentResult permits Paid, Declined {}
record Paid(String txId) implements PaymentResult {}
record Declined(String reason) implements PaymentResult {}
sealed interface PlaceResult permits Placed, Rejected {}
record Placed(Order order) implements PlaceResult {
    public String toString() { return "OK   #" + order.id() + " " + order.method() + " " + order.total(); }
}
record Rejected(String reason) implements PlaceResult { public String toString() { return "FAIL " + reason; } }
interface ProductRepository {                    // ===== ports =====
    Optional<Product> find(String sku); boolean reserve(String sku, int qty); void release(String sku, int qty);
}
interface OrderRepository { void save(Order o); Order get(int id); List<Order> all(); }
interface PaymentGateway { PaymentResult charge(PayMethod m, String phone, Money amount); }
interface SmsSender { void send(String phone, String text); }
class OrderService {                             // ===== core service =====
    static final int VAT_PERCENT = 15;
    private final ProductRepository products; private final OrderRepository orders;
    private final PaymentGateway gateway; private final SmsSender sms; private final Clock clock;
    OrderService(ProductRepository p, OrderRepository o, PaymentGateway g, SmsSender s, Clock c) {
        products = p; orders = o; gateway = g; sms = s; clock = c;
    }
    PlaceResult place(String phone, PayMethod method, List<Line> lines) {
        Money subtotal = Money.ZERO;
        List<Line> reserved = new ArrayList<>();
        for (Line l : lines) {                                   // 1. validate + reserve
            Optional<Product> p = products.find(l.sku());
            if (p.isEmpty() || !products.reserve(l.sku(), l.qty())) {
                releaseAll(reserved);
                return new Rejected((p.isEmpty() ? "unknown product " : "out of stock ") + l.sku());
            }
            reserved.add(l);
            subtotal = subtotal.plus(p.get().price().times(l.qty()));
        }
        Money total = subtotal.plus(subtotal.percent(VAT_PERCENT)); // 2. total + VAT
        var order = new Order(orders.all().size() + 1, phone, lines, total, method,
                              OrderStatus.PENDING, LocalDate.now(clock));
        return switch (gateway.charge(method, phone, total)) {     // 3. charge
            case Paid ok -> {
                orders.save(order.moveTo(OrderStatus.PAID));
                sms.send(phone, "অর্ডার #" + order.id() + " নিশ্চিত, মোট " + total + " টাকা");
                yield new Placed(orders.get(order.id()));
            }
            case Declined no -> {                                  // 4. compensate
                releaseAll(reserved);
                orders.save(order.moveTo(OrderStatus.CANCELLED));
                yield new Rejected("payment declined: " + no.reason());
            }
        };
    }
    void deliver(int id) { orders.save(orders.get(id).moveTo(OrderStatus.DELIVERED)); }
    void cancel(int id) {
        Order c = orders.get(id).moveTo(OrderStatus.CANCELLED);  // throws if forbidden
        releaseAll(c.lines());
        orders.save(c);
        sms.send(c.phone(), "অর্ডার #" + id + " বাতিল হয়েছে");
    }
    private void releaseAll(List<Line> ls) { ls.forEach(l -> products.release(l.sku(), l.qty())); }
}
class InMemoryProducts implements ProductRepository {   // ===== adapters =====
    final Map<String, Product> items = new TreeMap<>();
    final Map<String, Integer> stock = new TreeMap<>();
    void add(String sku, long taka, int qty) { items.put(sku, new Product(sku, Money.tk(taka))); stock.put(sku, qty); }
    public Optional<Product> find(String sku) { return Optional.ofNullable(items.get(sku)); }
    public synchronized boolean reserve(String sku, int qty) {
        int have = stock.getOrDefault(sku, 0);
        if (have < qty) return false;
        stock.put(sku, have - qty); return true;
    }
    public synchronized void release(String sku, int qty) { stock.merge(sku, qty, Integer::sum); }
}
class InMemoryOrders implements OrderRepository {
    private final Map<Integer, Order> byId = new TreeMap<>();
    public void save(Order o) { byId.put(o.id(), o); }
    public Order get(int id) { return Optional.ofNullable(byId.get(id)).orElseThrow(); }
    public List<Order> all() { return List.copyOf(byId.values()); }
}
class FakeWallets implements PaymentGateway {            // balance in paisa
    private final Map<String, Long> balance = new TreeMap<>(Map.of("01711", 500000L, "01811", 50000L));
    public PaymentResult charge(PayMethod m, String phone, Money amount) {
        if (m == PayMethod.CASH) return new Paid("cash");
        long have = balance.getOrDefault(phone, 0L);
        if (have < amount.paisa()) return new Declined("insufficient balance");
        balance.put(phone, have - amount.paisa()); return new Paid(m + "-" + phone);
    }
}
class Main {                                             // ===== scripted scenario =====
    public static void main(String[] args) {
        var products = new InMemoryProducts();
        products.add("RICE", 420, 10); products.add("OIL", 185, 5);
        products.add("DAL", 135, 8);   products.add("TEA", 260, 1);
        var clock = Clock.fixed(Instant.parse("2026-10-18T04:30:00Z"), ZoneId.of("Asia/Dhaka"));
        var orders = new InMemoryOrders();
        List<String> outbox = new ArrayList<>();
        SmsSender sms = (phone, text) -> outbox.add(phone + " : " + text);
        var shop = new OrderService(products, orders, new FakeWallets(), sms, clock);

        show(shop.place("01711", PayMethod.BKASH, List.of(new Line("RICE", 2), new Line("OIL", 1))));
        show(shop.place("01811", PayMethod.NAGAD, List.of(new Line("DAL", 3), new Line("TEA", 1))));
        show(shop.place("01911", PayMethod.CASH, List.of(new Line("TEA", 1), new Line("DAL", 2))));
        show(shop.place("01711", PayMethod.BKASH, List.of(new Line("TEA", 1))));
        show(shop.place("01811", PayMethod.NAGAD, List.of(new Line("OIL", 2))));
        shop.deliver(3);
        shop.cancel(4);
        try { shop.cancel(3); } catch (IllegalStateException e) { System.out.println("cancel #3 refused: " + e.getMessage()); }

        System.out.println("-- orders --");
        orders.all().forEach(o -> System.out.printf("#%d %-6s %-9s %8s%n", o.id(), o.method(), o.status(), o.total()));
        System.out.println("-- stock -- " + products.stock);
        LocalDate today = LocalDate.now(clock);
        Map<PayMethod, Money> sales = orders.all().stream()
            .filter(o -> o.day().equals(today) && o.status() != OrderStatus.CANCELLED)
            .collect(Collectors.groupingBy(Order::method, TreeMap::new,
                     Collectors.reducing(Money.ZERO, Order::total, Money::plus)));
        System.out.println("-- sales " + today + " --");
        for (PayMethod m : PayMethod.values()) System.out.printf("%-6s %8s%n", m, sales.getOrDefault(m, Money.ZERO));
        System.out.printf("%-6s %8s%n", "TOTAL", sales.values().stream().reduce(Money.ZERO, Money::plus));
        System.out.println("-- SMS outbox --");
        outbox.forEach(System.out::println);
    }
    static void show(PlaceResult r) { System.out.println(r); }
}

আউটপুট মিলিয়ে নিন: OK #1 BKASH 1178.75 (২ বস্তা চাল ৮৪০ + তেল ১৮৫ = ১০২৫, VAT ১৫৩.৭৫); FAIL payment declined: insufficient balance — অংশ ৪-এর দৃশ্য; OK #3 CASH 609.50 — শেষ চায়ের প্যাকেটটি সালমা আপা পেলেন, কারণ compensation সেটা ফেরত দিয়েছিল; FAIL out of stock TEA — এবার সত্যিই চা নেই, আর এই প্রত্যাখ্যানে অর্ডার নম্বরই তৈরি হয়নি; OK #4 NAGAD 425.50. তারপর cancel #3 refused: DELIVERED -> CANCELLED. অর্ডারের তালিকায় #২ আর #৪ CANCELLED, #৩ DELIVERED; stock {DAL=6, OIL=4, RICE=8, TEA=0}; দিনের বিক্রি BKASH 1178.75, NAGAD 0.00, CASH 609.50, TOTAL 1788.25; শেষে outbox-এ চারটি বাংলা SMS.

খুঁটিনাটি: (১) রিপোর্টের stream groupingBy(Order::method, TreeMap::new, reducing(...)) — পর্ব ৩৫-এর downstream collector; TreeMap-এ enum key তাদের ঘোষণার ক্রমে সাজানো থাকে, তাই ক্রম প্রতিবার একই। (২) NAGAD 0.00 এসেছে getOrDefault থেকে — #৪ বাতিল হওয়ায় Nagad-এর কোনো বিক্রি নেই, কিন্তু রিপোর্টে সারিটা থাকা দরকার। (৩) নকল wallet বাতিলে টাকা ফেরত দেয় না — একটি সৎ ফাঁক, অনুশীলন ১-এ ভরাবেন। (৪) reserve আর release synchronized — এক JVM-এর ভেতরে দুই thread-এর race ঠেকায়; একাধিক সার্ভারে কী করতে হয়, তা অংশ ৭-এ। (৫) নিজের প্রজেক্টে প্রতিটি type নিজের ফাইলে public হবে, আর Main হবে public class Main; অনলাইন runner-এর জন্য এখানে সব এক ফাইলে, public ছাড়া।

নিয়মগুলোর প্রমাণ — ছোট একটি টেস্ট

আউটপুট চোখে মেলানো একবারের কাজ। প্রতিটি বদলের পর নিয়মগুলো নিজে নিজে যাচাই হওয়া দরকার। JUnit (পর্ব ৪৭) এখানে চলে না, তাই একটি ছোট check helper দিয়ে টেস্টের ধারণাটা দেখুন — VAT-এর হিসাব, half-up rounding, ঋণাত্মক টাকা প্রত্যাখ্যান, আর state machine-এর পুরো ছক:

record Money(long paisa) {
    Money { if (paisa < 0) throw new IllegalArgumentException("negative money"); }
    Money percent(int p) { return new Money((paisa * p + 50) / 100); }   // half-up
}
enum OrderStatus {
    PENDING, PAID, DELIVERED, CANCELLED;
    boolean canMoveTo(OrderStatus n) {
        return switch (this) {
            case PENDING -> n == PAID || n == CANCELLED;
            case PAID -> n == DELIVERED || n == CANCELLED;
            case DELIVERED, CANCELLED -> false;
        };
    }
}
class Main {
    static int passed = 0, failed = 0;
    static void check(String name, boolean ok) {
        if (ok) passed++; else failed++;
        System.out.println((ok ? "PASS " : "FAIL ") + name);
    }
    static boolean rejects(Runnable r) {
        try { r.run(); return false; } catch (IllegalArgumentException e) { return true; }
    }
    public static void main(String[] args) {
        check("VAT 15% of 1025.00 = 153.75", new Money(102500).percent(15).paisa() == 15375);
        check("half-up: 15% of 0.10 = 0.02", new Money(10).percent(15).paisa() == 2);
        check("negative money rejected", rejects(() -> new Money(-1)));
        check("DELIVERED -> CANCELLED forbidden", !OrderStatus.DELIVERED.canMoveTo(OrderStatus.CANCELLED));
        int allowed = 0;
        System.out.println("          PEND PAID DELV CANC");
        for (OrderStatus from : OrderStatus.values()) {
            System.out.printf("%-9s", from);
            for (OrderStatus to : OrderStatus.values()) {
                if (from.canMoveTo(to)) allowed++;
                System.out.print(from.canMoveTo(to) ? "   Y " : "   . ");
            }
            System.out.println();
        }
        check("exactly 4 of 16 moves allowed", allowed == 4);
        System.out.printf("%d passed, %d failed%n", passed, failed);
    }
}

আউটপুট: পাঁচটি PASS, মাঝে ৪×৪ ছক — PENDING সারিতে PAID আর CANC-এ Y, PAID সারিতে DELV আর CANC-এ Y, শেষ দুই সারিতে শুধু বিন্দু — আর শেষে 5 passed, 0 failed. পরীক্ষা করে দেখুন: percent থেকে + 50 মুছে চালান — দ্বিতীয় টেস্ট FAIL হবে, কারণ ১.৫ পয়সা তখন কেটে ১ পয়সা হয়। এভাবেই টেস্ট ভবিষ্যতের “ছোট্ট পরিষ্কার করা”-কে ধরে ফেলে। “ঠিক চারটি বদল বৈধ” টেস্টটি গুরুত্বপূর্ণ: কেউ ভুল করে নতুন একটি বদল খুলে দিলে সংখ্যা বদলাবে।

আসল প্রজেক্টে এই টেস্টগুলো JUnit 5-এ থাকবে, আর OrderService-এর টেস্টে নকল adapter বসবে — যেমন সবসময় প্রত্যাখ্যান করা একটি gateway. আসলের জায়গায় বসানো এমন নকল object-কে বলে test double; যেটি কী ডাকা হলো মনে রাখে, সেটি mock.

class OrderServiceTest {
    static final Clock FIXED = Clock.fixed(Instant.parse("2026-10-18T04:30:00Z"), ZoneId.of("Asia/Dhaka"));

    @Test
    void declinedPaymentReleasesReservedStock() {
        var products = new InMemoryProducts();
        products.add("TEA", 260, 1);
        PaymentGateway alwaysDecline = (m, phone, amount) -> new Declined("insufficient balance");
        var service = new OrderService(products, new InMemoryOrders(), alwaysDecline, (p, t) -> {}, FIXED);

        var result = service.place("01811", PayMethod.NAGAD, List.of(new Line("TEA", 1)));

        assertInstanceOf(Rejected.class, result);
        assertEquals(1, products.stock.get("TEA"));          // compensation কাজ করেছে
    }
}

৬ · Adapter — JDBC, Spring REST, profile আর Maven

Core তৈরি, টেস্টও আছে। এবার তাকে বাস্তব দুনিয়ার সাথে জোড়ার পালা। নিচের কোডগুলো অনলাইনে চলবে না — এগুলোর জন্য PostgreSQL, Spring Boot আর Maven লাগে — কিন্তু প্রতিটি টুকরো আপনি পর্ব ৪৬–৫১-এ আলাদা করে শিখেছেন। লক্ষ করুন, কোনো adapter-এর জন্য OrderService-এর একটি লাইনও বদলাতে হয়নি।

JDBC repository — শর্তযুক্ত UPDATE আর transaction

পর্ব ৪৯-এর JDBC দিয়ে ProductRepository-এর database রূপ। প্রথমে টেবিল — আর তাতে একটি শর্ত, যাতে কোনো ভুলেই stock ঋণাত্মক না হয়:

CREATE TABLE product (
    sku         VARCHAR(20) PRIMARY KEY,
    name        VARCHAR(100) NOT NULL,
    price_paisa BIGINT NOT NULL CHECK (price_paisa >= 0),
    stock       INT    NOT NULL CHECK (stock >= 0)      -- শেষ প্রতিরক্ষা
);
public final class JdbcProductRepository implements ProductRepository {
    private final DataSource ds;
    public JdbcProductRepository(DataSource ds) { this.ds = ds; }

    @Override
    public boolean reserve(String sku, int qty) {
        String sql = "UPDATE product SET stock = stock - ? WHERE sku = ? AND stock >= ?";
        try (Connection c = ds.getConnection();
             PreparedStatement ps = c.prepareStatement(sql)) {
            ps.setInt(1, qty); ps.setString(2, sku); ps.setInt(3, qty);
            return ps.executeUpdate() == 1;            // 0 সারি = যথেষ্ট stock নেই
        } catch (SQLException e) {
            throw new IllegalStateException("reserve failed for " + sku, e);
        }
    }
    // find আর release একই ছকে; release: SET stock = stock + ?
}

দুটি জিনিস খেয়াল করুন। প্রথমত, “stock আছে কি না দেখা” আর “কমানো” আলাদা দুটি query নয় — একটিই UPDATE, যার WHERE-এ শর্ত। Database এটিকে অবিভাজ্যভাবে চালায়, তাই মাঝখানে অন্য কেউ ঢুকতে পারে না (অংশ ৭)। দ্বিতীয়ত, PreparedStatement-এর ? — ব্যবহারকারীর লেখা কখনো SQL-এর অংশ হয় না, তাই SQL injection-এর পথ বন্ধ। একটি অর্ডার আর তার সব লাইন সংরক্ষণ হয় একটি transaction-এ: setAutoCommit(false), সব INSERT, তারপর commit(); মাঝে ব্যর্থ হলে rollback() — হয় পুরো অর্ডার থাকবে, নয়তো কিছুই না।

Spring REST controller — যাচাই আর error-এর অনুবাদ

পর্ব ৫১-এর REST API. Controller-এর কাজ শুধু অনুবাদ: JSON → domain-এর ডাক → HTTP উত্তর। আকারগত যাচাই (ফোন নম্বর ১১ অঙ্কের কি না, তালিকা খালি কি না) হয় Bean Validation-এর annotation দিয়ে, দরজাতেই; ব্যবসার নিয়ম (stock, state) থাকে core-এ।

@RestController
@RequestMapping("/api/orders")
class OrderController {
    private final OrderService service;
    OrderController(OrderService service) { this.service = service; }   // constructor injection

    record LineDto(@NotBlank String sku, @Positive int qty) {}
    record PlaceOrderRequest(@Pattern(regexp = "01[3-9]\\d{8}") String phone,
                             @NotNull PayMethod method,
                             @NotEmpty List<@Valid LineDto> lines) {}

    @PostMapping
    ResponseEntity<?> place(@Valid @RequestBody PlaceOrderRequest req) {
        var lines = req.lines().stream().map(l -> new Line(l.sku(), l.qty())).toList();
        return switch (service.place(req.phone(), req.method(), lines)) {
            case Placed p   -> ResponseEntity.status(HttpStatus.CREATED).body(p.order());
            case Rejected r -> ResponseEntity.unprocessableEntity().body(Map.of("message", r.reason()));
        };
    }

    @PostMapping("/{id}/cancel")
    void cancel(@PathVariable int id) { service.cancel(id); }
}

@RestControllerAdvice
class ApiErrors {
    private static final Logger log = LoggerFactory.getLogger(ApiErrors.class);

    @ExceptionHandler(IllegalStateException.class)
    ResponseEntity<Map<String, String>> conflict(IllegalStateException e) {
        log.warn("illegal order transition: {}", e.getMessage());          // ডেভেলপারের জন্য
        return ResponseEntity.status(HttpStatus.CONFLICT)
                .body(Map.of("message", "এই অবস্থায় অর্ডারটি বাতিল করা যায় না"));  // গ্রাহকের জন্য
    }
}

উত্তরের তিনটি রূপ: সফল অর্ডারে 201 Created; stock বা পেমেন্টের কারণে প্রত্যাখ্যানে 422 Unprocessable Entity; নিষিদ্ধ state বদলে 409 Conflict. আর @Valid ব্যর্থ হলে Spring নিজেই 400 Bad Request দেয়। একই ভুলের দুটি বার্তা — log-এ ইংরেজিতে প্রযুক্তিগত তথ্য, গ্রাহকের কাছে সহজ বাংলা।

Wiring আর profile — কোন adapter কখন

কোন port-এর পেছনে কোন adapter বসবে, তা ঠিক হয় এক জায়গায়। পর্ব ৫০-এর Spring profile দিয়ে নিজের কম্পিউটারে (dev) নকল wallet, আর সার্ভারে (prod) আসল gateway:

@Configuration
class Wiring {
    @Bean OrderService orderService(ProductRepository p, OrderRepository o,
                                    PaymentGateway g, SmsSender s) {
        return new OrderService(p, o, g, s, Clock.system(ZoneId.of("Asia/Dhaka")));
    }
    @Bean @Profile("dev")  PaymentGateway fakeWallets() { return new FakeWallets(); }
    @Bean @Profile("prod") PaymentGateway realGateway(@Value("${payment.api-key}") String key) {
        return new BkashGateway(key);
    }
}
spring:
  profiles:
    active: dev
---
spring:
  config:
    activate:
      on-profile: prod
  datasource:
    url: jdbc:postgresql://db:5432/dokandar
    username: dokandar_app
    password: ${DB_PASSWORD}        # গোপন তথ্য environment variable থেকে
payment:
  api-key: ${PAYMENT_API_KEY}

লক্ষ করুন, OrderService-এ কোনো @Service annotation নেই — core Spring-কে চেনেই না। Spring-এর সাথে পরিচয় শুধু Wiring class-এর, যা একটি adapter.

Maven — কোন dependency কেন

<parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>3.3.5</version>   <!-- লেখার সময়ের একটি 3.x; নতুনটা দেখে নিন -->
</parent>
<properties><java.version>21</java.version></properties>
<dependencies>
    <dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-web</artifactId></dependency>
    <dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-validation</artifactId></dependency>
    <dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-jdbc</artifactId></dependency>
    <dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-actuator</artifactId></dependency>
    <dependency><groupId>org.postgresql</groupId><artifactId>postgresql</artifactId><scope>runtime</scope></dependency>
    <dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-test</artifactId><scope>test</scope></dependency>
</dependencies>

web দেয় REST আর embedded server, validation দেয় @Valid, jdbc দেয় DataSource আর connection pool, actuator দেয় health আর metrics, postgresql হলো JDBC driver (শুধু চলার সময় লাগে), আর starter-test-এ JUnit 5 আছে। পর্ব ৪৬-এর Maven এগুলো Maven Central থেকে নামিয়ে আনে। বড় হলে core-কে আলাদা Maven module বানানো যায়, যার pom-এ Spring-এর কোনো dependency থাকবে না — তখন dependency rule ভাঙলে build-ই ব্যর্থ হবে।

৭ · শেষ পিসটি দুজন একসাথে কিনলে — concurrency

ঈদের আগের রাত। দোকানে বিশেষ সেমাইয়ের শেষ প্যাকেট। মোহাম্মদপুর থেকে আয়েশা আর ধানমন্ডি থেকে বাবুল একই সেকেন্ডে “কিনুন” চাপলেন। দুটি অনুরোধ দুটি আলাদা thread-এ — হয়তো দুটি আলাদা সার্ভারে। এটাই পর্ব ৪০-এর race condition, আর এর মূলে check-then-act: “আছে কি না দেখি, তারপর কমাই” — দেখা আর কমানোর মাঝের ফাঁকে অন্যজন ঢুকে পড়েন।

1// ভুল: দুই ধাপে2int s = SELECT stock ...;   if (s >= 1)3    UPDATE product SET stock = s - 1 ...45// ঠিক: এক ধাপে, শর্তসহ6UPDATE product SET stock = stock - 17  WHERE sku = 'SEMAI' AND stock >= 1;
database আর দুই গ্রাহক
stock (DB)
আয়েশা
বাবুল
  1. একই মুহূর্তে দুটি অনুরোধ। Stock ১. দুটি অনুরোধ দুটি thread-এ — হয়তো দুটি আলাদা সার্ভারে, একই database-এ।
  2. দুজনেই “আছে” দেখলেন। দুই-ধাপের পদ্ধতিতে দুজনের SELECT-ই ১ পেল, কারণ কেউ এখনো কমায়নি। এই ফাঁকটাই race.
  3. দুজনেই কমালেন — হিসাবে ভুল। দুজনেই লিখলেন stock = 1 - 1. ফল ০, কিন্তু বিক্রি হলো দুটি — একটি হালনাগাদ হারিয়ে গেল (lost update). একজন গ্রাহক টাকা দিয়েও মাল পাবেন না।
  4. সমাধান: দেখা আর কমানো এক ধাপে। শর্তযুক্ত UPDATE database নিজে অবিভাজ্যভাবে চালায়, আর একই সারিতে দুটি UPDATE একসাথে লিখতে পারে না — দ্বিতীয়টি প্রথমটির শেষ পর্যন্ত অপেক্ষা করে। আয়েশারটি আগে পৌঁছাল: ১ সারি বদলাল।
  5. দ্বিতীয়জন পরিষ্কার “না” পেলেন। বাবুলের UPDATE এবার নতুন মান ০ দেখে; শর্ত stock >= 1 মিথ্যা, তাই ০ সারি। reserve ফেরত দেয় false, core দেয় Rejected, আর অ্যাপ বলে “দুঃখিত, মাল শেষ”। টাকা কাটার আগেই — কারণ reserve পেমেন্টের আগে।

বিকল্পগুলো আর আমাদের সিদ্ধান্ত

পদ্ধতিকীভাবেকখন ঠিক, কখন নয়
synchronized (পর্ব ৪০)এক JVM-এ এক সময়ে এক threadin-memory adapter-এ যথেষ্ট; দুটি সার্ভার চললে অকেজো, কারণ lock প্রতিটি JVM-এর নিজের
শর্তযুক্ত UPDATEদেখা আর কমানো এক SQL-এstock-এর মতো একটি সংখ্যার জন্য সবচেয়ে সরল ও দ্রুত — আমাদের পছন্দ
Optimistic lockingসারিতে version কলাম; WHERE version = ? মিললে তবেই লেখাযখন পুরো সারি পড়ে অনেক কিছু বদলাতে হয় আর সংঘর্ষ কম; ব্যর্থ হলে আবার চেষ্টা
Pessimistic lockingSELECT ... FOR UPDATE — পড়ার সময়ই সারি আটকানোসংঘর্ষ ঘন ঘন হলে; কিন্তু অন্যরা অপেক্ষা করে, আর ভুল ক্রমে deadlock-এর ঝুঁকি
CHECK (stock >= 0)database নিজেই অবৈধ মান প্রত্যাখ্যান করেমূল সমাধান নয়, শেষ প্রতিরক্ষা — কোডে ভুল থাকলেও ঋণাত্মক stock অসম্ভব

আমাদের সিদ্ধান্ত: শর্তযুক্ত UPDATE, সাথে CHECK constraint. কারণ stock একটি সংখ্যা, কাজটি এক লাইনের, আর এটি একাধিক সার্ভারেও সঠিক — পর্ব ৪০-এর compare-and-swap-এর database রূপ। In-memory adapter-এ synchronized রাখা হয়েছে শুধু টেস্ট আর ডেমোর জন্য। এই ধরনের সিদ্ধান্ত আর তার কারণ প্রজেক্টের একটি ছোট নোটে লিখে রাখুন — ইন্টারভিউতে এটাই সবচেয়ে মূল্যবান গল্প।

৮ · মানের চেকলিস্ট — টেস্ট, log, নিরাপত্তা আর গতি

“চলে” আর “production-এ রাখা যায়” এক জিনিস নয়। জমা দেওয়ার আগে নিচের তালিকা ধরে নিজের প্রজেক্ট মিলিয়ে নিন।

টেস্ট

core-এর প্রতিটি নিয়মে JUnit টেস্ট, নকল adapter দিয়ে; JDBC adapter-এর জন্য আসল database-এ কয়েকটি integration টেস্ট; আর edge case: শূন্য বা ঋণাত্মক পরিমাণ, অজানা SKU, শেষ পিস, একই অর্ডার দুবার বাতিল।

পর্ব ৪৭

Log আর error বার্তা

গ্রাহকের জন্য সহজ বাংলা বার্তা; ডেভেলপারের জন্য logging-এ ইংরেজি, অর্ডার নম্বর আর কারণসহ। পুরো ফোন নম্বর, পাসওয়ার্ড বা টোকেন log-এ নয় — 017****0001-এর মতো আড়াল করুন।

পর্ব ২৯, ৫০

নিরাপত্তা

দরজায় যাচাই (Bean Validation) আর ভেতরে invariant; সব SQL-এ PreparedStatement; গোপন তথ্য কোডে বা git-এ নয়, environment variable-এ; database user-কে শুধু দরকারি অনুমতি — least privilege; পেমেন্টের callback-এর স্বাক্ষর যাচাই।

পর্ব ৪৯–৫১

গতি

আগে মাপুন, পরে দ্রুত করুন। sku-তে index, রিপোর্টে তারিখ ধরে query আর পাতায় ভাগ করা (pagination), প্রতিটি লাইনের জন্য আলাদা query-র (N+1) ফাঁদ এড়ানো। ছোট দোকানে এক সার্ভার আর এক database বহু বছর যথেষ্ট।

পর্ব ৪৪, ৫৪

একই কাজ দুবার — idempotency. গ্রাহকের ইন্টারনেট দুর্বল; “অর্ডার দিন” চাপার পর উত্তর আসেনি, তিনি আবার চাপলেন। এখন কি দুটি অর্ডার, দুবার টাকা কাটা? সমাধান: অ্যাপ প্রতিটি অর্ডারের সাথে একটি অনন্য request id পাঠায়, আর server একই id দ্বিতীয়বার পেলে আগের ফলটাই ফেরত দেয়। একই অনুরোধ যতবারই আসুক, ফল একবারের মতো — একে বলে idempotency. টাকার লেনদেনে এটি ঐচ্ছিক নয়।

পরীক্ষা ও ইন্টারভিউতে
  • Hexagonal architecture কী? — ব্যবসার নিয়ম framework-মুক্ত core-এ; বাইরের সবকিছু port (interface) আর adapter দিয়ে যুক্ত; dependency সবসময় ভেতরের দিকে।
  • টাকা double-এ কেন নয়? — binary floating point-এ ০.১-এর মতো দশমিক হুবহু রাখা যায় না; তাই পয়সায় long বা BigDecimal.
  • দুজন শেষ পিস কিনলে? — শর্তযুক্ত UPDATE ... WHERE stock >= ?, অথবা optimistic/pessimistic locking; একাধিক সার্ভারে synchronized যথেষ্ট নয়।
  • Saga বা compensation কী? — যেখানে এক transaction সম্ভব নয়, সেখানে প্রতিটি ধাপের একটি পাল্টা কাজ; ব্যর্থ হলে আগের ধাপগুলো উল্টো ক্রমে উল্টানো।
  • Exception নাকি result? — প্রত্যাশিত ব্যবসায়িক ফল (ব্যালান্স কম) result-এ; চুক্তি ভাঙা বা অসম্ভব অবস্থা exception-এ।

৯ · Deployment, portfolio আর পরের ধাপ

এক ফাইলে প্যাকেট — JAR

mvn clean verify                          # compile + সব টেস্ট
java -jar target/dokandar-0.1.0.jar --spring.profiles.active=dev

Spring Boot-এর Maven plugin সব dependency-সহ একটি চালানো যায় এমন JAR বানায় — যেকোনো কম্পিউটারে Java 21 থাকলেই চলে। Docker এক ধাপ এগিয়ে Java-সহ পুরো পরিবেশটাই একটি image-এ ভরে দেয়: eclipse-temurin:21-jre-এর মতো একটি base image, তার ওপর আপনার JAR, root ছাড়া একটি সাধারণ user, আর ENTRYPOINT ["java", "-jar", "/app/app.jar"]. তখন “আমার কম্পিউটারে তো চলে” সমস্যা অনেকটাই মিটে যায়, আর PostgreSQL-সহ পুরো সিস্টেম docker compose দিয়ে এক কমান্ডে চালানো যায়।

CI (continuous integration): প্রতিটি push-এ GitHub Actions-এর মতো একটি সেবা কোড নামিয়ে mvn verify চালায়; কোনো টেস্ট ব্যর্থ হলে merge আটকে যায়। আর চালু সিস্টেমের খবর রাখতে Actuator-এর /actuator/health বলে সিস্টেম আর database ঠিক আছে কি না, /actuator/metrics দেয় request-এর সংখ্যা আর সময়। Health endpoint ছাড়া বাকিগুলো বাইরের দুনিয়ার জন্য খোলা রাখবেন না।

Portfolio — প্রজেক্টটি কীভাবে দেখাবেন

  • README: এক অনুচ্ছেদে সমস্যা; user story-র তালিকা; স্থাপত্যের ছবি (অংশ ৩-এর মতো); চালানোর তিন লাইনের নির্দেশ; API-র উদাহরণ (একটি curl আর তার JSON উত্তর); আর “নকশার সিদ্ধান্ত” অংশ — পয়সায় টাকা, compensation, শর্তযুক্ত UPDATE — প্রতিটির কারণসহ।
  • প্রমাণ: CI-এর সবুজ চিহ্ন, টেস্টের সংখ্যা, আর একটি ছোট ভিডিও বা কয়েকটি screenshot — অর্ডার, ব্যর্থ পেমেন্ট, দিনের রিপোর্ট।
  • ইন্টারভিউতে বলুন গল্প, feature-এর তালিকা নয়: “পেমেন্ট ব্যর্থ হলে stock আটকে থাকত — তাই compensation যোগ করি, আর একটি টেস্ট দিয়ে প্রমাণ করি।” “দুটি সার্ভারে synchronized কাজ করে না বলে শর্তযুক্ত UPDATE বেছে নিয়েছি।” কী আলাদা করতেন, সেটাও বলুন — যেমন idempotency key আগে থেকে না রাখা।
  • যা বোঝেন না, তা রাখবেন না: README-তে Kafka বা microservice লিখলে প্রশ্ন আসবেই। প্রতিটি লাইনের কারণ বলতে পারা — এটাই আপনার আসল সার্টিফিকেট।

বাড়ানোর ধারণা

রিপোর্ট

সবচেয়ে বেশি বিক্রি হওয়া পণ্য, মাসিক VAT-এর হিসাব, CSV export (পর্ব ৩৭)।

অনুশীলন ২

একাধিক শাখা

stock শাখা অনুযায়ী; এক শাখা থেকে আরেক শাখায় মাল পাঠানো।

অনুশীলন ৪

বাকির খাতা ও ফেরত

নিয়মিত গ্রাহকের বাকি, মাস শেষে হিসাব; নতুন state RETURNED.

state machine

Frontend

পরে React বা Android অ্যাপ — একই REST API, core অপরিবর্তিত।

নতুন adapter

১০ · AI-এর যুগে — কয়েক সপ্তাহের প্রজেক্টে AI pair programmer, মালিক আপনি

এক বা দুই সপ্তাহের প্রজেক্টে AI সত্যিই বড় সাহায্য: নকশা রিভিউ, edge case-এর টেস্টের তালিকা, refactoring-এর পরামর্শ, অচেনা error-এর ব্যাখ্যা। বিপদ অন্য জায়গায়। “আমাকে hexagonal architecture-এ একটি Spring Boot order system বানিয়ে দাও” লিখলে কয়েক মিনিটে পঞ্চাশটি ফাইল আসবে — আর ইন্টারভিউতে প্রথম প্রশ্নেই বোঝা যাবে, নকশাটা আপনার মাথা থেকে আসেনি। নিয়ম একটাই: সিদ্ধান্ত আপনার, AI প্রস্তাব দেয় আর সমালোচনা করে। একসাথে একটি ছোট টুকরো, আর প্রতিটি টুকরো নিজে বুঝে, চালিয়ে, টেস্ট করে তবেই রাখা।

AI প্রায়ই যা করেকেন সমস্যা
টাকার জন্য doubleদশমিকের গরমিল; পয়সা বা BigDecimal চান
Domain class-এ @Entity, @Servicecore framework-এ বাঁধা পড়ে, dependency rule ভাঙে
পেমেন্ট ব্যর্থতায় শুধু exception ছোড়াআটকানো stock আর ফেরত যায় না — compensation নেই
“stock আছে কি না” দেখে পরে কমানোcheck-then-act race; শেষ পিস দুবার বিক্রি
শুরুতেই microservice, Kafka, Redisছোট দোকানের জন্য অপ্রয়োজনীয় জটিলতা; ব্যাখ্যা করতে পারবেন না
টেস্টে শুধু সফল পথব্যর্থ পেমেন্ট, শেষ পিস, নিষিদ্ধ বাতিল — আসল বাগ এখানেই

নকশা রিভিউ আর edge case-এর জন্য একটি prompt. লক্ষ করুন, এখানে পুরো সিস্টেম লিখে দিতে বলা হচ্ছে না:

আমি Java 21 দিয়ে একটি ছোট দোকানের inventory ও order backend বানাচ্ছি (ports and adapters নকশা)। core-এ শুধু JDK; port: ProductRepository, OrderRepository, PaymentGateway, SmsSender, Clock. টাকা পয়সায় long-এ। নিচে আমার domain type আর OrderService দিলাম। [এখানে কোড] তুমি নতুন করে কোড লিখবে না। শুধু রিভিউ করো: ১. কোথাও কি core কোনো framework বা বাইরের লাইব্রেরির ওপর নির্ভর করছে? ২. place() মাঝপথে ব্যর্থ হলে (দ্বিতীয় লাইনে stock নেই, পেমেন্ট declined, পেমেন্টের পরে save ব্যর্থ) stock আর টাকার অবস্থা কী হবে? কোন ক্ষেত্র আমি সামলাইনি? ৩. দুটি সার্ভার একসাথে চললে কোন কোন race হতে পারে? ৪. এমন ৮টি edge case দাও, যার JUnit টেস্ট আমার লেখা উচিত — প্রতিটির প্রত্যাশিত ফলসহ। ৫. প্রতিটি পরামর্শের পাশে লেখো: "এখনই জরুরি" নাকি "পরে করলেও চলবে", আর কেন।

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

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

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

প্র ০১Hexagonal architecture আর Clean Architecture কোথা থেকে এলো? এরা আসলে কোন সমস্যার সমাধান করে?

Alistair Cockburn ২০০৫ সালে তাঁর “Hexagonal Architecture” লেখাটি প্রকাশ করেন, যার বিকল্প নাম Ports and Adapters. তাঁর পর্যবেক্ষণ ছিল সরল: অ্যাপ্লিকেশনের ব্যবসার নিয়ম প্রায়ই user interface আর database-এর কোডে মিশে যায়। তখন নিয়মটা টেস্ট করতে পুরো UI চালাতে হয়, আর database বদলাতে গেলে নিয়মের কোড ভাঙে। তাঁর প্রস্তাব: অ্যাপ্লিকেশন সব বাইরের জিনিসের সাথে কথা বলবে কয়েকটি “port” দিয়ে, আর প্রতিটি বাইরের প্রযুক্তির জন্য থাকবে একটি “adapter”. ষড়ভুজ আকারটি কোনো জাদুর সংখ্যা নয় — ছবিতে অনেক port আঁকার জায়গা পেতে এবং “ওপর-নিচ” স্তরের ধারণা ভাঙতেই এই আকার।

Robert C. Martin ২০১২ সালে একটি ব্লগ লেখায় “The Clean Architecture” নাম দিয়ে hexagonal, Jeffrey Palermo-র onion architecture আর এ ধরনের আরও কয়েকটি ধারণাকে এক ছবিতে আনেন, আর ২০১৭ সালে একই নামে বই লেখেন। তাঁর বৃত্তাকার ছবির কেন্দ্রে entity, তার বাইরে use case, তারপর adapter, সবার বাইরে framework আর database. সব নকশার মূল কথা একটি নিয়ম: source code-এর dependency কেবল ভেতরের দিকে।

কোন সমস্যা, আর কোন খরচ। লাভ তিনটি: core দ্রুত টেস্ট করা যায়, প্রযুক্তি বদলানো সস্তা, আর ব্যবসার নিয়ম এক জায়গায় পড়া যায়। খরচও আছে: বেশি interface, বেশি ফাইল, আর এক জায়গার ডেটা আরেক জায়গার আকারে রূপান্তরের কোড (যেমন LineDto থেকে Line). পাঁচ পর্দার একটি CRUD অ্যাপে এই খরচ লাভের চেয়ে বেশি হতে পারে। “দোকানদার”-এ এটি মানায়, কারণ এর নিয়ম (compensation, state, VAT) আসল, আর বাইরের সেবা (পেমেন্ট, SMS) বদলানোর সম্ভাবনা বাস্তব।

মূল উপলব্ধি: নাম যা-ই হোক — hexagonal, onion, clean — সারকথা একটি: যা সবচেয়ে কম বদলায় (ব্যবসার নিয়ম), তা যেন সবচেয়ে বেশি বদলায় এমন কিছুর (framework, database) ওপর নির্ভর না করে।

প্র ০২Domain-Driven Design-এর “ubiquitous language” কী? বাংলাদেশি ব্যবসার সফটওয়্যারে বাংলা শব্দের জায়গা কোথায়?

২০০৩ সালে Eric Evans প্রকাশ করেন Domain-Driven Design: Tackling Complexity in the Heart of Software. বইটির মূল দাবি: জটিল সফটওয়্যারের আসল কঠিন অংশ প্রযুক্তি নয়, ব্যবসার জ্ঞান — আর সেই জ্ঞান কোডে ঠিকভাবে না এলে সব প্রযুক্তি বৃথা। তাঁর সবচেয়ে প্রভাবশালী ধারণা ubiquitous language: ব্যবসার মানুষ আর ডেভেলপার মিলে একটি সাধারণ শব্দভান্ডার তৈরি করবেন, আর সেই শব্দগুলোই সরাসরি কোডের class আর method-এর নাম হবে। দোকানদার যদি বলেন “মাল আটকে রাখো”, কোডেও থাকবে reserve — কোথাও lockInventoryRecord, কোথাও holdStock নয়।

বাংলাদেশি প্রেক্ষাপট। আমাদের ব্যবসার ভাষায় এমন অনেক ধারণা আছে, যার সরাসরি ইংরেজি নেই: “বাকির খাতা”, “হালখাতা”, “মাল ফেরত”, “ডেলিভারি চার্জ ঢাকার ভেতরে-বাইরে”, “কিস্তি”। ইংরেজি থেকে আন্দাজে অনুবাদ করলে অর্থ হারায় — হালখাতা শুধু “year-end settlement” নয়, তার সাথে পুরোনো বাকি মেটানো আর নতুন খাতা খোলার নিয়ম জড়িত। ভালো অভ্যাস হলো প্রজেক্টে একটি শব্দকোষ রাখা: বাংলা শব্দ, তার স্পষ্ট সংজ্ঞা, আর কোডে কোন নাম। Java identifier-এ বাংলা অক্ষর ব্যবহার করা যায় (Java identifier-এ Unicode অক্ষর বৈধ), কিন্তু টিমের কীবোর্ড, search আর tool-এর সুবিধার জন্য সাধারণত ইংরেজি বা বাংলার রোমান রূপ (halkhata) নেওয়া হয় — মূল কথা হলো নামটা একটাই আর সবাই সেটাই বলবে।

Evans আরও বলেন, একই শব্দ ভিন্ন জায়গায় ভিন্ন অর্থ পেতে পারে — হিসাব বিভাগের “অর্ডার” আর গুদামের “অর্ডার” এক জিনিস না-ও হতে পারে। প্রতিটি অর্থের জন্য একটি সীমানা টানা — bounded context — বড় সিস্টেমে এটাই কাজ ভাগের ভিত্তি।

মূল উপলব্ধি: কোড হলো ব্যবসার জ্ঞানের লিখিত রূপ। যে শব্দে দোকানদার ভাবেন, সেই শব্দে কোড লিখলে ভুল বোঝাবুঝি কমে, আর নতুন নিয়ম কোথায় বসবে তা প্রায় নিজেই স্পষ্ট হয়ে যায়।

প্র ০৩Saga pattern কী? দুটি আলাদা সিস্টেমের মধ্যে একটি transaction কেন সম্ভব নয়, আর compensation কেন যথেষ্ট?

১৯৮৭ সালে Hector Garcia-Molina আর Kenneth Salem ACM SIGMOD সম্মেলনে “Sagas” নামে একটি গবেষণাপত্র প্রকাশ করেন। সমস্যাটা ছিল দীর্ঘ সময় ধরে চলা transaction: একটি transaction যত বেশি সময় চলে, তত বেশি সারি সে আটকে রাখে, আর অন্য সবাই অপেক্ষা করে। তাঁদের প্রস্তাব: বড় কাজটিকে কয়েকটি ছোট transaction-এ ভাগ করো, আর প্রতিটির জন্য একটি compensating transaction লেখো, যা তার প্রভাব শব্দার্থে উল্টে দেয়। কোনো ধাপ ব্যর্থ হলে আগের সফল ধাপগুলোর পাল্টা কাজ উল্টো ক্রমে চালাও।

কেন এক transaction নয়। আমাদের stock PostgreSQL-এ, টাকা bKash বা Nagad-এর সার্ভারে। দুই ভিন্ন সংস্থার সিস্টেমকে এক ACID transaction-এ বাঁধার প্রচলিত পথ two-phase commit, যেখানে সব পক্ষ একটি coordinator-এর নির্দেশে একসাথে commit করে। কিন্তু পেমেন্ট কোম্পানি আপনার coordinator-এর কথায় চলবে না; আর coordinator মাঝপথে বন্ধ হলে সবাই আটকে থাকে। তাই বাস্তবে প্রতিটি সিস্টেম নিজের transaction করে, আর আমরা সামঞ্জস্য রাখি পাল্টা কাজ দিয়ে।

দুটি সতর্কতা। প্রথমত, compensation সবসময় “যেন কিছুই হয়নি” নয়: গ্রাহক SMS পেয়ে গেলে তা ফেরত আনা যায় না — বড়জোর আরেকটি SMS পাঠানো যায়। তাই যে কাজ উল্টানো যায় না, তা রাখুন সবার শেষে; আমাদের place-এ SMS ঠিক তাই, পেমেন্ট সফল হওয়ার পরে। দ্বিতীয়ত, মাঝের অবস্থা অন্যরা দেখতে পায় — পেমেন্ট চলার সময় stock কম দেখায়। এটাই saga-র দাম: সাময়িক অসামঞ্জস্য মেনে নেওয়া। Microservice-এর যুগে ধারণাটি নতুন করে জনপ্রিয় হয়েছে; একটি coordinator সব ধাপ চালালে বলে orchestration, আর প্রতিটি সেবা event শুনে নিজের কাজ করলে choreography.

মূল উপলব্ধি: যেখানে সব কাজ একসাথে commit করা যায় না, সেখানে প্রতিটি ধাপের সাথে তার “উল্টো ধাপ” নকশা করুন, আর অফেরতযোগ্য কাজ রাখুন সবার শেষে।

প্র ০৪ছোট ব্যবসার জন্য microservice নাকি একটি monolith? Martin Fowler-এর “monolith first” কী বলে?

২০১৪ সালে James Lewis আর Martin Fowler-এর “Microservices” লেখাটি এই শব্দকে মূলধারায় আনে: একটি অ্যাপ্লিকেশন অনেক ছোট সেবায় ভাগ, প্রতিটি আলাদাভাবে deploy হয়, নিজের ডেটা রাখে, আর নেটওয়ার্কে কথা বলে। বড় প্রতিষ্ঠানে এর লাভ বাস্তব — শত শত ডেভেলপার আলাদা আলাদা দলে স্বাধীনভাবে কাজ করতে পারেন। পরের বছর, ২০১৫ সালে, Fowler নিজেই “MonolithFirst” নামে একটি লেখায় সতর্ক করেন: তিনি যত সফল microservice-এর গল্প শুনেছেন, তার প্রায় সবই শুরু হয়েছিল একটি monolith হিসেবে, যা বড় হয়ে ভাগ হয়েছে; আর শুরু থেকেই microservice হিসেবে বানানো অনেক সিস্টেম গুরুতর সমস্যায় পড়েছে।

কারণ কী। সেবার সীমানা ঠিক করতে ব্যবসাটা গভীরভাবে বুঝতে হয়, আর শুরুতে সেই বোঝাপড়া থাকে না। ভুল জায়গায় সীমানা টানলে একটি ফিচারের জন্য পাঁচটি সেবা বদলাতে হয়। তার ওপর আছে নেটওয়ার্কের খরচ: প্রতিটি method ডাক একটি নেটওয়ার্ক request হয়ে যায়, যা ধীর, ব্যর্থ হতে পারে, আর ডিবাগ করা কঠিন। অংশ ৪-এর compensation এক প্রক্রিয়ার ভেতরে কয়েক লাইনের কাজ; ছয়টি সেবায় ভাগ হলে সেটাই একটি বড় distributed saga.

“দোকানদার”-এর জন্য। একটি দোকান, কয়েকশো অর্ডার দিনে, একজন বা দুজন ডেভেলপার — একটি monolith, একটি JAR, একটি database যথেষ্টর চেয়েও বেশি। কিন্তু ভেতরে ভালোভাবে ভাগ করা — আজকাল যাকে বলে modular monolith. আমাদের port আর adapter ঠিক সেই প্রস্তুতি: কোনোদিন পেমেন্ট অংশ আলাদা সেবা হলে PaymentGateway-এর পেছনে শুধু একটি নতুন adapter বসবে।

মূল উপলব্ধি: Microservice একটি সংগঠনের সমস্যার সমাধান, ছোট দলের শুরুর বিন্দু নয়। ভালোভাবে ভাগ করা monolith দিয়ে শুরু করুন; ভাগ করুন তখন, যখন সত্যিকারের চাপ বলে কোথায় ভাগ করতে হবে।

প্র ০৫Gall’s Law কী বলে, আর একটি বড় সিস্টেম ধাপে ধাপে বড় করার সাথে এর সম্পর্ক কী?

শিশুরোগ বিশেষজ্ঞ John Gall ১৯৭৫ সালে প্রথম প্রকাশিত Systemantics বইয়ে সিস্টেম কীভাবে ব্যর্থ হয়, তা নিয়ে কিছুটা রসিকতা আর অনেকটা গভীর পর্যবেক্ষণ লেখেন। তার একটি অনুচ্ছেদ আজ সফটওয়্যার জগতে Gall’s Law নামে পরিচিত। মূল কথা: যে জটিল সিস্টেম কাজ করে, তা প্রায় সবসময় এমন একটি সরল সিস্টেম থেকে বিবর্তিত হয়েছে, যা কাজ করত। শূন্য থেকে নকশা করা জটিল সিস্টেম কখনো কাজ করে না, আর জোড়াতালি দিয়ে তাকে চালানোও যায় না — আবার একটি কাজ করা সরল সিস্টেম থেকে শুরু করতে হয়।

এই কোর্সের সাথে মিল। পর্ব ২২-এর “হিসাব খাতা” ছিল array আর একটি interface; আজকের “দোকানদার”-এর core-ও এক ফাইলে চলে, in-memory adapter দিয়ে। সঠিক ক্রম হলো: প্রথমে এই চলা সরল রূপ, টেস্টসহ। তারপর একটি করে adapter — JDBC, তারপর REST, তারপর আসল পেমেন্ট — প্রতিটি ধাপের শেষে সিস্টেম আবার চলে, টেস্ট আবার সবুজ। উল্টো পথে, প্রথম দিনেই Spring, PostgreSQL, Docker, bKash API আর SMS একসাথে জুড়লে কিছু না চললে কোথায় ভুল, তা খুঁজে পাওয়াই কঠিন।

বাস্তবের উদাহরণ। বহু বড় সফটওয়্যার ছোট শুরু থেকে বেড়েছে: Linus Torvalds ১৯৯১ সালে Linux ঘোষণার সময় একে “শুধু একটি শখ, বড় ও পেশাদার কিছু নয়” বলেছিলেন। আবার উল্টো দিকে, শুরু থেকে নিখুঁত, পূর্ণাঙ্গ নকশার অনেক সরকারি ও ব্যাংকিং সফটওয়্যার প্রকল্প বছরের পর বছর পিছিয়েছে। Agile পদ্ধতির ছোট ছোট iteration, MVP, আর continuous integration — সবই এই একই অভিজ্ঞতার আধুনিক রূপ।

মূল উপলব্ধি: বড় সিস্টেম নকশা করা হয় না, বড় করা হয়। প্রতিটি ধাপে একটি চলা, টেস্ট করা সিস্টেম রাখুন — তাহলে জটিলতা আসে নিয়ন্ত্রিতভাবে, আর ভুল ধরা পড়ে ছোট অবস্থায়।

অনুশীলন

চারটি কাজই “দোকানদার”-কে বাড়ানো। প্রতিটির পর অংশ ৫-এর দৃশ্য আর টেস্ট আবার চালিয়ে দেখুন, পুরোনো আচরণ ভাঙেনি তো।

  1. বাতিলে টাকা ফেরত: এখন PAID অর্ডার বাতিল হলে stock ফেরত যায়, কিন্তু টাকা যায় না। কোন port-এ কী যোগ করবেন, Order-এ কোন তথ্য রাখতে হবে, আর PENDING অর্ডার বাতিলে কেন refund লাগবে না?
    interface PaymentGateway {
        PaymentResult charge(PayMethod m, String phone, Money amount);
        void refund(String txId, Money amount);                    // নতুন
    }
    // Order-এ নতুন component: String txId (Paid ok থেকে ok.txId())
    void cancel(int id) {
        Order o = orders.get(id);
        Order c = o.moveTo(OrderStatus.CANCELLED);
        if (o.status() == OrderStatus.PAID) gateway.refund(o.txId(), o.total());
        releaseAll(c.lines());
        orders.save(c);
    }

    Port-এ refund, আর কোন লেনদেন ফেরত দিতে হবে তা জানতে Order-এ txId. PENDING-এ টাকা কাটাই হয়নি, তাই refund নেই — এখানে state machine সিদ্ধান্ত নিতে সাহায্য করল। আগে moveTo, পরে refund — নিষিদ্ধ বাতিল হলে exception আগেই আসবে, টাকা ভুল করে ফেরত যাবে না। FakeWallets-এ refund ব্যালান্স বাড়াবে, আর টেস্ট দেখাবে বাতিলের পরে ব্যালান্স আগের মতো। আরেকটি প্রশ্ন ভাবুন: refund নিজেই ব্যর্থ হলে? বাস্তবে অর্ডারটি একটি REFUND_PENDING অবস্থায় রেখে পরে আবার চেষ্টা করা হয়।

  2. সবচেয়ে বেশি বিক্রি হওয়া পণ্য: দিনের রিপোর্টে প্রতিটি পণ্যের মোট বিক্রিত পরিমাণ দেখান, বেশি থেকে কম ক্রমে। শুধু বাতিল নয় এমন অর্ডার গুনবেন। কোন stream operation লাগবে?
    Map<String, Integer> sold = orders.all().stream()
        .filter(o -> o.status() != OrderStatus.CANCELLED)
        .flatMap(o -> o.lines().stream())
        .collect(Collectors.groupingBy(Line::sku, TreeMap::new, Collectors.summingInt(Line::qty)));
    sold.entrySet().stream()
        .sorted(Map.Entry.<String, Integer>comparingByValue().reversed())
        .forEach(e -> System.out.printf("%-5s %3d%n", e.getKey(), e.getValue()));

    প্রতিটি অর্ডারের ভেতরে লাইনের তালিকা, তাই flatMap সব লাইনকে এক stream-এ আনে (পর্ব ৩৫)। summingInt একই SKU-র পরিমাণ যোগ করে। অংশ ৫-এর দৃশ্যে ফল: DAL 2, RICE 2, OIL 1, TEA 1. সমান মানের ক্ষেত্রে ক্রম স্থির থাকে, কারণ TreeMap আগে নাম ধরে সাজিয়েছে আর sorted stable (পর্ব ৩৩)। এই রিপোর্ট core-এর ভেতরে নয়, একটি আলাদা reporting service-এ রাখা ভালো — এটি শুধু পড়ে, কিছু বদলায় না।

  3. কম stock-এর সতর্কতা: কোনো পণ্যের stock ৩-এর নিচে নামলে দোকানদারের ফোনে SMS যাবে। এই নিয়ম কোথায় বসবে — controller, JDBC adapter, নাকি core? দোকানদারের নম্বর আর সীমা কোথা থেকে আসবে? কীভাবে টেস্ট করবেন?

    এটা ব্যবসার নিয়ম, তাই core-এ — OrderService.place-এ পেমেন্ট সফল হওয়ার পরে। Controller-এ বসালে CLI দিয়ে দেওয়া অর্ডারে সতর্কতা যেত না; JDBC adapter-এ বসালে database বদলালে নিয়ম হারাত। Stock জানতে ProductRepository-এ int stock(String sku) যোগ করুন। দোকানদারের নম্বর আর সীমা কোডে বসাবেন না — OrderService-এর constructor-এ একটি ছোট record ShopSettings(String ownerPhone, int lowStockLimit) দিন, যা Spring application.yml থেকে পড়ে। নতুন port লাগবে না, বিদ্যমান SmsSender-ই যথেষ্ট। টেস্ট: TEA-র stock ৪ রেখে ২টি অর্ডার, তারপর outbox-এ দোকানদারের নম্বরে ঠিক একটি SMS আছে কি না দেখুন। আর ব্যর্থ পেমেন্টে সতর্কতা যায় না, কারণ stock ফেরত এসেছে — সেটাও একটি টেস্ট।

  4. একাধিক শাখা (ভাবনার কাজ): রহিম ভাই মিরপুর আর উত্তরায় দুটি শাখা খুলেছেন। প্রতিটি শাখার stock আলাদা, অর্ডার আসে একটি নির্দিষ্ট শাখায়। কোন কোন class আর port বদলাবে, আর কোনগুলো এক অক্ষরও বদলাবে না? আগে লিখে রাখুন, তারপর উত্তর দেখুন।

    বদলাবে: stock-এর key হবে শাখা আর SKU মিলিয়ে; port হবে reserve(BranchId branch, String sku, int qty) আর release-ও একইভাবে; Order-এ নতুন component BranchId branch; database-এ নতুন branch_stock(branch_id, sku, stock) টেবিল, যার primary key দুটি কলাম মিলিয়ে; আর REST request-এ শাখার id. বদলাবে না: Money, PayMethod, OrderStatus আর তার state machine, PaymentGateway, SmsSender, compensation-এর যুক্তি। দাম কি শাখা অনুযায়ী আলাদা হবে? এটা প্রযুক্তির প্রশ্ন নয়, ব্যবসার প্রশ্ন — রহিম ভাইকে জিজ্ঞেস করতে হবে। এটাই DDD-র শিক্ষা: নতুন চাহিদায় প্রথম কাজ কোড নয়, ভাষা আর নিয়ম স্পষ্ট করা। তালিকাটি দেখুন — বেশির ভাগ বদল adapter আর port-এর স্বাক্ষরে, ব্যবসার মূল নিয়মে প্রায় কিছুই নয়।

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

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

ক্রমstock, পেমেন্ট আর SMS আছে এমন একটি অর্ডার সিস্টেমে সফল অর্ডারের ধাপগুলো সঠিক ক্রমে সাজান (▲▼ চেপে সরান)

  1. 📝 অনুরোধের আকার যাচাই — ফোন নম্বর, পরিমাণ, তালিকা খালি কি না
  2. 📦 প্রতিটি লাইনের stock আটকানো (reserve)
  3. 🧮 VAT-সহ মোট টাকা হিসাব
  4. 💳 পেমেন্ট নেওয়া — ব্যর্থ হলে আটকানো stock ফেরত
  5. ✅ অর্ডার PAID অবস্থায় সংরক্ষণ
  6. 📱 গ্রাহককে নিশ্চিতকরণের SMS

Hexagonal (ports and adapters) নকশায় একটি Java অ্যাপের core অংশ, অর্থাৎ domain আর OrderService, কোনটির ওপর নির্ভর করতে পারে?

  • Spring-এর @Service আর @Autowired
  • JDK আর core-এর নিজের লেখা port interface
  • JdbcProductRepository class
  • REST controller

একটি Java অর্ডার সিস্টেমে টাকা double-এ না রেখে পয়সায় long-এ রাখার মূল কারণ কী?

  • long, double-এর চেয়ে অনেক দ্রুত
  • double-এ ১ কোটির বেশি টাকা রাখা যায় না
  • double-এ দশমিক ভগ্নাংশ হুবহু রাখা যায় না, তাই হিসাবে ক্ষুদ্র গরমিল জমে
  • double JSON বা database-এ রাখা যায় না

একটি অর্ডারে stock আটকানো (reserve) সফল হয়েছে, কিন্তু তারপর পেমেন্ট গেটওয়ে “insufficient balance” ফেরত দিল। Saga ধাঁচের নকশায় পরের কাজ কী?

  • আটকানো stock ফেরত দেওয়া আর অর্ডার CANCELLED হিসেবে রাখা
  • কিছু না করা; stock আটকেই থাকবে
  • অর্ডার DELIVERED করে দেওয়া
  • database rollback দিলে সব নিজে থেকেই ঠিক হবে

দুটি সার্ভারে চলা একটি Java অ্যাপে একই সেকেন্ডে দুজন গ্রাহক একটি পণ্যের শেষ পিসটি কিনছেন। কোনটি নির্ভরযোগ্যভাবে একটিমাত্র বিক্রি নিশ্চিত করে?

  • reserve method-কে synchronized করা
  • আগে SELECT দিয়ে stock দেখা, 1 থাকলে তারপর UPDATE করা
  • stock ভ্যারিয়েবলকে volatile করা
  • UPDATE product SET stock = stock - 1 WHERE sku = ? AND stock >= 1, আর বদলানো সারির সংখ্যা দেখা

Java 21-এ sealed interface PaymentResult permits Paid, Declined আছে, আর কয়েক জায়গায় default ছাড়া switch expression এর দুটি রূপই সামলায়। এখন permits-এ তৃতীয় record Pending যোগ করলে কী হবে?

  • সব কিছু আগের মতো compile হবে, Pending এলে চলার সময় কিছুই ঘটবে না
  • যে switch Pending সামলায় না, সেগুলোতে compile error আসবে
  • শুধু চলার সময় ClassCastException আসবে
  • compiler নিজেই Pending-এর জন্য একটি default শাখা যোগ করবে

একটি অর্ডারের অবস্থা enum: PENDING, PAID, DELIVERED, CANCELLED; বৈধ বদল শুধু PENDING→PAID, PENDING→CANCELLED, PAID→DELIVERED আর PAID→CANCELLED. একটি DELIVERED অর্ডার বাতিলের চেষ্টাকে সবচেয়ে ভালোভাবে কীভাবে সামলানো উচিত?

  • অবস্থা CANCELLED করে stock ফেরত দেওয়া
  • কিছু না করে চুপচাপ ফিরে যাওয়া
  • IllegalStateException ছোড়া, যা বাইরের স্তর একটি বোধগম্য error বার্তায় রূপ দেয়
  • একটি নতুন অবস্থা DELIVERED_CANCELLED বানিয়ে সেখানে পাঠানো

শব্দকোষ

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

মনে রাখুন

  1. আগে চাহিদা: user story, গ্রহণযোগ্যতার শর্ত আর non-goal — এগুলোই পরে টেস্টের তালিকা।
  2. Domain-এ অবৈধ অবস্থা কঠিন করুন: পয়সায় Money, enum-এ state machine, record-এ যাচাই, sealed result-এ compiler-নিশ্চিত সব শাখা।
  3. Dependency ভেতরের দিকে: framework-মুক্ত core, তার নিজের port, আর বাইরে বদলযোগ্য adapter — in-memory, JDBC, REST.
  4. ভুলের পথ নকশা করুন: ব্যর্থ পেমেন্টে compensation, নিষিদ্ধ বদলে exception, শেষ পিসে শর্তযুক্ত UPDATE আর CHECK constraint, পুনরাবৃত্ত অনুরোধে idempotency.
  5. Portfolio মানে গল্প: README-তে সিদ্ধান্ত আর তার কারণ, টেস্ট আর CI-এর প্রমাণ; AI সাহায্য করে, কিন্তু প্রতিটি লাইনের ব্যাখ্যা আপনার।

পরের পর্বে: মডিউল ৮ — পরীক্ষা ও ইন্টারভিউ প্রস্তুতি — শুরু। প্রথমে “আউটপুট কী হবে?”: ছোট ছোট Java কোড পড়ে চালানোর আগেই ফল বলার অনুশীলন — String pool, integer division, overloading আর exception-এর ফাঁদসহ।

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

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