بنية طبقية: Routes و Services و Repositories — REST APIs
البنية الطبقية للـ API
في الأمثلة السابقة، معالج المسار يقرأ الطلب ويتحقق ويستعلم القاعدة ويطبق قواعد العمل ويرسل الاستجابة — كله في دالة واحدة. هذا يصلح لمثال، ويصبح فوضى مع 50 مساراً: منطق مكرر، واختبار مستحيل دون خادم وقاعدة بيانات.
الطبقات الثلاث
HTTP ← Route/Controller ← Service ← Repository ← قاعدة البيانات
(يعرف HTTP) (قواعد العمل) (يعرف SQL/Mongo)| الطبقة | تعرف | لا تعرف | مثال |
|---|---|---|---|
| Route / Controller | req و res، رموز الحالة، التحقق من شكل المدخلات | SQL، قواعد العمل | يحوّل req.body إلى بيانات ويستدعي الخدمة ويحدد 201 |
| Service | قواعد العمل والتنسيق بين المستودعات | HTTP وتفاصيل القاعدة | "لا يُستعار كتاب مُعار"، "الحد 3 كتب" |
| Repository | الاستعلامات وتحويل الصفوف إلى كائنات | قواعد العمل وHTTP | findById، markBorrowed |
حقن الاعتماديات
الخدمة تستقبل مستودعاتها بدل استيرادها مباشرة:
function createLoanService({ booksRepo, loansRepo, clock = () => new Date() }) {
return {
async borrow(memberId, bookId) {
const book = await booksRepo.findById(bookId);
if (!book) throw Object.assign(new Error("الكتاب غير موجود"), { code: "NOT_FOUND" });
if (book.borrowedBy) throw Object.assign(new Error("الكتاب مُعار"), { code: "CONFLICT" });
const active = await loansRepo.countActive(memberId);
if (active >= 3) throw Object.assign(new Error("الحد الأقصى 3 كتب"), { code: "LIMIT" });
return loansRepo.create({ memberId, bookId, at: clock() });
},
};
}
console.log(typeof createLoanService({ booksRepo: {}, loansRepo: {} }).borrow);في الإنتاج تمرر مستودعات PostgreSQL، وفي الاختبار مستودعات في الذاكرة — والخدمة لا تتغير. هذا ما يجعل منطق العمل قابلاً للاختبار في ملّي ثوانٍ.
الأخطاء عبر الطبقات
- المستودع يحوّل أخطاء القاعدة إلى أخطاء نطاق (23505 ← ConflictError).
- الخدمة ترمي أخطاء عمل بمعنى واضح (LIMIT، NOT_FOUND).
- المسار (أو معالج الأخطاء المركزي) يحوّلها إلى HTTP: NOT_FOUND ← 404، CONFLICT ← 409، LIMIT ← 422.
لا يعرف أي طرف تفاصيل الآخر — يمكنك تغيير القاعدة من MongoDB إلى PostgreSQL بتعديل المستودع وحده.
المسار رقيق
// router.post("/loans", requireAuth, validate(loanSchema), async (req, res) => {
// const loan = await loanService.borrow(req.user.id, req.body.bookId);
// res.status(201).json(loan);
// });
const responsibilities = ["المصادقة", "التحقق من الشكل", "استدعاء الخدمة", "رمز الحالة"];
console.log(responsibilities.join(" ← "));لا تبالغ
تطبيق صغير بثلاثة مسارات لا يحتاج عشرة ملفات لكل ميزة. ابدأ بفصل المنطق عن HTTP (الخدمة)، وأضف المستودع عندما يكثر الوصول للبيانات. البنية وسيلة لتسهيل التغيير والاختبار، لا هدفاً.
تمرين
أضف للمثال الكامل returnBook في الخدمة: يُرجع فقط المستعير نفسه، ويحسب غرامة يوم لكل يوم تأخير بعد 14 يوماً باستخدام clock الوهمي. اكتب ثلاثة اختبارات.
// خدمة إعارة كتب بطبقات منفصلة + اختبار منطق العمل دون خادم أو قاعدة بيانات
// شغّله بـ: node --test (أو node loans.test.js)
const { describe, it } = require("node:test");
const assert = require("node:assert/strict");
// ===== أخطاء النطاق =====
class DomainError extends Error {
constructor(code, message) { super(message); this.code = code; }
}
const HTTP_STATUS = { NOT_FOUND: 404, CONFLICT: 409, LIMIT: 422 }; // يستخدمها معالج الأخطاء في طبقة HTTP
// ===== Repositories: نسخة في الذاكرة (في الإنتاج: PostgreSQL بنفس الواجهة) =====
function createMemoryRepos(seedBooks) {
const books = new Map(seedBooks.map((b) => [b.id, { ...b, borrowedBy: null }]));
const loans = [];
return {
booksRepo: {
findById: async (id) => books.get(id) ?? null,
setBorrower: async (id, memberId) => { books.get(id).borrowedBy = memberId; },
},
loansRepo: {
countActive: async (memberId) => loans.filter((l) => l.memberId === memberId && !l.returnedAt).length,
create: async (loan) => { const row = { id: loans.length + 1, ...loan, returnedAt: null }; loans.push(row); return row; },
},
};
}
// ===== Service: قواعد العمل فقط =====
function createLoanService({ booksRepo, loansRepo, clock = () => new Date(), maxActive = 3 }) {
return {
async borrow(memberId, bookId) {
const book = await booksRepo.findById(bookId);
if (!book) throw new DomainError("NOT_FOUND", "الكتاب غير موجود");
if (book.borrowedBy) throw new DomainError("CONFLICT", "الكتاب مُعار حالياً");
if ((await loansRepo.countActive(memberId)) >= maxActive) throw new DomainError("LIMIT", `الحد الأقصى ${maxActive} كتب`);
await booksRepo.setBorrower(bookId, memberId);
return loansRepo.create({ memberId, bookId, borrowedAt: clock() });
},
};
}
// ===== اختبارات الخدمة =====
describe("خدمة الإعارة", () => {
const books = [1, 2, 3, 4].map((id) => ({ id, title: `كتاب ${id}` }));
const fixedClock = () => new Date("2026-10-01T10:00:00Z");
it("تُعير كتاباً متاحاً", async () => {
const service = createLoanService({ ...createMemoryRepos(books), clock: fixedClock });
const loan = await service.borrow("sara", 1);
assert.equal(loan.bookId, 1);
assert.equal(loan.borrowedAt.toISOString(), "2026-10-01T10:00:00.000Z");
});
it("ترفض كتاباً مُعاراً بخطأ CONFLICT (← 409)", async () => {
const service = createLoanService(createMemoryRepos(books));
await service.borrow("sara", 1);
await assert.rejects(service.borrow("omar", 1), (err) => err.code === "CONFLICT" && HTTP_STATUS[err.code] === 409);
});
it("تفرض الحد الأقصى للإعارات", async () => {
const service = createLoanService({ ...createMemoryRepos(books), maxActive: 3 });
for (const id of [1, 2, 3]) await service.borrow("sara", id);
await assert.rejects(service.borrow("sara", 4), { code: "LIMIT" });
});
it("ترفض كتاباً غير موجود", async () => {
const service = createLoanService(createMemoryRepos(books));
await assert.rejects(service.borrow("sara", 99), { code: "NOT_FOUND" });
});
});اختبر فهمك