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

Backend-এর গঠন — controller, service, repository আর dependency injection

Backend Structure — Controllers, Services, Repositories and Dependency Injection

Taught in Bangla, this lesson explains why a one-file route stops scaling, assigns code to route, controller, service and repository layers, refactors a fat order route in small steps behind a behaviour test, sets folders and dependency direction, shows dependency injection as plain function parameters, writes repository contracts as TypeScript interfaces, tests services with fake repositories, and says when three layers are too much.

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

  • এক ফাইলের route কেন একসময় আর চলে না, আর কোন কোড কোন স্তরে যাবে
  • বাজারঘরের একটি মোটা order route ধাপে ধাপে ভাঙা, আচরণ না বদলে
  • নির্ভরতার দিক, feature folder আর startup-এ সব জোড়া লাগানো
  • TypeScript interface দিয়ে repository-র চুক্তি লেখা আর Vitest-এ service test করা
  • Container কখন লাগে, transaction কোথায় আটকায়, আর কখন তিন স্তর বাড়াবাড়ি

১ · এক ফাইলের route কেন একদিন আর চলে না

পর্ব ২৫ থেকে পর্ব ২৮ পর্যন্ত আপনি Express-এর route, middleware, যাচাই আর error handling দেখেছেন। সব কোড এক ফাইলে রাখলেও প্রথম মাসে কোনো সমস্যা হয় না। বাজারঘরের POST /orders route-টি ত্রিশ-চল্লিশ লাইনের, আর পুরো ছবি এক পাতায় দেখা যায়।

তারপর দোকান চলতে শুরু করে। মালিক চান admin panel থেকেও অর্ডার বসাতে। হিসাব বিভাগ চান রাতে একটি নির্দিষ্ট সময়ে নিজে থেকে চলা cron job, যা বাকি অর্ডার মিলিয়ে দেখবে। ঈদের কুপন আসে, আসে নতুন delivery charge. আর একদিন কেউ জানতে চান, “১০০০ টাকার নিয়মটি ঠিক আছে তো?” এর উত্তর দিতে এখন একটি server চালু করতে হয়, database ভরতে হয়, আর HTTP request পাঠাতে হয়।

সমস্যাটি লাইনের সংখ্যা নয়। সমস্যা হলো তিন রকম জ্ঞান এক জায়গায় জড়িয়ে গেছে:

  • HTTP-র জ্ঞান: req.body, status 409, JSON.
  • দোকানের নিয়ম: stock-এর বেশি নয়, কুপনে ১০% ছাড়, ১০০০ টাকায় delivery ফ্রি।
  • Data-র জ্ঞান: কোন table, কোন SQL, কোন column-এর নাম।

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

এই ভাগের ধারণা আপনি পর্ব ০৪-এ দেখেছেন। সেখানে একটি ছোট কার্ট দিয়ে “কেন” বোঝা হয়েছে। এই পর্বে আমরা একটি সত্যিকারের Express 5 আর TypeScript প্রজেক্টে “কীভাবে” করব: একটি পূর্ণ order route ভাঙব, জোড়া লাগাব, test করব, আর শেষে দামটিও হিসাব করব। পর্ব ০৪-এ separation of concerns-এর যে ভিত্তি আছে, সেটি আর পুনরাবৃত্তি করব না।

Service Layer আর Repository নাম দুটি ব্যাপকভাবে ছড়ায় Martin Fowler-এর ২০০২ সালের Patterns of Enterprise Application Architecture বই থেকে। বইয়ের Service Layer pattern-টি Randy Stafford-এর লেখা, আর Repository লিখেছেন Edward Hieatt ও Rob Mee. ২০০৩ সালে Eric Evans-এর Domain-Driven Design বই Repository-কে আরও পরিচিত করে। “Dependency Injection” নামটি এসেছে Fowler-এর জানুয়ারি ২০০৪-এর একটি প্রবন্ধ থেকে।

২ · চারটি স্তর — কোন কোড কোথায় যাবে

আমরা চারটি স্তর নেব। পর্ব ০৪-এর তিন স্তরের সাথে এখানে route আলাদা করে গোনা হলো, কারণ Express-এ ঠিকানার তালিকা আর HTTP-র কাজ আলাদা ফাইলে রাখা সুবিধার।

স্তরযে প্রশ্নের উত্তর দেয়যা চেনেযা চেনে না
Routeকোন method আর path, কোন middleware?URL, controllerনিয়ম, data
Controllerকী এলো, কী ফেরত যাবে?req, res, schema, serviceSQL, ছাড়ের হিসাব
Serviceদোকানের নিয়ম কী?order, stock, কুপন, repository-র চুক্তিreq, status code, SQL
RepositoryData কোথায়, কীভাবে আসবে?table, SQL, ORMকুপন, HTTP

একটি লেখা কোন স্তরে যাবে, সেটি ঠিক করতে তিনটি সহজ প্রশ্ন করুন।

তিন প্রশ্নের পরীক্ষা

১. কথাটি দোকানের মালিক প্রযুক্তির নাম ছাড়াই বলতে পারেন? তাহলে এটি business logic, service-এ যাবে। ২. ওয়েবের বদলে একটি cron job ডাকলেও কি এই কোড লাগবে? না লাগলে এটি HTTP-র কাজ, controller-এ যাবে। ৩. Database বদলালে কি এই লাইন বদলাবে? বদলালে repository-তে যাবে।

কিছু কাজ সীমানায় পড়ে, আর সেগুলো নিয়েই ভুল বেশি হয়:

কাজকোথায়কেন
qty পূর্ণসংখ্যা কি নাController বা middleware, Zod দিয়েআকারের validation, দরজার পাহারা
qty stock-এর বেশি কি নাServiceএটি নিয়ম, data দেখে ঠিক হয়
OUT_OF_STOCK থেকে 409 বানানোCentral error handlerStatus হলো HTTP-র ভাষা
SMS পাঠানোর সিদ্ধান্তService“অর্ডার হলে জানান” একটি নিয়ম
SMS কোন gateway দিয়ে যাবেService-এর বাইরে, আলাদা adapterএটি প্রযুক্তির সিদ্ধান্ত
“এই অর্ডার এই ক্রেতার কি না”ServiceRoute বদলালেও নিয়ম থাকতে হবে (পর্ব ৪৮)

এবার বাজারঘরের একটি অর্ডার চার স্তরের ভেতর দিয়ে যেতে দেখুন। ক্রেতা ২ প্যাকেট ৫ কেজির চাল নিচ্ছেন, কুপন EID10. চালের দাম ৪২,০০০ পয়সা, stock 3. টাকা সবসময় পূর্ণসংখ্যা পয়সায় রাখা হয়েছে। প্রতিটি ধাপে কোন স্তর কতটুকু জানে, সেটি খেয়াল করুন।

  1. ClientPOSTক্রেতা
  2. Route/ordersঠিকানা
  3. ControllercreateHTTP
  4. Serviceplaceনিয়ম
  5. RepositorySQLdata
Client পাঠালHTTP request
POST /orders
content-type: application/json

{"customerId":"c1",
 "items":[{"productId":"chal-5kg","qty":2}],
 "coupon":"EID10"}
Routeএক লাইনের কাজ
app.use('/orders', createOrderRouter(controller));
router.post('/', controller.create);
Controllerআকার যাচাই, তারপর service
const input = placeOrderSchema.parse(req.body);
// qty: 2 পূর্ণসংখ্যা, ঠিক আছে
const receipt = await service.place(input);
Serviceহিসাব, HTTP নেই
subtotal = 2 * 42000           = 84000
discount = floor(84000 / 10)   = 8400
after    = 84000 - 8400        = 75600
delivery = 75600 < 100000 তাই  = 6000
total    = 75600 + 6000        = 81600
Repositoryparameterized query
UPDATE products SET stock = stock - ?
 WHERE id = ? AND stock >= ?
-- 2, 'chal-5kg', 2  →  stock 3 থেকে 1
INSERT INTO orders (customer_id, total_paisa, lines)
VALUES (?, ?, ?)
-- নতুন order id = 1
Controller ফেরত দিলসত্যিকারের উত্তর
HTTP/1.1 201 Created
Content-Type: application/json; charset=utf-8

{"id":1,"subtotal":84000,"discount":8400,
 "delivery":6000,"total":81600}
  1. ধাপ ১ — Client. Request-এ আছে ক্রেতা c1, পণ্য chal-5kg, সংখ্যা 2 আর কুপন. Server এখনো কিছু বিশ্বাস করেনি।
  2. ধাপ ২ — Route. Method আর path মিলিয়ে সঠিক controller বেছে নেওয়া হলো। এখানে কোনো সিদ্ধান্ত নেই, শুধু ঠিকানার তালিকা।
  3. ধাপ ৩ — Controller. Zod দেখল qty একটি পূর্ণসংখ্যা, আর 1-এর কম নয়। ভুল হলে এখানেই 400 ফিরত। Service-এর কাছে যায় একটি পরিষ্কার object, req নয়।
  4. ধাপ ৪ — Service. মোট 84000, ছাড় 8400, বাকি 75600. এটি ১০০০ টাকার (100000 পয়সা) কম, তাই delivery 6000. শেষ হিসাব 81600. এখানে status code বা SQL-এর একটি অক্ষরও নেই।
  5. ধাপ ৫ — Repository. AND stock >= ? শর্তসহ stock 3 থেকে 1 হলো। মান আলাদা করে পাঠানো হয়েছে, তাই SQL injection-এর পথ বন্ধ। Repository ছাড়ের কথা কিছুই জানে না।
  6. ধাপ ৬ — ফিরে আসা। Service SMS-এর কাজ শেষ করে receipt ফেরত দিল, আর controller সেটি 201 দিয়ে পাঠাল। ডাক নিচে নেমেছিল, ফল ওপরে উঠল। উত্তরটি আমাদের চালানো server-এর হুবহু উত্তর।

৩ · মোটা order route ভাঙা — ধাপে ধাপে

নিচে সেই আসল route. এটি আমাদের পরীক্ষার প্রজেক্টে সত্যিই চলেছে, আর SQL parameterized হওয়ায় নিরাপদও। তবে এটি একটি fat controller: এক ফাংশনে ইনপুট যাচাই, stock, ছাড়, delivery, SQL, SMS আর HTTP-র উত্তর।

app.post('/orders', (req, res) => {
  const { customerId, items, coupon } = req.body ?? {};
  if (typeof customerId !== 'string' || !customerId ||
      !Array.isArray(items) || items.length === 0) {
    return res.status(400).json({ error: 'BAD_INPUT' });
  }
  const lines = [];
  let subtotal = 0;
  for (const item of items) {
    if (!Number.isInteger(item?.qty) || item.qty < 1) {
      return res.status(400).json({ error: 'BAD_INPUT' });
    }
    const p: any = db.prepare('SELECT * FROM products WHERE id = ?').get(item.productId);
    if (!p) return res.status(404).json({ error: 'PRODUCT_NOT_FOUND' });
    if (item.qty > p.stock) return res.status(409).json({ error: 'OUT_OF_STOCK' });
    subtotal += item.qty * p.price_paisa;
    lines.push({ productId: p.id, qty: item.qty, unitPaisa: p.price_paisa });
  }
  if (coupon !== undefined && coupon !== 'EID10') {
    return res.status(422).json({ error: 'BAD_COUPON' });
  }
  const discount = coupon === 'EID10' ? Math.floor(subtotal / 10) : 0;
  const delivery = subtotal - discount >= 100000 ? 0 : 6000;
  const total = subtotal - discount + delivery;
  for (const l of lines) {
    db.prepare('UPDATE products SET stock = stock - ? WHERE id = ?').run(l.qty, l.productId);
  }
  const r = db.prepare('INSERT INTO orders (customer_id, total_paisa, lines) VALUES (?, ?, ?)')
    .run(customerId, total, JSON.stringify(lines));
  const id = Number(r.lastInsertRowid);
  sms('order ' + id + ' total ' + total);
  res.status(201).json({ id, subtotal, discount, delivery, total });
});

এখন ভাঙার পালা। Refactoring মানে বাইরের আচরণ না বদলে ভেতরের গঠন বদলানো। ঝুঁকি হলো, কিছু একটা চুপচাপ বদলে যেতে পারে। তাই প্রথম কাজ কোড সরানো নয়, প্রথম কাজ একটি জাল পাতা: characterization test. এই test বলে না কোডটি কেমন হওয়া উচিত। এটি শুধু লিখে রাখে কোডটি এখন কী করছে। Michael Feathers তাঁর ২০০৪ সালের Working Effectively with Legacy Code বইয়ে এই ধারণাটি জনপ্রিয় করেন। Code smell আর refactoring-এর সাধারণ তত্ত্ব আছে Code smell ও refactoring পর্বে।

আমাদের জালটি একটি ছোট script. সে একই ৭টি request মোটা route আর নতুন গঠন, দুই জায়গাতেই পাঠিয়ে উত্তর মেলায়। নিচের ধাপগুলোতে ব্যবসার একটি নিয়ম route থেকে service-এ সরতে দেখুন।

fat-app.tsHTTP + নিয়ম + SQL
order.service.tsনিয়ম এখানে
error-handler.tscode থেকে status
নতুন ডাকের জায়গাHTTP, cron, admin
আগে: route-এর ভেতরেনিয়ম আর HTTP এক লাইনে
if (item.qty > p.stock)
  return res.status(409).json({ error: 'OUT_OF_STOCK' });
জাল পাতাকোড সরানোর আগের ছবি
201 {"id":1,"subtotal":84000,...}
409 {"error":"OUT_OF_STOCK"}
404 {"error":"PRODUCT_NOT_FOUND"}
...
৭টি উত্তর লিখে রাখা হলো
Serviceres চলে গেল
if (item.qty > p.stock) throw new OrderError('OUT_OF_STOCK');

// error-handler.ts
const STATUS = { PRODUCT_NOT_FOUND: 404, OUT_OF_STOCK: 409,
                 BAD_COUPON: 422 };
ServiceMagic number নাম পেল
const FREE_DELIVERY_FROM = 100000;
const DELIVERY_PAISA = 6000;
const delivery =
  subtotal - discount >= FREE_DELIVERY_FROM ? 0 : DELIVERY_PAISA;
আবার মেলানোসত্যিকারের ফল
same 409 {"error":"OUT_OF_STOCK"}
...
7/7 responses identical
  1. ধাপ ১ — শুরু। “Stock-এর বেশি নয়” নিয়মটি 409-এর সাথে একই লাইনে বাঁধা। এই নিয়ম HTTP ছাড়া ডাকার কোনো উপায় নেই।
  2. ধাপ ২ — জাল পাতা। কোড ছোঁয়ার আগে ৭টি request-এর উত্তর ধরে রাখা হলো, যার মধ্যে আছে 201, 409, 404, 400 আর 422. ভুল ইনপুটগুলোও রাখা জরুরি।
  3. ধাপ ৩ — নিয়ম সরানো। Service আর res চেনে না, সে একটি অর্থবহ error ছোড়ে। কোন error কোন status পাবে, সেটি গেল central error handler-এ। পর্ব ২৮-এর নকশাটি এখানেই কাজে লাগল।
  4. ধাপ ৪ — নাম দেওয়া। 100000 আর 6000 এখন নাম পেল। পরের বার মালিক সীমা বদলাতে চাইলে একটি লাইন বদলালেই হবে।
  5. ধাপ ৫ — আবার মেলানো। ৭টির ৭টি উত্তর হুবহু এক। আচরণ বদলায়নি, কিন্তু নিয়মটি এখন HTTP ছাড়াও ডাকা যায়। Admin panel আর cron job একই service ডাকতে পারবে।

পুরো কাজের ক্রমটি এমন: প্রথমে জাল, তারপর repository, তারপর service, তারপর controller, শেষে route আর startup-এ জোড়া। প্রতিটি ধাপের পরে জাল আবার চালানো হয়। নিচের ছোট সংস্করণটি নিজে চালিয়ে দেখুন। এখানে HTTP-র জায়গায় একটি সাধারণ object আছে, আর দুই গঠনে একই request যাচ্ছে।

const newDb = () => ({ stock: { "chal-5kg": 3 }, paisa: { "chal-5kg": 42000 }, orders: [] });
const fail = (status, error) => ({ status, json: { error } });
const off = (sub, c) => (c === "EID10" ? Math.floor(sub / 10) : 0);
const finalTotal = (s, c) => s - off(s, c) + (s - off(s, c) >= 100000 ? 0 : 6000);
// আগে: HTTP-র উত্তর, নিয়ম আর data এক ফাংশনে
function fatHandler(db, { id, qty, coupon }) {
  if (!(qty >= 1)) return fail(400, "BAD_INPUT");
  if (qty > db.stock[id]) return fail(409, "OUT_OF_STOCK");
  const total = finalTotal(qty * db.paisa[id], coupon);
  db.stock[id] -= qty; db.orders.push({ id, qty, total });
  return { status: 201, json: { total } };
}
// পরে: repository, service আর controller আলাদা
const makeRepo = (db) => ({
  stockOf: (id) => db.stock[id], paisaOf: (id) => db.paisa[id],
  take(id, qty) { db.stock[id] -= qty; }, save(o) { db.orders.push(o); },
});
function place(repo, { id, qty, coupon }) {         // service: HTTP চেনে না
  if (qty > repo.stockOf(id)) throw new Error("OUT_OF_STOCK");
  const total = finalTotal(qty * repo.paisaOf(id), coupon);
  repo.take(id, qty); repo.save({ id, qty, total });
  return { total };
}
function controller(repo, body) {                   // controller: শুধু HTTP
  if (!(body.qty >= 1)) return fail(400, "BAD_INPUT");
  try { return { status: 201, json: place(repo, body) }; }
  catch (e) { return fail(409, e.message); }
}
const a = newDb(), b = newDb(), repo = makeRepo(b), id = "chal-5kg";
for (const c of [{ id, qty: 2, coupon: "EID10" }, { id, qty: 2 }, { id, qty: 0 }]) {
  const x = JSON.stringify(fatHandler(a, c)), y = JSON.stringify(controller(repo, c));
  console.log(x === y ? "same" : "DIFF", y);
}
console.log("stock:", a.stock[id], b.stock[id], "orders:", a.orders.length, b.orders.length);

আউটপুট:

same {"status":201,"json":{"total":81600}}
same {"status":409,"json":{"error":"OUT_OF_STOCK"}}
same {"status":400,"json":{"error":"BAD_INPUT"}}
stock: 1 1 orders: 1 1

তিনটি উত্তরই মিলেছে, আর শেষের লাইন বলছে দুই database-এর শেষ অবস্থাও এক: stock 1, অর্ডার ১টি। উত্তর মেলানোই যথেষ্ট নয়, পার্শ্বপ্রতিক্রিয়াও মেলাতে হয়। দ্বিতীয় request-এ stock 1-এর বিপরীতে 2 চাওয়া হয়েছে, তাই 409.

এবার আসল Express প্রজেক্ট। পূর্ণ route ভেঙে একই ৭টি request দুই server-এ পাঠানো হলো। সত্যিকারের run. লেখার সময়ের সংস্করণ: Node 24.15.0, Express 5.3.0, TypeScript 7.0.2, tsx 4.23.15, Zod 4.6.7. Data রাখা হয়েছে Node-এর নিজস্ব node:sqlite module-এ, যাতে বাড়তি কিছু লাগে না। Prisma আসবে পর্ব ৩৯-এ।

~/bazarghar$ npx tsx scripts/compare.ts
same 201 {"id":1,"subtotal":84000,"discount":8400,"delivery":6000,"total":81600}
same 409 {"error":"OUT_OF_STOCK"}
same 201 {"id":2,"subtotal":109000,"discount":0,"delivery":0,"total":109000}
same 404 {"error":"PRODUCT_NOT_FOUND"}
same 400 {"error":"BAD_INPUT"}
same 422 {"error":"BAD_COUPON"}
same 400 {"error":"BAD_INPUT"}
7/7 responses identical

ভাঙার পরে route, controller আর service এমন দাঁড়াল। Controller-এ try/catch নেই, কারণ Express 5-এ async handler-এর rejected promise নিজে থেকেই error middleware-এ পৌঁছায়। Express-এর docs-এ এই আচরণ লেখা আছে।

export function createOrderRouter(controller: OrderController) {
  const router = Router();
  router.post('/', controller.create);
  return router;
}
export function createOrderController(service: OrderService) {
  return {
    async create(req: Request, res: Response) {
      const input = placeOrderSchema.parse(req.body);
      const receipt = await service.place(input);
      res.status(201).json(receipt);
    },
  };
}
export function createOrderService({ repo, notify }: OrderServiceDeps) {
  return {
    async place(input: PlaceOrderInput): Promise<Receipt> {
      const lines: OrderLine[] = [];
      let subtotal = 0;
      for (const item of input.items) {
        const p = await repo.findProduct(item.productId);
        if (!p) throw new OrderError('PRODUCT_NOT_FOUND');
        if (item.qty > p.stock) throw new OrderError('OUT_OF_STOCK');
        subtotal += item.qty * p.pricePaisa;
        lines.push({ productId: p.id, qty: item.qty, unitPaisa: p.pricePaisa });
      }
      // ... কুপন, ছাড় আর delivery-র হিসাব ...
      for (const l of lines) {
        if (!(await repo.reserveStock(l.productId, l.qty))) {
          throw new OrderError('OUT_OF_STOCK');
        }
      }
      const order = await repo.saveOrder({
        customerId: input.customerId, lines, totalPaisa: total,
      });
      await notify('order ' + order.id + ' total ' + total);
      return { id: order.id, subtotal, discount, delivery, total };
    },
  };
}
const STATUS: Record<OrderErrorCode, number> = {
  PRODUCT_NOT_FOUND: 404, OUT_OF_STOCK: 409, BAD_COUPON: 422,
};

export function errorHandler(err: unknown, _req: Request, res: Response, _next: NextFunction) {
  if (err instanceof ZodError) return res.status(400).json({ error: 'BAD_INPUT' });
  if (err instanceof OrderError) return res.status(STATUS[err.code]).json({ error: err.code });
  console.error(err);
  res.status(500).json({ error: 'INTERNAL' });
}

Service-এর নিয়মে একটি সূক্ষ্ম পরিবর্তন আছে। মোটা route stock কমাত শর্ত ছাড়াই। নতুন reserveStock-এ SQL নিজেই শর্ত দেখে: WHERE ... AND stock >= ?. একসাথে দুজন ক্রেতা এলে পড়া আর লেখার মাঝখানের ফাঁক দিয়ে যে race condition হতে পারে, এই শর্ত তা একটি পণ্যের জন্য ঠেকায়। পুরো অর্ডারের সুরক্ষা আসবে transaction থেকে, সে কথা অংশ ৮-এ।

৪ · Folder আর নির্ভরতার দিক

পর্ব ০৪-এ আপনি layer অনুযায়ী আর feature অনুযায়ী folder-এর তুলনা দেখেছেন। প্রশ্নটি সহজ: একটি বদলের জন্য কয়টি folder ঘুরতে হয়? আমাদের প্রজেক্টের আসল গঠন এটি:

src/
  main.ts                 চালু করা
  compose.ts              সব জোড়া লাগানো
  app.ts                  Express অ্যাপ
  http/
    error-handler.ts
  db/
    sqlite.ts
  features/
    orders/
      order.routes.ts
      order.controller.ts
      order.schema.ts
      order.service.ts
      order.service.test.ts
      order.types.ts
      order.repository.ts          চুক্তি
      order.repository.sqlite.ts   আসল
      order.repository.memory.ts   test-এর জন্য
প্রশ্নLayer অনুযায়ীFeature অনুযায়ী
Order-এর একটি বদল৩–৪টি folder ঘুরতে হয়একটি folder
ছোট প্রজেক্টেসহজ ও পরিচিতএকটু বাড়তি মনে হতে পারে
পরে আলাদা করাকঠিন, কোড ছড়ানোসহজ, সীমানা আগেই আছে
দেখা যায় যেপ্রযুক্তির ভাগদোকানের কাজের ভাগ

Feature folder আসলে modular monolith-এর ছোট রূপ। তবে দুই feature-এর সীমানায় নিয়ম মানতে হয়: cart সরাসরি order-এর repository ছোঁবে না, সে order-এর service ডাকবে। না হলে সীমানা শুধু folder-এর নামেই থাকে।

তীর কোন দিকে

Folder ঠিক করে ফাইল কোথায় থাকবে। আসল সিদ্ধান্তটি অন্য: কে কাকে import করবে। সাধারণ layered architecture-এ তীর নিচের দিকে যায়। ফলে service সরাসরি SQLite-এর কোড চেনে, আর database বদলালে service-ও ছুঁতে হয়।

আমরা একটি ছোট চালাকি করেছি। চুক্তিটি (OrderRepository interface) service-এর পাশে থাকে, আর আসল SQLite class সেই চুক্তি মেনে চলে। ফলে নিচের স্তরের তীর ওপরের দিকে ঘুরে যায়। এই নীতির নাম dependency inversion. ওপরের স্তর নিচের স্তরের খুঁটিনাটির ওপর নির্ভর করে না, দুজনেই একটি চুক্তির ওপর নির্ভর করে। পুরো তত্ত্ব আছে SOLID: LSP, ISP আর DIP পর্বে, আর সব design pattern-এর পরিচয় আছে Design pattern পর্বে।

নির্ভরতার দিক

Import arrows point towards the rules; both repositories depend on the contract

Route থেকে service পর্যন্ত তীর নিচে, আর দুটি repository থেকে তীর ওপরে চুক্তির দিকে Route Controller Service OrderRepository চুক্তি (interface), কোড নেই SQLite repo আসল database Memory repo test আর demo compose.ts কোন repo বসবে, শুধু এখানেই ঠিক হয় implements
ছবি ২৯.১ — Service চুক্তি চেনে, আসল class চেনে না। দুটি repository-ই ওপরের দিকে চুক্তির ওপর নির্ভর করে।

এই দাবিটি চোখে না দেখে সত্যিই যাচাই করা যায়। সত্যিকারের run:

~/bazarghar$ grep -l "express" src/features/orders/*.ts
src/features/orders/order.controller.ts
src/features/orders/order.routes.ts

~/bazarghar$ grep -lE "SELECT|node:sqlite" src/features/orders/*.ts src/http/*.ts src/app.ts src/compose.ts
src/features/orders/order.repository.sqlite.ts

Order feature-এর ভেতরে Express আছে শুধু দুটি ফাইলে, আর SQL পুরো প্রজেক্টের এই তালিকায় শুধু একটিতে। order.service.ts দুই তালিকার কোনোটিতেই নেই। এই নিয়ম বড় প্রজেক্টে CI-তে একটি lint rule বা ছোট script দিয়ে পাহারা দেওয়া যায়। না হলে ব্যস্ত দিনে কেউ একটি import বসিয়ে দেবেন, আর সীমানা ভাঙবে। Circular dependency-ও এই একই তীরের ভুল থেকে আসে।

আরও গভীরে: order.service.ts-এ repository এসেছে import type দিয়ে। Compile করার পরে আসল ফাইলে এর চিহ্নও থাকে না। সত্যিকারের tsc output-এ order.service.js-এর একমাত্র import হলো ./order.types.js, আর order.repository.js ফাইলটি শুধু export {};. একে বলে type erasure. চলার সময়ে নির্ভরতাটি সত্যিই নেই। এই একই কারণে NestJS-এর মতো container interface দেখে কিছু খুঁজে পায় না, তাকে আলাদা একটি token দিতে হয়।

৫ · Dependency injection — শুধু function parameter

নামটি ভারী, কাজটি সরল। Dependency injection মানে একটি অংশ তার দরকারি জিনিস নিজে বানায় না, বাইরে থেকে পায়। তফাতটি একটি লাইনেই দেখা যায়:

// আগে: service নিজেই database বেছে নিয়েছে। test-এ বদলানোর পথ নেই।
import { db } from '../db';
export async function place(input) { db.prepare('...'); }

// পরে: যা লাগবে, parameter-এ চাইছে। কে দেবে, সেটি service জানে না।
export function createOrderService({ repo, notify }: OrderServiceDeps) { ... }

রেস্তোরাঁর বাবুর্চি নিজে বাজারে যান না। তিনি বলে দেন: “আমার চাল, ডাল আর তেল লাগবে।” কে কোন দোকান থেকে আনবেন, সেটি ভাঁড়ারির কাজ। কারওয়ান বাজারের বদলে মিরপুর থেকে এলেও রান্না বদলায় না। আর রেসিপি পরীক্ষা করার সময় অল্প একটু নমুনা মাল দিলেই চলে। এই নমুনা মালই test-এর নকল repository.

এখানে কোনো library নেই, কোনো decorator নেই। শুধু JavaScript-এর closure: createOrderService যে object ফেরত দেয়, তার ভেতরের ফাংশন repo-কে মনে রাখে। সব object কোথাও একবার বানাতে হয়। সেই এক জায়গার নাম composition root. আমাদের ক্ষেত্রে এটি compose.ts. নিচের ধাপগুলোতে server চালু হওয়ার মুহূর্তটি দেখুন।

1const db = openDb();2function compose(db, sms) {3  const repo = new SqliteOrderRepository(db);4  const service = createOrderService({ repo, notify });5  const orderController = createOrderController(service);6  return createApp({ orderController });7}8app.listen(4329, '127.0.0.1');
কে তৈরি হলো, কাকে চেনে
db
repo
service
controller
app
  1. ধাপ ১ — সবচেয়ে নিচের জিনিস। Database connection আগে, কারণ এটি কারও ওপর নির্ভর করে না।
  2. ধাপ ২ — Repository. Repository db পেল। এটিই একমাত্র জায়গা, যেখানে SqliteOrderRepository নামটি লেখা। Test-এ এখানে বসে MemoryOrderRepository.
  3. ধাপ ৩ — Service. Service পেল repo আর notify. সে শুধু জানে এগুলো চুক্তি মানে। আসলে কে, তা জানে না।
  4. ধাপ ৪ — Controller. Controller পেল service. তৈরির ক্রমটি নির্ভরতার উল্টো দিকে: নিচের জিনিস আগে, ওপরের জিনিস পরে।
  5. ধাপ ৫ — অ্যাপ। Express অ্যাপ controller পেল, আর route ও error handler বসল। এখনো কোনো request আসেনি।
  6. ধাপ ৬ — Listen. এবার server শুনতে শুরু করল। টার্মিনালের লেখাটি সত্যিকারের run-এর। একটি request এলে ডাক ঠিক উল্টো ক্রমে নিচে নামবে।

লক্ষ করুন, new আর create... সব এই একটি ফাইলে। বাকি কোডে কেউ কাউকে তৈরি করে না। নিচের block-এ একই service-এর নিচে দুটি ভিন্ন repository বসিয়ে দেখুন। একটি Map ব্যবহার করে, আরেকটি array.

// service একবারই লেখা; repository চেনে শুধু তিনটি method দিয়ে
function createService(repo) {
  return {
    place(id, qty) {
      const p = repo.find(id);
      if (!p || qty > p.stock) return "REJECTED";
      repo.setStock(id, p.stock - qty);
      return `OK total=${qty * p.paisa} left=${repo.find(id).stock}`;
    },
    report: () => repo.all().map((p) => `${p.id}:${p.stock}`).join(" "),
  };
}
const seed = [{ id: "chal", paisa: 42000, stock: 3 }, { id: "dal", paisa: 14000, stock: 10 }];

function mapRepo() {                  // Map: id দিয়ে সরাসরি খোঁজা
  const rows = new Map(seed.map((p) => [p.id, { ...p }]));
  return {
    find: (id) => rows.get(id) ?? null,
    setStock: (id, n) => { rows.get(id).stock = n; },
    all: () => [...rows.values()],
  };
}
function arrayRepo() {                // array: প্রতিবার সারি ধরে খোঁজা
  const rows = seed.map((p) => ({ ...p }));
  return {
    find: (id) => rows.find((p) => p.id === id) ?? null,
    setStock: (id, n) => { rows.find((p) => p.id === id).stock = n; },
    all: () => [...rows],
  };
}
for (const [name, make] of [["map", mapRepo], ["array", arrayRepo]]) {
  const s = createService(make());    // শুধু এখানেই ঠিক হয় কোন repository
  console.log(name, s.place("chal", 2), "|", s.place("chal", 2), "|", s.report());
}

আউটপুট:

map OK total=84000 left=1 | REJECTED | chal:1 dal:10
array OK total=84000 left=1 | REJECTED | chal:1 dal:10

দুটি লাইন হুবহু এক, অথচ ভেতরের গঠন আলাদা। Service-এর একটি অক্ষরও বদলায়নি। Stock 3 থেকে 2 নেওয়ায় বাকি 1, তাই দ্বিতীয়বার 2 চাওয়া REJECTED. দুটি repository-ই seed-এর কপি রাখে, তাই একটির বদল আরেকটিতে যায় না। আসল প্রজেক্টেও memory আর SQLite repository দিয়ে একই ফল এসেছে:

~/bazarghar$ npx tsx scripts/swap.ts
memory {"id":1,"subtotal":84000,"discount":8400,"delivery":6000,"total":81600}
sqlite {"id":1,"subtotal":84000,"discount":8400,"delivery":6000,"total":81600}

DI শুধু database-এর জন্য নয়। যা কিছু বাইরের জগৎ ছোঁয় বা অনিশ্চিত, সেটি parameter-এ নেওয়ার যোগ্য: SMS gateway, ঘড়ি (now()), দৈব সংখ্যা, payment gateway. আমাদের service-এ notify ঠিক এমন একটি। তবে সবকিছু inject করবেন না। Math.floor বা একটি pure function নকল করার কোনো অর্থ নেই।

৬ · TypeScript interface — repository-র চুক্তি

JavaScript-এ repository বদলানোর সময় ভুল ধরা পড়ে চলতে গিয়ে। TypeScript-এর interface এই চুক্তিটি লিখিত করে দেয়। ভাষার মূল কথা আছে পর্ব ০৬-এ। আমাদের চুক্তিটি মাত্র নয় লাইনের ফাইল:

import type { NewOrder, Order, Product } from './order.types.ts';

// চুক্তি: service শুধু এটুকুই চেনে
export interface OrderRepository {
  findProduct(id: string): Promise<Product | null>;
  reserveStock(id: string, qty: number): Promise<boolean>;
  saveOrder(order: NewOrder): Promise<Order>;
  findOrder(id: number): Promise<Order | null>;
}

চারটি সিদ্ধান্ত খেয়াল করুন:

  • সবকিছু Promise: SQLite-এর এই API synchronous, কিন্তু পরের database নাও হতে পারে। চুক্তি আসল প্রযুক্তির সীমা ফাঁস করে না।
  • না পেলে null: Repository জানে না “না পাওয়া” 404 কি না। সেই সিদ্ধান্ত service-এর।
  • ফেরত আসে দোকানের ভাষায়: pricePaisa, price_paisa নয়। Column-এর নাম repository-র বাইরে যায় না।
  • নামগুলো কাজের নাম: reserveStock, updateRow নয়।

নিচের block-টি strict mode-এ চলে। Service একটি interface চেনে, class নয়। null যাচাইয়ের পরে TypeScript নিজেই বোঝে p আর null নয়, একে বলে narrowing. ফলাফল একটি discriminated union.

interface Product { id: string; pricePaisa: number; stock: number }

interface OrderRepository {
  findProduct(id: string): Promise<Product | null>;
  reserveStock(id: string, qty: number): Promise<boolean>;
}
class MapRepository implements OrderRepository {
  private rows = new Map<string, Product>();
  constructor(seed: Product[]) { seed.forEach((p) => this.rows.set(p.id, { ...p })); }
  async findProduct(id: string) { return this.rows.get(id) ?? null; }
  async reserveStock(id: string, qty: number) {
    const p = this.rows.get(id);
    if (!p || p.stock < qty) return false;
    p.stock -= qty;
    return true;
  }
}

type Result = { ok: true; totalPaisa: number } | { ok: false; code: "NOT_FOUND" | "OUT_OF_STOCK" };

// service চেনে শুধু interface, কোনো class নয়
async function place(repo: OrderRepository, id: string, qty: number): Promise<Result> {
  const p = await repo.findProduct(id);
  if (p === null) return { ok: false, code: "NOT_FOUND" };   // এরপর p আর null নয়
  if (!(await repo.reserveStock(id, qty))) return { ok: false, code: "OUT_OF_STOCK" };
  return { ok: true, totalPaisa: qty * p.pricePaisa };
}

async function main() {
  const repo = new MapRepository([{ id: "chal", pricePaisa: 42000, stock: 3 }]);
  for (const [id, qty] of [["chal", 2], ["chal", 2], ["tel", 1]] as const) {
    console.log(id, qty, JSON.stringify(await place(repo, id, qty)));
  }
}
main();

আউটপুট:

chal 2 {"ok":true,"totalPaisa":84000}
chal 2 {"ok":false,"code":"OUT_OF_STOCK"}
tel 1 {"ok":false,"code":"NOT_FOUND"}

প্রথমবার stock 3 থেকে 2 গেল, দ্বিতীয়বার 1-এর বিপরীতে 2 চাওয়ায় ব্যর্থ, আর তৃতীয়বার পণ্যই নেই। এখানে error না ছুড়ে একটি মান ফেরত দেওয়া হয়েছে। Express প্রজেক্টে আমরা error ছুড়েছি, কারণ central handler আছে। দুটি পথই চলে, তবে এক প্রজেক্টে একটি পথ রাখুন।

এবার চুক্তি ভাঙলে কী হয়। একটি repository-তে findOrder লিখতে ভুলে গেলাম, আর আরেক জায়গায় service-কে আধখানা object দিলাম। npm run typecheck-এর সত্যিকারের দুটি ফল (TypeScript 7.0.2):

src/bad.ts(4,14): error TS2420: Class 'HalfRepository' incorrectly implements interface 'OrderRepository'.
  Property 'findOrder' is missing in type 'HalfRepository' but required in type 'OrderRepository'.

src/bad.ts(9,22): error TS2739: Type '{ findProduct: () => Promise<null>; }' is missing the following properties from type 'OrderRepository': reserveStock, saveOrder, findOrder

ভুলটি production-এ পৌঁছানোর আগেই ধরা পড়ল। তবে একটি সীমা মনে রাখুন। Interface শুধু আকার দেখে, আচরণ দেখে না। reserveStock সব সময় true ফেরত দিলেও type মিলবে। আচরণ মেলাতে দরকার contract test: একই test-গুলো দুটি repository-র ওপর চালানো।

const makers: [string, () => OrderRepository][] = [
  ['memory', () => new MemoryOrderRepository([/* chal-5kg, stock 3 */])],
  ['sqlite', () => new SqliteOrderRepository(openDb())],
];

describe.each(makers)('OrderRepository contract: %s', (_name, make) => {
  it('reserves stock only when enough is left', async () => {
    const repo = make();
    expect(await repo.reserveStock('chal-5kg', 2)).toBe(true);
    expect(await repo.reserveStock('chal-5kg', 2)).toBe(false);
    expect((await repo.findProduct('chal-5kg'))?.stock).toBe(1);
  });
});

এখন নকল repository আসলের মতো আচরণ করে, এই দাবিটি আর বিশ্বাসের বিষয় নয়। প্রতিবার test চালালেই সেটি যাচাই হয়। একটি কথা: সব table-এর জন্য একটি generic Repository<T> বানানো জনপ্রিয় অভ্যাস, কিন্তু order আর product-এর কাজ এক নয়। সাধারণত কাজের নামে লেখা ছোট interface-ই বেশি পরিষ্কার। আমাদের ছোট প্রজেক্টে একটি interface-এই পণ্য আর অর্ডার রাখা হয়েছে। বড় হলে এটি দুটি ভাগ হওয়া স্বাভাবিক।

৭ · নকল repository দিয়ে service test

পুরো পরিশ্রমের সবচেয়ে বড় ফল এখানে। মোটা route-এ “৮৪,০০০ পয়সায় ছাড় কত” যাচাই করতে server আর database লাগত। এখন service-কে একটি নকল repository দিলেই হয়। এমন বদলি জিনিসের সাধারণ নাম test double. এর নানা রূপ (stub, spy, mock, fake) নিয়ে তত্ত্ব আছে Test double পর্বে। আগে হাতে একটি বানিয়ে দেখুন, কোনো test library ছাড়াই।

const assert = require("node:assert/strict");
function createOrderService({ repo, notify }) {
  return {
    async place({ productId, qty, coupon }) {
      const p = await repo.findProduct(productId);
      if (qty > p.stock) throw new Error("OUT_OF_STOCK");
      const subtotal = qty * p.pricePaisa;
      const discount = coupon === "EID10" ? Math.floor(subtotal / 10) : 0;
      const delivery = subtotal - discount >= 100000 ? 0 : 6000;
      const total = subtotal - discount + delivery;
      await repo.reserveStock(productId, qty);
      const order = await repo.saveOrder({ productId, qty, total });
      await notify(`order ${order.id} total ${total}`);
      return { id: order.id, subtotal, discount, delivery, total };
    },
  };
}
// নকল repository: database নেই, শুধু কে কী ডাকল তার হিসাব
function fakeRepo(stock) {
  const calls = [];
  return { calls,
    findProduct: async () => ({ pricePaisa: 42000, stock }),
    reserveStock: async (id, qty) => { calls.push(`reserve ${qty}`); stock -= qty; return true; },
    saveOrder: async (o) => { calls.push("save"); return { id: 7, ...o }; },
  };
}
(async () => {
  const repo = fakeRepo(3), sms = [];
  const s = createOrderService({ repo, notify: async (m) => { sms.push(m); } });
  const r = await s.place({ productId: "chal-5kg", qty: 2, coupon: "EID10" });
  assert.deepEqual(r, { id: 7, subtotal: 84000, discount: 8400, delivery: 6000, total: 81600 });
  await assert.rejects(s.place({ productId: "chal-5kg", qty: 2 }), /OUT_OF_STOCK/);
  assert.deepEqual(repo.calls, ["reserve 2", "save"]);
  console.log("3 checks passed; calls:", repo.calls, "sms:", sms);
})();

আউটপুট:

3 checks passed; calls: [ 'reserve 2', 'save' ] sms: [ 'order 7 total 81600' ]

ব্যর্থ দ্বিতীয় অর্ডারের পরেও calls-এ দুটি জিনিসই আছে, আর SMS একটিই। মানে stock শেষ হলে কিছুই লেখা হয়নি, কোনো SMS-ও যায়নি। HTTP নেই, database নেই, তবু ব্যবসার নিয়ম পুরো যাচাই হলো। Assertion ব্যর্থ হলে প্রোগ্রাম এখানেই থেমে যেত।

আসল প্রজেক্টে এই কাজটি করে Vitest. Vitest-এর পুরো ব্যবহার আসবে পর্ব ৭৩-এ, আর test কৌশল পর্ব ৭২-এ। এখানে শুধু কাঠামোটি দেখুন। notify হিসেবে দেওয়া হয়েছে vi.fn, যা মনে রাখে তাকে কীভাবে ডাকা হলো।

function setup() {
  const repo = new MemoryOrderRepository(seed);
  const notify = vi.fn(async (_m: string) => {});
  return { repo, notify, service: createOrderService({ repo, notify }) };
}

it('out of stock: nothing saved, no sms', async () => {
  const { service, repo, notify } = setup();
  await expect(service.place({ customerId: 'c1', items: [chal(4)] }))
    .rejects.toMatchObject({ code: 'OUT_OF_STOCK' });
  expect(await repo.findOrder(1)).toBeNull();
  expect(notify).not.toHaveBeenCalled();
});

সত্যিকারের run (Vitest 5.0.5, Node 24.15.0). সময়গুলো এক কম্পিউটারের একবারের মাপ, কোনো সাধারণ ফল নয়:

~/bazarghar$ npx vitest run --reporter=verbose
 ✓ ...contract.test.ts > OrderRepository contract: memory > finds a product, or null 1ms
 ✓ ...contract.test.ts > OrderRepository contract: memory > reserves stock only when enough is left 0ms
 ✓ ...contract.test.ts > OrderRepository contract: memory > saves and reads an order 0ms
 ✓ ...contract.test.ts > OrderRepository contract: sqlite > finds a product, or null 1ms
 ✓ ...contract.test.ts > OrderRepository contract: sqlite > reserves stock only when enough is left 0ms
 ✓ ...contract.test.ts > OrderRepository contract: sqlite > saves and reads an order 0ms
 ✓ ...service.test.ts > order service > coupon: 10% discount, then delivery 2ms
 ✓ ...service.test.ts > order service > free delivery from 1000 taka after discount 0ms
 ✓ ...service.test.ts > order service > out of stock: nothing saved, no sms 1ms
 ✓ ...service.test.ts > order service > unknown coupon is rejected 0ms
 ✓ ...service.test.ts > order service > sends one sms after saving 0ms
 ✓ ...service.test.ts > order service > KNOWN GAP: save fails after stock is reserved 1ms

 Test Files  2 passed (2)
      Tests  12 passed (12)
   Duration  124ms

সবুজ test তখনই অর্থবহ, যখন ভুল হলে সে লাল হয়। তাই ইচ্ছা করে নিয়মটি নষ্ট করলাম: subtotal / 10-কে subtotal / 20 করলাম। সত্যিকারের ফল:

 FAIL  src/features/orders/order.service.test.ts > order service > coupon: 10% discount, then delivery
AssertionError: expected { id: 1, subtotal: 84000, …(3) } to deeply equal { id: 1, subtotal: 84000, …(3) }

- Expected
+ Received

  {
    "delivery": 6000,
-   "discount": 8400,
+   "discount": 4200,
    "id": 1,
    "subtotal": 84000,
-   "total": 81600,
+   "total": 85800,
  }
…
      Tests  1 failed | 5 passed (6)

Test ঠিক জায়গায় ধরল, আর পার্থক্যটি পয়সার হিসাবে দেখাল। তবে সীমাটিও সৎভাবে বলা দরকার। এই test দিয়ে প্রমাণ হয় না যে HTTP route ঠিকমতো জোড়া আছে, বা SQL সত্যিই চলে। সেই কাজ compare.ts আর contract test-এর, আর পূর্ণ integration test আসবে পর্ব ৭৫-এ। নকলের ওপর ভরসা মানে আসলকে ভুলে যাওয়া নয়।

৮ · দুটি সীমা — container আর transaction

Container কখন লাগে

compose.ts-এর চারটি লাইন আমরা হাতে লিখেছি। Object-এর সংখ্যা দুইশো হলে এই ফাইল বিরক্তিকর হয়ে ওঠে। তখন কাজে আসে DI container: একটি library, যাকে আপনি বলে দেন কোন জিনিসের কী লাগে, আর সে সঠিক ক্রমে সব বানিয়ে দেয়। Node-এর জগতে এর পরিচিত উদাহরণ NestJS. এই কোর্সে NestJS-এর আলাদা পর্ব নেই, তাই ছোট একটি সত্যিকারের run এখানেই দেখুন (NestJS 12.3.2):

const ORDER_REPOSITORY = Symbol('ORDER_REPOSITORY');

@Injectable()
class OrderService {
  constructor(@Inject(ORDER_REPOSITORY) private readonly repo: OrderRepository) {}
  total(id: string, qty: number) { return this.repo.findPrice(id) * qty; }
}

@Module({
  providers: [OrderService, { provide: ORDER_REPOSITORY, useClass: MemoryOrderRepository }],
})
class AppModule {}

async function main() {
  const app = await NestFactory.createApplicationContext(AppModule, { logger: false });
  console.log(app.get(OrderService).total('chal-5kg', 2));   // ছাপল 84000
  await app.close();
}
main();

এখানে কেউ new OrderService(...) লেখেনি। Decorator (@Injectable) দেখে container নিজে constructor-এর দরকার বুঝে নিয়েছে। Interface চলার সময় থাকে না, তাই ORDER_REPOSITORY নামের একটি আলাদা token লাগল। এটি inversion of control-এর একটি রূপ। Laravel-এর service container (পর্ব ৭০), Spring Boot (পর্ব ৭১) আর Angular-ও (পর্ব ৬৭) dependency injection-এর চিন্তা ব্যবহার করে।

প্রশ্নহাতে parameterContainer
ভুল জোড়া ধরা পড়েCompile-এর সময়েঅনেকটাই চালু হওয়ার সময়ে
কে কাকে পেল, পড়া যায়একটি সাধারণ ফাইলেDecorator আর module মিলিয়ে
Object অনেক হলেফাইল লম্বা হয়নিজে সামলায়
Scope আর lifecycleনিজে লিখতে হয়সাধারণত তৈরি থাকে
বাড়তি জিনিসকিছু নয়Library আর তার নিয়ম

এখানে সম্প্রদায় একমত নয়। অনেকে Node-এ হাতের জোড়াকেই যথেষ্ট মনে করেন, আবার বড় দল container-এর একই ধাঁচ পছন্দ করে। সৎ পরামর্শ: Express-এর ছোট বা মাঝারি প্রজেক্টে parameter দিয়ে শুরু করুন। যে framework container নিয়েই আসে, সেখানে তার পথেই চলুন।

Transaction কোথায় আটকায়

আমাদের service-এ একটি সত্যিকারের ফাঁক আছে, আর সেটি আমরা ইচ্ছা করে test-এ লিখে রেখেছি। Stock কমানো আর অর্ডার লেখা দুটি আলাদা repository ডাক। মাঝখানে কিছু ভাঙলে?

it('KNOWN GAP: save fails after stock is reserved', async () => {
  // ... saveOrder ইচ্ছা করে 'disk full' ছোড়ে ...
  await expect(s.place({ customerId: 'c1', items: [chal(2)] })).rejects.toThrow('disk full');
  expect((await repo.findProduct('chal-5kg'))?.stock).toBe(1); // ২টি হারিয়ে গেল
});

এই test পাস করেছে, আর সেটিই খারাপ খবর। Stock 3 থেকে 1 হয়েছে, কিন্তু কোনো অর্ডার নেই। ২ প্যাকেট চাল হিসাব থেকে উধাও। সমাধান হলো transaction: দুটি কাজ হয় একসাথে হবে, নয়তো একটিও না। ACID-এর তত্ত্ব আছে Transaction ও ACID পর্বে।

কিন্তু এখানেই স্তরের নকশায় একটি আসল টানাপোড়েন। Transaction শুরু করে database, অথচ “কোন কাজগুলো একসাথে” সেটি জানে service. আর দুটি service একই transaction-এ থাকতে হলে সমস্যা আরও বাড়ে, যেমন order service আর payment service. সাধারণ পথ দুটি: repository-তে একটি বড় placeOrder method রাখা, অথবা service-কে একটি withTransaction ধরনের সুবিধা inject করা। বাস্তব Prisma কোড আর race condition-এর পূর্ণ হিসাব আসবে পর্ব ৪৩-এ। আজ শুধু মনে রাখুন, স্তর ভাগ করলে এই সমস্যা নিজে থেকে যায় না, বরং চোখে পড়ে।

৯ · কখন তিন স্তর বাড়াবাড়ি

এবার দামের হিসাব। আমাদের প্রজেক্টের সত্যিকারের wc -l থেকে, test আর memory repository বাদ দিয়ে:

গঠনফাইললাইন
মোটা routefat-app.ts44
স্তরে ভাগ করাcontroller 14, routes 8, schema 10, service 46, types 23, চুক্তি 9, SQLite 39, error handler 15, app 12, compose 13189
189 ÷ 44 ≈ ৪.৩ গুণ লাইন, ১০টি ফাইল
একই ৭টি উত্তর

লাভটি লাইন কমানো নয়। লাভ হলো নিয়ম আলাদা test করা, repository বদলানো, আর এক জায়গায় কম বদল। অ্যাপে সেই লাভের দরকার না থাকলে এই ৪ গুণ শুধুই খরচ। পর্ব ৩৯-এর Prisma-র ওপরে আবার একটি repository বসানো নিয়েও বিতর্ক আছে, আর সে কথা ভাবনার প্রশ্নে আছে।

স্তর কমান

ছোট CRUD admin, এক সপ্তাহের prototype, কম নিয়মের internal tool, একজনের ছোট প্রজেক্ট। Route থেকে একটি ফাংশন ডাকাই যথেষ্ট।

নিয়ম প্রায় নেই, ঝুঁকি কম

Service যোগ করুন

একই নিয়ম দুই জায়গা থেকে লাগছে। অথবা নিয়মটি HTTP ছাড়া test করতে চান। টাকা বা stock-এর হিসাব থাকলে এই স্তর প্রায় সবসময় দাম ফেরত দেয়।

দ্বিতীয় caller এলেই

Repository যোগ করুন

একই query নানা জায়গায় ছড়িয়েছে। Query জটিল হয়েছে। অথবা নকল দিয়ে test করার সত্যিকারের প্রয়োজন আছে।

Query ছড়ালে

এই ধাপে ধাপে বাড়ার চিন্তার একটি পরিচিত নাম আছে YAGNI: যা এখন লাগছে না, সেটি এখন বানাবেন না। PoEAA বইয়ের Transaction Script ঠিক এই মোটা route-এর পরিপাটি রূপ: প্রতিটি কাজের জন্য একটি সোজা procedure. কম নিয়মের অ্যাপে এটি ভুল নয়, বরং সঠিক নকশা।

অতিরিক্ত নকশার লক্ষণগুলো চিনে রাখুন। একটি repository-র মাত্র একটি বাস্তবায়ন আছে আর test-এও কেউ সেটি বদলায় না। Service-এর প্রতিটি method শুধু repository-র একই নামের method ডেকে দেয়। একটি field যোগ করতে পাঁচটি ফাইল ছুঁতে হয়। এমন স্তরের নাম pass-through. একে সরালে কোড সহজই হবে।

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

  • Controller আর service-এর পার্থক্য? Controller HTTP-র ভাষা জানে, service দোকানের নিয়ম জানে।
  • Dependency injection কী? দরকারি জিনিস নিজে না বানিয়ে বাইরে থেকে নেওয়া। Constructor বা function parameter দিয়েই করা যায়।
  • DI আর DIP কি এক? না। DI একটি কৌশল, DIP নকশার নীতি: উভয় স্তর abstraction-এর ওপর নির্ভর করবে।
  • Service থেকে 404 পাঠাবেন? না। Service অর্থবহ error ছোড়ে, HTTP স্তর status বানায়।
  • Mock কখন খারাপ? যখন test কোডের ভেতরের ডাকের ক্রমে আটকে যায়, আর আচরণ না বদলালেও ভাঙে।

১০ · AI-এর যুগে — backend-এর গঠন যাচাই

AI-কে “একটি Express API বানাও” বললে সে প্রায়ই সুন্দর folder-এর গাছ বানিয়ে দেয়। Folder দেখে নকশা বিচার করা যায় না। তীর আর দায়িত্ব দেখতে হয়।

AI-এর সাধারণ ভুলকীভাবে ধরবেন
Service-এ req বা res পাঠানোgrep -l express চালান
Service-এর ভেতরে res.status(404)-এর মতো HTTPHTTP ছাড়া service ডেকে দেখুন
Express 5-এ অকারণ try/catch বা পুরোনো async wrapper packageআপনার version-এর docs পড়ুন
ছোট অ্যাপে container আর generic repository জুড়ে দেওয়াকোন test বা বদলে এটি লাগছে, জিজ্ঞেস করুন
Authorization controller-এ রাখা, অন্য caller-এ বাদ পড়াCron বা admin পথে একই নিয়ম চলছে কি না দেখুন
Test পাস করাতে error গিলে ফেলাcatch ব্লকগুলো পড়ুন, নিয়ম নষ্ট করে test লাল হয় কি না দেখুন
Transaction ছাড়া দুটি লেখার কাজমাঝখানে ইচ্ছা করে error ছুড়ে data দেখুন

AI-কে কাঠামো না চেয়ে সীমানা বলে দিন, আর ফলের প্রমাণ চান। নিচের prompt-টি আপনার মোটা route-এর জন্য বদলে নিন:

আমি Express 5 আর TypeScript ব্যবহার করছি। নিচের route-টি ভেঙে controller, service আর repository বানাতে সাহায্য করো। [এখানে আপনার route পেস্ট করুন, কোনো আসল secret দেবেন না] নিয়ম: ১. আচরণ বদলানো যাবে না। আগে একটি characterization test লেখো। ২. service-এ req, res বা status code থাকবে না। SQL থাকবে শুধু repository-তে। ৩. repository-র জন্য TypeScript interface দাও, আর service সেটি parameter-এ নেবে। ৪. নতুন কোনো package যোগ করবে না। ৫. একসাথে একটি ধাপ করো, আর বলো কোন test চালিয়ে যাচাই করব। ৬. যেখানে দুটি লেখার কাজ আছে, transaction-এর ঝুঁকি আলাদা করে দেখাও।

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

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

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

প্র ০১“Dependency injection” নামটি কীভাবে এলো, আর এর জন্য কি সত্যিই একটি framework লাগে?

২০০০-এর দশকের শুরুতে Java-র জগতে ভারী enterprise framework-এর বিরুদ্ধে হালকা কিছু container তৈরি হচ্ছিল, যেমন Spring আর PicoContainer. তারা নিজেদের বলত “Inversion of Control” container. জানুয়ারি ২০০৪-এ Martin Fowler তাঁর Inversion of Control Containers and the Dependency Injection pattern প্রবন্ধে যুক্তি দেন, এই কথাটি খুব সাধারণ। যেকোনো framework-ই তো নিয়ন্ত্রণ উল্টে দেয়। তাই কয়েকজনের সাথে আলোচনা করে তিনি নির্দিষ্ট একটি নাম প্রস্তাব করেন: Dependency Injection.

মজার ব্যাপার হলো, ওই প্রবন্ধের মূল কথা container-কে নিয়ে নয়। Fowler সেখানে constructor injection, setter injection আর service locator-এর তুলনা করেন, আর বলেন এদের মধ্যে বাছাইয়ের চেয়ে বড় কথা হলো ব্যবহার থেকে কনফিগারেশন আলাদা করা। অর্থাৎ আসল শিক্ষাটি হলো: একটি জিনিস কোথায় ব্যবহার হচ্ছে আর কোথায় ঠিক হচ্ছে সেটি কে, এই দুটি জায়গা আলাদা।

আমাদের প্রজেক্টে এই কাজটি একটি সাধারণ compose.ts করেছে, চারটি লাইনে। Mark Seemann পরে এই ধরনের হাতের জোড়াকে “Pure DI” নামে পরিচিত করেন, আর composition root কথাটিও তাঁর লেখা থেকে ছড়ায়। C# বা Java-র মতো ভাষায় constructor-এর কাগজপত্র বেশি, তাই container সেখানে বেশি স্বাভাবিক লাগে। JavaScript-এ closure আর object সস্তা, তাই ছোট প্রজেক্টে হাতের জোড়া প্রায়ই যথেষ্ট।

মূল উপলব্ধি: Dependency injection একটি অভ্যাস, কোনো পণ্য নয়। Container শুধু সেই অভ্যাসের কাগজের কাজ কমায়। আগে অভ্যাসটি বুঝুন, তারপর দরকার হলে টুল নিন।

প্র ০২Prisma-র মতো ORM নিজেই তো একরকম repository. তার ওপরে আবার repository বসানো কি অপ্রয়োজনীয়?

এটি backend জগতের একটি পুরোনো ও সৎ বিতর্ক। .NET-এর জগতেও একই প্রশ্ন বহু বছর ধরে চলেছে, Entity Framework-এর ওপরে repository লেখা উচিত কি না তা নিয়ে।

বসানোর পক্ষে: Service-এ ORM-এর query ছড়ালে ORM বদলানো কঠিন হয়। একই জটিল query নানা জায়গায় কপি হয়। আর prisma.order.findMany({ include: ... }) ধরনের লেখা service-এর ভাষাকে data-র ভাষায় টেনে নেয়। repo.findOpenOrdersOf(customerId) নামটি অনেক স্পষ্ট। নকল repository দিয়ে দ্রুত test করা যায়, ORM-এর পুরো API নকল করতে হয় না।

বিপক্ষে: ORM আসলে কদাচিৎ বদলায়। পাতলা repository হয়ে যায় pass-through: প্রতিটি নতুন query-র জন্য একটি বাড়তি method. ORM-এর সুবিধা, যেমন type-safe select আর transaction, এই স্তরের আড়ালে ঢাকা পড়ে। আর repository-তে সাধারণ method রাখতে গেলে দরকারের চেয়ে বেশি data আনার ঝুঁকি বাড়ে, যা থেকে N+1 আর ধীর query আসে (পর্ব ৪২)।

কাজের মাঝের পথ: সাধারণ CRUD অ্যাপে service থেকে সরাসরি ORM ডাকা ঠিক আছে, আর test চলে একটি আসল test database-এ। নিয়ম বেশি হলে বা query জটিল হলে সেই নির্দিষ্ট অংশে repository বানান। সিদ্ধান্তের মানদণ্ড “pattern-এ লেখা আছে” নয়, মানদণ্ড হলো এই স্তর কোন বদল সহজ করছে।

মূল উপলব্ধি: Repository একটি মালিকানার সীমা: data-র জ্ঞান কে রাখবে। ORM থাকলে সেই সীমার দাম আবার হিসাব করতে হয়। উত্তর দুই দিকেই হতে পারে।

প্র ০৩দুটি service-কে একই transaction-এ রাখতে হলে নকশার কী হয়? Transaction কি service-এর, না repository-র কাজ?

Transaction স্তরের সীমানা ভাঙা একটি সমস্যা। শুরু করার যন্ত্রটি database-এর, অথচ “কোন কাজগুলো একসাথে” সেই সিদ্ধান্তটি ব্যবসার। তাই কোনো এক স্তরে রাখলেই কিছু একটা ফাঁস হয়।

পথ ১, repository-র ভেতরে: placeOrderAtomically নামের একটি method একসাথে stock কমায় আর order লেখে। সহজ, কিন্তু ব্যবসার ক্রম repository-তে ঢুকে পড়ে। দুটি feature মিশলে এই পথ কঠিন হয়।

পথ ২, service থেকে সীমানা টানা: Service একটি withTransaction(async (tx) => { ... }) ফাংশন inject করে পায়, আর ভেতরে transaction-বাঁধা repository নেয়। PoEAA বইয়ে এই ধরনের কাজের হিসাব রাখার pattern-এর নাম Unit of Work. Spring-এর @Transactional annotation-ও এই সীমানা টানে। Prisma-র interactive transaction-এ একটি tx client ফাংশনে আসে, যা পর্ব ৪৩-এ দেখবেন।

আরেকটি সমস্যা, database-এর বাইরের কাজ: SMS কখনো rollback হয় না। Transaction-এর ভেতরে SMS পাঠিয়ে পরে rollback হলে ক্রেতা ভুল খবর পাবেন। পরে পাঠালে আবার পাঠানোর আগেই server বন্ধ হতে পারে। এ জন্য একটি পরিচিত পথ হলো একই transaction-এ একটি “পাঠাতে হবে” সারি লেখা, আর পরে background job দিয়ে পাঠানো (পর্ব ৬২)। একে সাধারণত outbox pattern বলে।

Microservice-এ দুটি database থাকলে একটি সহজ transaction আর সম্ভব থাকে না। এই খরচের কথাই পর্ব ০৪-এ বলা হয়েছিল। Modular monolith-এ এক database থাকলে এই সুবিধাটি থাকে।

মূল উপলব্ধি: স্তর হলো কোডের সীমানা, transaction হলো data-র সীমানা। দুটি সবসময় মেলে না। মিল না থাকার জায়গাটি চিনে রাখা নকশার বড় একটি অংশ।

প্র ০৪নকল repository, mock আর আসল database — service test-এ কোনটি ব্যবহার করবেন?

Martin Fowler তাঁর Mocks Aren't Stubs লেখায় (শেষ পরিমার্জন ২০০৭) দুটি ধারার কথা বলেন। একটি ধারা শেষের অবস্থা যাচাই করে: অর্ডারটি সত্যিই আছে কি না। আরেকটি ডাকের আচরণ যাচাই করে: saveOrder ডাকা হলো কি না। দুই ধারারই ভালো ব্যবহার আছে।

Mock-এর ঝুঁকি: আমাদের service-এ findProduct আগে, না reserveStock আগে, এই ক্রম mock দিয়ে কঠোরভাবে বেঁধে দিলে test আচরণ নয়, বাস্তবায়ন মুখস্থ করে। ভেতরের কোড পরিষ্কার করলেই test ভাঙে, অথচ কোনো bug নেই। তখন refactoring-এর জাল উল্টে refactoring-এর বাধা হয়।

Fake-এর সুবিধা: আমাদের MemoryOrderRepository একটি ছোট কিন্তু কাজ করা repository. সে stock সত্যিই কমায়। Test-এ তাই শেষের অবস্থা দেখা যায়। ঝুঁকি একটিই: fake আর আসল আলাদা হয়ে যেতে পারে। Contract test সেই ঝুঁকিই কমায়।

আসল database-এর সুবিধা: SQL-এর ভুল, constraint আর transaction শুধু এখানেই ধরা পড়ে। Container-ভিত্তিক টুল আসার পরে এটি আগের চেয়ে সহজ হয়েছে। তবে এটি ধীর, আর test-এর মধ্যে data আলাদা রাখার ঝামেলা আছে। Google-এর প্রকৌশলীদের Software Engineering at Google (২০২০) বইয়ের test double অধ্যায়েও বাস্তবের কাছাকাছি থাকার দিকে ঝোঁক দেখা যায়।

আমাদের ভাগ: নিয়মের জন্য fake, repository-র জন্য আসল SQLite-এ contract test, আর বাইরের গেটওয়ের জন্য spy (vi.fn). SMS সত্যিই পাঠানোর উপায় test-এ নেই, তাই শুধু সেখানেই “ডাকা হলো কি না” দেখা যুক্তিসঙ্গত।

মূল উপলব্ধি: নিজের মালিকানার জিনিস যত সম্ভব আসলের কাছাকাছি রাখুন। নকল ব্যবহার করুন সেখানেই, যেখানে সীমানার ওপারে যাওয়া সত্যিই অসম্ভব বা ব্যয়বহুল।

প্র ০৫Service-এ সব নিয়ম রাখলে object-গুলো শুধু data হয়ে যায়। একে কি anti-pattern বলা উচিত?

২০০৩ সালে Martin Fowler Anemic Domain Model নামের একটি ছোট লেখায় এই অবস্থাকে সমালোচনা করেন: object-এর নাম আছে, field আছে, কিন্তু আচরণ নেই। সব নিয়ম service-এ, যা আসলে পুরোনো procedural কোডের নতুন পোশাক। আমাদের Order ঠিক তাই, শুধু একটি interface.

এর বিকল্প হলো rich domain model: order.applyCoupon(c), order.total(). নিয়ম data-র পাশে থাকে, আর ভুল অবস্থায় object তৈরিই হয় না। Domain-Driven Design-এ এই পথটি গুরুত্ব পায়। এর তত্ত্ব আছে Hexagonal ও Clean Architecture পর্বে। Alistair Cockburn-এর hexagonal architecture (২০০৫ নাগাদ) আমাদের port-এর চিন্তার পেছনেও আছে।

তবে TypeScript আর Node-এর বাস্তবতা একটু আলাদা। Data প্রায়ই JSON হয়ে network আর database পার হয়, আর class-এর method সেখানে হারিয়ে যায়। React-এর জগৎও সাধারণ object আর function পছন্দ করে। তাই অনেক দল service আর pure function-এই থাকে। একে “দুর্বল” বলা সব ক্ষেত্রে ঠিক নয়, কারণ PoEAA-তেই Transaction Script কম জটিল কাজের জন্য বৈধ পথ।

আসল সংকেত হলো জটিলতা। নিয়ম বাড়লে service-এর ফাংশন ফুলে ওঠে, আর সেখান থেকে হিসাবের অংশটি বের করে priceOrder(lines, coupon) নামের একটি pure function বানানোই পরের ভালো ধাপ। এতে কোনো নকল ছাড়াই test করা যায়। আমাদের ছাড়ের হিসাবও এভাবে আলাদা করা যেত। আরও জটিল হলে domain object-এ যাওয়া যায়।

মূল উপলব্ধি: Anemic model সবসময় ভুল নয়, তবে এটি একটি সীমা জানিয়ে দেয়। জটিলতা বাড়লে নিয়মকে data-র কাছে আনুন। ধাপটি নিন যখন ব্যথা দেখা দেয়, আগে থেকে নয়।

অনুশীলন

দাম: চাল 42000, ডাল 14000, তেল 95000 পয়সা। কুপন EID10-এ ১০% ছাড় (নিচে গোল করা), ছাড়ের পরে 100000 পয়সা বা বেশি হলে delivery ফ্রি, নইলে 6000.

  1. হিসাব করুন: অর্ডারে ১টি তেল আর ১টি ডাল, কুপন EID10. subtotal, discount, delivery আর total কত? কুপন ছাড়া হলে ক্রেতা কত দিতেন, আর কোন ক্ষেত্রে কম দিতে হয়?

    Subtotal = 95000 + 14000 = 109000. Discount = floor(109000 / 10) = 10900. ছাড়ের পরে 98100, যা 100000-এর কম, তাই delivery 6000. Total = 98100 + 6000 = 104100.

    কুপন ছাড়া 109000 ≥ 100000, তাই delivery 0, আর total 109000, আমাদের চালানো ফলেও তাই এসেছে। কুপনে ক্রেতা 4900 পয়সা, মানে ৪৯ টাকা বাঁচান। ছাড় 10900 হলেও delivery 6000 যোগ হয়েছে: 10900 − 6000 = 4900. এমন সীমানার আচরণ দোকানের মালিকের সাথে মিলিয়ে নেওয়ার মতো একটি বিষয়, আর সেজন্যই নিয়মটি এক জায়গায় থাকা দরকার।

  2. একটি test লিখে চালান: ঠিক 100000 পয়সায় delivery ফ্রি হওয়ার কথা। Vitest-এ একটি test লিখুন, যেখানে একটি পণ্যের দাম 50000, সংখ্যা 2. তারপর service-এ >= বদলে > করে npx vitest run চালান। আগের ছয়টি test কি এই ভুল ধরে?

    আমরা এটি সত্যিই চালিয়েছি। ঠিক কোডে নতুন test পাস করে। > করার পরে নতুন test লাল হয়:

    AssertionError: expected 6000 to be +0 // Object.is equality
          Tests  1 failed (1)

    কিন্তু আগের ছয়টি test একই ভুল কোডে Tests 6 passed (6) দেখায়, কারণ কোনোটিই ঠিক সীমার মান ব্যবহার করে না। সব test সবুজ মানেই সব নিয়ম যাচাই হয়েছে, এমন নয়। সীমানার মান পরীক্ষা করা test design-এর একটি মূল কৌশল। আরও দেখুন Test design পর্বে।

  3. নকশা সমালোচনা করুন: একটি পর্যালোচনায় এই কোড পেলেন। অন্তত চারটি সমস্যা বের করুন, আর বলুন প্রতিটি কোথায় যাবে।
    class OrderService {
      async place(req, res) {
        const db = new Database(process.env.DB_URL);
        const p = await db.query('SELECT * FROM products WHERE id = ' + req.body.id);
        if (req.body.qty > p.stock) return res.status(409).send('no stock');
        if (req.body.price) p.price = req.body.price;
        await db.query('UPDATE ...');
        res.json({ total: req.body.qty * p.price });
      }
    }
    • Service-এ req আর res: HTTP-র জ্ঞান ঢুকে গেছে। Controller ইনপুট বের করে একটি সাধারণ object দেবে, আর service ফল বা error ফেরত দেবে।
    • new Database(...): Service নিজেই নির্ভরতা বানাচ্ছে, তাই test অসম্ভব। প্রতি request-এ নতুন connection-ও ক্ষতিকর। এটি composition root-এ যাবে, আর repository service-এ inject হবে।
    • String জোড়া SQL: এটি SQL injection-এর দরজা। Repository-তে parameterized query লাগবে।
    • req.body.price বিশ্বাস করা: সবচেয়ে বিপজ্জনক। ক্রেতা নিজেই দাম ঠিক করতে পারবেন। দাম আসবে শুধু server-এর data থেকে।
    • যাচাই নেই: qty ঋণাত্মক হলে total ঋণাত্মক হবে। Zod schema controller-এ লাগবে।
    • শর্তহীন UPDATE: Race condition-এর সুযোগ আছে। Stock-এর শর্ত SQL-এ লাগবে, আর পুরোটা transaction-এ।
  4. আউটপুট বলুন: Stock 5. save শুধু দ্বিতীয়বার ডাকলে disk full ছোড়ে। প্রোগ্রাম কী ছাপবে?
    function createService(repo, notify) {
      return {
        async place(id, qty) {
          const p = await repo.find(id);
          if (qty > p.stock) return "NO";
          await repo.take(id, qty);
          const o = await repo.save({ id, qty });
          await notify(o.no);
          return "YES";
        },
      };
    }
    const rows = { dal: { stock: 5 } }; let no = 0; const log = [];
    const repo = {
      find: async (id) => rows[id],
      take: async (id, q) => { rows[id].stock -= q; },
      save: async () => { no += 1; if (no === 2) throw new Error("disk full"); return { no }; },
    };
    (async () => {
      const s = createService(repo, async (n) => log.push(n));
      console.log(await s.place("dal", 3));
      try { console.log(await s.place("dal", 2)); } catch (e) { console.log(e.message); }
      console.log(await s.place("dal", 1));
      console.log(rows.dal.stock, log);
    })();

    Node-এ চালানো সত্যিকারের আউটপুট:

    YES
    disk full
    NO
    0 [ 1 ]

    প্রথমবার stock 5 থেকে 2 হলো, order 1, SMS log-এ 1. দ্বিতীয়বার stock 2 থেকে 0 হলো, তারপর save ভেঙে পড়ল। notify আর চলেনি। তৃতীয়বার ক্রেতা ১টি চাইলেন, কিন্তু stock 0, তাই NO, যদিও আসলে ২টি মাল গুদামে আছে। এটিই transaction না থাকার সত্যিকারের দাম: ব্যর্থ অর্ডার পরের ক্রেতার কেনা আটকে দিল।

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

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

ক্রমএকটি মোটা route নিরাপদে ভাঙার ধাপগুলো সঠিক ক্রমে সাজান (▲▼ চেপে সরান)

  1. 🕸️ এখনকার আচরণ ধরে রাখার characterization test লেখা
  2. 🗄️ SQL সরিয়ে repository বানানো, আবার মেলানো
  3. 🧮 নিয়ম সরিয়ে service বানানো, res বাদ দেওয়া
  4. 🛎️ Controller পাতলা করা, error-কে central handler-এ পাঠানো
  5. 🔌 Startup-এর একটি ফাইলে সব জোড়া লাগানো
  6. 🧪 নকল repository দিয়ে service-এর নতুন test লেখা

একটি Express অ্যাপে “কুপন EID10-এ ১০% ছাড়” নিয়মটি কোন স্তরে থাকা সবচেয়ে উপযুক্ত?

  • Route
  • Service
  • Repository
  • React component

নিচের কোন কথাটি function parameter দিয়ে dependency injection সম্পর্কে সঠিক?

  • এর জন্য একটি container library অবশ্যই লাগবে
  • এতে প্রোগ্রাম চলার গতি অনেক বাড়ে
  • Service তার দরকারি জিনিস নিজে না বানিয়ে বাইরে থেকে পায়
  • Class-এর ওপরে decorator লিখতে হয়

NestJS-এর মতো container-এ একটি TypeScript interface inject করতে আলাদা token লাগে কেন?

  • Interface চলার সময় থাকে না, compile-এর সময়েই মুছে যায়
  • Interface খুব ধীরে চলে
  • Interface সব সময় private থাকে
  • Interface-এ async method থাকতে পারে না

Service প্রথমে stock কমায়, তারপর অর্ডার লেখে। দুটি আলাদা repository ডাকের মাঝখানে server ভেঙে গেলে কী হতে পারে, আর ঠিক করার পথ কী?

  • Express নিজেই stock ফিরিয়ে দেবে
  • TypeScript interface এই ভুল আটকাবে
  • Layer ভাগ করা থাকলে এমন কিছু হতে পারে না
  • Stock কমে থাকবে কিন্তু অর্ডার থাকবে না; transaction লাগবে

একটি ছোট admin tool-এ তিনটি table-এর সাধারণ CRUD আছে, বিশেষ কোনো ব্যবসার নিয়ম নেই। কোনটি সবচেয়ে যুক্তিসঙ্গত?

  • প্রতিটি table-এ controller, service, repository, interface আর container
  • সহজ route আর ছোট ফাংশন দিয়ে শুরু, প্রয়োজন এলে স্তর যোগ
  • প্রতিটি table-এর জন্য আলাদা microservice
  • সব validation বাদ, কারণ tool-টি ছোট

দুটি OrderRepository বাস্তবায়ন আছে: memory আর SQLite. একই test দুটির ওপর চালানোর মূল উদ্দেশ্য কী?

  • শুধু test-এর সংখ্যা বেশি দেখানো
  • SQLite-কে দ্রুত করা
  • নকল repository আচরণেও আসলের সাথে মেলে, সেটি নিশ্চিত করা
  • TypeScript type চেকের প্রয়োজন দূর করা

শব্দকোষ

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

মনে রাখুন

  1. Route ঠিকানা, controller HTTP, service নিয়ম, repository data.
  2. ভাঙার আগে আচরণ ধরে রাখার test, তারপর ছোট ধাপ।
  3. Dependency injection মানে parameter. জোড়া লাগে এক জায়গায়, composition root-এ।
  4. Interface আকার মেলায়, contract test আচরণ মেলায়। ভাগ করলে নকল repository দিয়ে নিয়ম test করা যায়।
  5. Transaction স্তর ভাগ করলে আসে না। আর ছোট অ্যাপে তিন স্তর প্রায়ই বাড়াবাড়ি।

পরের পর্বে: এতক্ষণ request ছিল ছোট JSON. এবার ক্রেতা পণ্যের ছবি আপলোড করবেন। multipart, stream আর সীমা, আর এই স্তরের ভাগে ফাইল কোথায় বসে।

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

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