هيكلة التطبيق وأنماط التصميم
كود يفهمه زميلك، ويتغير دون أن ينكسر ⏱ نحو 45 دقيقة.
- تفصل التطبيق إلى طبقات بمسؤوليات واضحة.
- تطبّق أنماط الخدمة والمستودع وحقن الاعتماديات والمراقِب.
- توحّد الأخطاء بصنف
AppErrorومعالج واحد. - تختبر طبقة الخدمة بمزيفات، وتنظّم مشروعًا كاملًا حسب الميزات.
١لماذا نهتم بالهيكلة؟
الكود يُقرأ أكثر مما يُكتب، ويعدّله أشخاص لم يكتبوه. التطبيق الجيد التنظيم: يسهل العثور فيه على الشيء، ويُغيَّر جزء منه دون أن ينكسر غيره، ويُختبر كل جزء وحده. المبدأ الحاكم: فصل المسؤوليات، فلكل ملف سبب واحد للتغيير.
| الطبقة | تعرف | لا تعرف | مثال تغيير يخصها وحدها |
|---|---|---|---|
| المسارات Routes | المسار ← المتحكم | قواعد العمل | تغيير /orders إلى /v2/orders |
| المتحكم Controller | req و res ورموز الحالة | SQL وقواعد العمل | تغيير شكل JSON الراجع |
| الخدمة Service | قواعد العمل: المخزون، والأسعار، والصلاحيات | HTTP و Express | خصم 10% للطلاب |
المستودع Repository / Model | كيف تُحفظ البيانات وتُقرأ | HTTP وقواعد العمل | الانتقال من SQLite إلى PostgreSQL |
٢تطبيق بطبقات وأنماط
هذا خادم طلبات شراء صغير، لكنه مبني كما تُبنى التطبيقات الكبيرة. تنقّل بين الملفات الستة، ولاحظ أن قاعدة «الكمية غير متوفرة» تعيش في الخدمة وحدها:
// server.js: نقطة التشغيل فقطconst { createApp } = require('./app');const PORT = process.env.PORT || 3000;createApp().listen(PORT, () => console.log('يعمل على ' + PORT));
// app.js: يجمّع الأجزاء (ولا يستمع على منفذ: هذا ييسّر الاختبار)const express = require('express');const { EventEmitter } = require('events');const { createMemoryRepo } = require('./repositories/memoryRepo');const { createOrderService } = require('./services/orderService');const { createOrderController } = require('./controllers/orderController');const { errorHandler } = require('./errors'); function createApp({ repo = createMemoryRepo(), events = new EventEmitter() } = {}) { const app = express(); app.use(express.json()); app.use((req, res, next) => { req.user = { id: 7 }; next(); }); // مكان المصادقة الحقيقية const ctrl = createOrderController(createOrderService({ repo, events })); const router = express.Router(); router.get('/', ctrl.list); router.post('/', ctrl.place); app.use('/api/orders', router); events.on('order.placed', (o) => console.log(`📧 بريد تأكيد للطلب #${o.id} (الإجمالي ${o.total})`)); app.use(errorHandler); return app;} module.exports = { createApp };
// controllers/orderController.js: يترجم HTTP ↔ الخدمةfunction createOrderController(service) { return { list: async (req, res) => res.json(await service.list()), place: async (req, res) => { const order = await service.place(req.user.id, req.body); res.status(201).json(order); }, };} module.exports = { createOrderController };
// services/orderService.js: قواعد العمل فقط، لا req ولا resconst { AppError } = require('../errors'); function createOrderService({ repo, events }) { return { async list() { return repo.all(); }, async place(userId, { productId, qty }) { if (!Number.isInteger(qty) || qty < 1) throw new AppError(400, 'الكمية يجب أن تكون عددًا موجبًا'); const product = await repo.findProduct(productId); if (!product) throw new AppError(404, 'المنتج غير موجود'); if (product.stock < qty) throw new AppError(409, 'الكمية غير متوفرة'); product.stock -= qty; const order = await repo.saveOrder({ userId, productId, qty, total: qty * product.price }); events.emit('order.placed', order); // من يهتم يستمع (بريد، إشعار، تقارير) return order; }, };} module.exports = { createOrderService };
// repositories/memoryRepo.js: الوصول للبيانات (يُستبدل بـ Sequelize دون لمس الخدمة)function createMemoryRepo() { const products = [{ id: 1, name: 'لوحة مفاتيح', price: 150, stock: 3 }]; const orders = []; return { all: async () => orders, findProduct: async (id) => products.find(p => p.id === id), saveOrder: async (o) => { const order = { id: orders.length + 1, ...o }; orders.push(order); return order; }, };} module.exports = { createMemoryRepo };
// errors.js: خطأ التطبيق ومعالجه الموحّدclass AppError extends Error { constructor(status, message) { super(message); this.status = status; }} function errorHandler(err, req, res, next) { if (err instanceof AppError) return res.status(err.status).json({ error: err.message }); console.error(err); res.status(500).json({ error: 'خطأ غير متوقع' });} module.exports = { AppError, errorHandler };
| النمط | أين في الكود | فائدته |
|---|---|---|
| طبقة الخدمة Service Layer | services/orderService.js | قواعد العمل في مكان واحد، يستدعيها HTTP أو Socket.io أو مهمة مجدولة |
| المستودع Repository | repositories/memoryRepo.js | تبديل التخزين دون لمس الخدمة |
| حقن الاعتماديات DI | createOrderService({ repo, events }) | الخدمة تستلم ما تحتاجه بدل أن تنشئه، فنمرر لها نسخًا مزيفة في الاختبار |
| المصنع Factory | createApp() و create…() | إنشاء نسخ مستقلة مهيأة |
| سلسلة المسؤولية | الوسطاء في Express | كل وسيط يعالج أو يمرر |
| المراقِب Observer | events.emit('order.placed') | إضافة ردود فعل (بريد، إشعار) دون تعديل الخدمة |
٣الاختبار: ثمرة الهيكلة
لأن الخدمة لا تعرف Express ولا قاعدة البيانات، نختبرها مباشرة بمستودع مزيّف في أجزاء من الثانية. هذا ملف اختبار يعمل بـ node test.js؛ شغّله وانظر النتائج في الطرفية، ثم غيّر قاعدة في الخدمة (مثل حساب الإجمالي) وأعد التشغيل لترى الاختبار يفشل:
// server.js: نقطة التشغيل فقطconst { createApp } = require('./app');const PORT = process.env.PORT || 3000;createApp().listen(PORT, () => console.log('يعمل على ' + PORT));
// app.js: يجمّع الأجزاء (ولا يستمع على منفذ: هذا ييسّر الاختبار)const express = require('express');const { EventEmitter } = require('events');const { createMemoryRepo } = require('./repositories/memoryRepo');const { createOrderService } = require('./services/orderService');const { createOrderController } = require('./controllers/orderController');const { errorHandler } = require('./errors'); function createApp({ repo = createMemoryRepo(), events = new EventEmitter() } = {}) { const app = express(); app.use(express.json()); app.use((req, res, next) => { req.user = { id: 7 }; next(); }); // مكان المصادقة الحقيقية const ctrl = createOrderController(createOrderService({ repo, events })); const router = express.Router(); router.get('/', ctrl.list); router.post('/', ctrl.place); app.use('/api/orders', router); events.on('order.placed', (o) => console.log(`📧 بريد تأكيد للطلب #${o.id} (الإجمالي ${o.total})`)); app.use(errorHandler); return app;} module.exports = { createApp };
// controllers/orderController.js: يترجم HTTP ↔ الخدمةfunction createOrderController(service) { return { list: async (req, res) => res.json(await service.list()), place: async (req, res) => { const order = await service.place(req.user.id, req.body); res.status(201).json(order); }, };} module.exports = { createOrderController };
// services/orderService.js: قواعد العمل فقط، لا req ولا resconst { AppError } = require('../errors'); function createOrderService({ repo, events }) { return { async list() { return repo.all(); }, async place(userId, { productId, qty }) { if (!Number.isInteger(qty) || qty < 1) throw new AppError(400, 'الكمية يجب أن تكون عددًا موجبًا'); const product = await repo.findProduct(productId); if (!product) throw new AppError(404, 'المنتج غير موجود'); if (product.stock < qty) throw new AppError(409, 'الكمية غير متوفرة'); product.stock -= qty; const order = await repo.saveOrder({ userId, productId, qty, total: qty * product.price }); events.emit('order.placed', order); // من يهتم يستمع (بريد، إشعار، تقارير) return order; }, };} module.exports = { createOrderService };
// repositories/memoryRepo.js: الوصول للبيانات (يُستبدل بـ Sequelize دون لمس الخدمة)function createMemoryRepo() { const products = [{ id: 1, name: 'لوحة مفاتيح', price: 150, stock: 3 }]; const orders = []; return { all: async () => orders, findProduct: async (id) => products.find(p => p.id === id), saveOrder: async (o) => { const order = { id: orders.length + 1, ...o }; orders.push(order); return order; }, };} module.exports = { createMemoryRepo };
// errors.js: خطأ التطبيق ومعالجه الموحّدclass AppError extends Error { constructor(status, message) { super(message); this.status = status; }} function errorHandler(err, req, res, next) { if (err instanceof AppError) return res.status(err.status).json({ error: err.message }); console.error(err); res.status(500).json({ error: 'خطأ غير متوقع' });} module.exports = { AppError, errorHandler };
// test.js: اختبار وحدة لطبقة الخدمة، بلا خادم ولا قاعدة بياناتconst assert = require('assert');const { EventEmitter } = require('events');const { createOrderService } = require('./services/orderService'); // مستودع مزيّف: نتحكم في بياناته بالكاملconst fakeRepo = { findProduct: async (id) => (id === 1 ? { id: 1, price: 10, stock: 2 } : null), saveOrder: async (o) => ({ id: 99, ...o }),};const service = createOrderService({ repo: fakeRepo, events: new EventEmitter() }); async function test(name, fn) { try { await fn(); console.log('✓', name); } catch (e) { console.log('✗', name, '—', e.message); }} (async () => { await test('يحسب الإجمالي', async () => { const o = await service.place(7, { productId: 1, qty: 2 }); assert.strictEqual(o.total, 20); }); await test('يرفض الكمية الصفرية', async () => { await assert.rejects(service.place(7, { productId: 1, qty: 0 }), /موجبًا/); }); await test('يرفض المنتج غير الموجود', async () => { await assert.rejects(service.place(7, { productId: 5, qty: 1 }), /غير موجود/); });})();
| نوع الاختبار | يختبر | أدوات شائعة |
|---|---|---|
| وحدة Unit | دالة أو خدمة وحدها بمزيفات | node:test، Vitest، Jest |
| تكامل Integration | المسار كاملًا مع قاعدة بيانات اختبار | Supertest مع createApp() |
| طرف لطرف E2E | المستخدم يضغط في متصفح حقيقي | Playwright، Cypress |
| مكوّنات React | ما يظهر ويحدث عند التفاعل | Vitest + Testing Library |
٤تنظيم المشروع الكامل
في الواجهة، التنظيم حسب الميزة Feature أوضح من التنظيم حسب نوع الملف حين يكبر المشروع: كل ما يخص «الطلبات» في مجلد واحد:
shop/ ├── client/ ← React + Vite │ └── src/ │ ├── features/ │ │ ├── orders/ OrderList.jsx, OrderForm.jsx, useOrders.js, ordersApi.js │ │ └── auth/ LoginForm.jsx, AuthContext.jsx, useAuth.js │ ├── components/ Button.jsx, Modal.jsx ← عامة قابلة لإعادة الاستخدام │ ├── lib/api.js ← fetch موحّد (الدرس 18) │ └── App.jsx ├── server/ ← Express │ ├── routes/ controllers/ services/ repositories/ (أو models/) │ ├── middleware/ auth.js, validate.js │ ├── config/ index.js ← يقرأ process.env في مكان واحد │ ├── app.js server.js errors.js │ └── tests/ ├── .env.example └── README.md
| ممارسة | لماذا |
|---|---|
أسماء واضحة ومتسقة (useOrders و orderService) | الاسم يغني عن التعليق |
| دوال قصيرة بمهمة واحدة | أسهل في القراءة والاختبار |
| ESLint و Prettier | أسلوب موحد للفريق، وكشف الأخطاء مبكرًا |
| Git: فروع ورسائل وصفية ومراجعة كود | تاريخ مفهوم، وأخطاء أقل |
| README فيه خطوات التشغيل | زميلك يشغّل المشروع في دقائق |
توقّع وتحقّق
أين تُكتب قاعدة «لا يُباع أكثر من المخزون»؟
ما فائدة حقن الاعتماديات؟
DI.لماذا يُفصل app.js (createApp) عن server.js (listen)؟
نمط Observer في الدرس ممثل بـ:
الخلاصة
Routes←Controllers←Services←Repositories؛ قواعد العمل في الخدمة فقط.DI: مرّر الاعتماديات بدل إنشائها؛ فيسهل الاختبار والتبديل.AppError(status, message) + errorHandlerواحد.createAppمنفصل عنlistenلأجل الاختبار.- الواجهة حسب الميزات، والإعدادات في
config، والأسلوب بـESLintوPrettier.
تحدٍّ برمجي: تفكيك المتحكم السمين
متقدمكل منطق التسجيل في المقررات محشور في المسار. انقل قواعد العمل إلى خدمة، مع بقاء السلوك كما هو.
- أنشئ
services/enrollService.jsيصدّرenroll(studentId, courseId)تحوي القواعد: المقرر موجود (وإلا AppError 404)، ولم يمتلئ (وإلا 409)، والطالب غير مسجل مسبقًا (وإلا 409). - المسار في
server.jsيستدعي الخدمة فقط، ولا يحوي كلمةcapacity. - الأخطاء تمر على
errorHandlerالجاهز في errors.js.
👀 اعرض الاستجابات المطلوبة
POST /api/courses/1/enroll{"studentId": 10}POST /api/courses/1/enroll{"studentId": 10}POST /api/courses/1/enroll{"studentId": 11}POST /api/courses/1/enroll{"studentId": 12}POST /api/courses/5/enroll{"studentId": 10}
- الخادم يعمل ويرد على كل الطلبات بلا أخطاء غير معالَجة
- التسجيل الأول ينجح
- التكرار ← «مسجل مسبقًا»
- … بالحالة 409
- الامتلاء ← «المقرر ممتلئ»
- مقرر غير موجود ← 404
- الخدمة تصدّر enroll وترمي AppError
- المسار لا يحوي قواعد العمل
(capacity)
💡 تلميح 1
throw new AppError(409, 'المقرر ممتلئ') بدل res.status.💡 تلميح 2
حاول بنفسك أولًا؛ للمسألة أكثر من حلّ صحيح.
const express = require('express');const { errorHandler } = require('./errors');const { enroll } = require('./services/enrollService');const app = express();app.use(express.json()); app.post('/api/courses/:id/enroll', (req, res) => { res.status(201).json(enroll(req.body.studentId, Number(req.params.id)));}); app.use(errorHandler);app.listen(3000);
const { AppError } = require('../errors'); const courses = [{ id: 1, code: 'CS242', capacity: 2, students: [] }]; function enroll(studentId, courseId) { const course = courses.find(c => c.id === courseId); if (!course) throw new AppError(404, 'المقرر غير موجود'); if (course.students.length >= course.capacity) throw new AppError(409, 'المقرر ممتلئ'); if (course.students.includes(studentId)) throw new AppError(409, 'مسجل مسبقًا'); course.students.push(studentId); return { code: course.code, students: course.students.length };} module.exports = { enroll };
// errors.js: خطأ التطبيق ومعالجه الموحّدclass AppError extends Error { constructor(status, message) { super(message); this.status = status; }} function errorHandler(err, req, res, next) { if (err instanceof AppError) return res.status(err.status).json({ error: err.message }); console.error(err); res.status(500).json({ error: 'خطأ غير متوقع' });} module.exports = { AppError, errorHandler };