بين مارس 2025 وأبريل 2026 عملت على مجموعة من منصات العملاء المتشابهة المبنية بـ Angular. والنتيجة المقاسة التي أستطيع الإشارة إليها هي: خفض منطق الواجهة الأمامية المكرر بنسبة 35%، عبر تجميعه في مكتبات مكوّنات Angular قابلة لإعادة الاستخدام ووحدات مشتركة داخل Nx monorepo. أسماء العملاء تبقى محجوبة، أما المنهج فلا داعي لحجبه.
كيف يتراكم التكرار فعلاً
لا أحد يجلس ويقرر أن يكتب حارس المصادقة نفسه أربع مرات. الأمر يحدث هكذا.
تُطلَق منصة العميل «أ». وبعد ستة أسابيع يوقّع العميل «ب»، فتكون أسرع بداية هي نسخ مستودع «أ»، وإعادة تسميته، وحذف ما لا ينطبق. تحت ضغط موعد التسليم يكون هذا قراراً منطقياً بالفعل — وفي تلك اللحظة يكون التكرار مجانياً، لأن القاعدتين متطابقتان ولا أحد يصون نسختين متباعدتين بعد.
الفاتورة تصل بعد نحو أربعة أشهر. يكتشف أحدهم خللاً في مُنتقي نطاق التواريخ. يُصلَح في «أ»، ويبقى في «ب». وفي «ج» توجد نسخة أخرى أعاد شخص آخر هيكلتها فصارت تتصرف بشكل مختلف. وإصلاح أمني في interceptor تجديد الرمز يجب تطبيقه يدوياً في ثلاثة مواضع بواسطة ثلاثة أشخاص، كل واحد منهم مضطر لقراءة كود لم يكتبه. والجانب الخبيث في الأمر أن لا شيء يبدو معطلاً؛ فكل مستودع على حدة يبدو نظيفاً. التكلفة كلها تقع في المساحة بينها، وهي بالضبط المساحة التي لا يتحمل مسؤوليتها مالك أي مستودع.
التكرار لا يبطئك حين تُنشئه، بل يبطئك في كل مرة تُصلح فيها شيئاً، وإلى الأبد، وبطريقة لن تظهر لك في مقاييس أي مشروع منفرد.
كيف قِست نسبة الـ 35%
أي شخص يستطيع أن يكتب في سيرته الذاتية «خفضت التكرار بمقدار الثلث». وهذا بالضبط ما تعنيه تلك الجملة في حالتي، لتقرّر أنت كم من الثقة تمنحها لها.
لم أشغّل أداة كشف استنساخ على المستودع كله وأنشر نتيجتها. ما فعلته أضيق نطاقاً وأقوى في الدفاع عنه:
- الحصر. فهرست الوحدات الموجودة في أكثر من مشروع واحد — الـ interceptors، وحراس المصادقة، ومساعدات التحقق من النماذج، وغلاف جدول البيانات المُقسَّم إلى صفحات، وأداة رفع الملفات، وبنية الإشعارات، وأدوات التعريب ودعم RTL، ونماذج البيانات المشتركة للـ API. ولكل عنصر سجّلت: الملف، والمشاريع التي ظهر فيها، وعدد أسطره.
- خط الأساس. الجزء المكرر هو كل نسخة بعد الأولى. فغلاف جدول بطول 180 سطراً موجود في أربعة مشاريع يساهم بـ 3 × 180 = 540 سطراً مكرراً، لا 720. النسخة الأولى ليست تكراراً، بل هي الكود نفسه.
- التجميع. تنتقل نسخة واحدة إلى مكتبة مشتركة، وتتحول مواضع الاستدعاء إلى نقطة الدخول العامة للمكتبة، ثم تُحذف باقي النسخ.
- إعادة العدّ. لم يكن ممكناً حذف كل نسخة بالكامل؛ فبعضها حمل تخصيصاً خاصاً بعميل وجب بقاؤه. وتلك الأسطر المتبقية تظل محسوبة ضمن المكرر — وطرحها هو ما يبقي الرقم أميناً.
إذاً فنسبة 35% هي الأسطر المكررة المحذوفة ÷ الأسطر المكررة عند خط الأساس، ضمن ذلك الحصر. هي قياس «قبل وبعد» على نطاق محدّد، وليست درجة تحليل ساكن على مستوى المستودع بأكمله، وهكذا تحديداً سأصفها في أي مقابلة عمل. وإن أردت رقماً لقاعدة الكود لديك، فابدأ بالحصر أولاً — يستغرق يوماً تقريباً، وسيخبرك أيضاً، قبل أن تلتزم بأي شيء، إن كان الـ monorepo هو الحل الصحيح لك أصلاً.
ارسم الحدود قبل أن تنقل ملفاً واحداً
وضع الفشل في المكتبة المشتركة ليس صعوبة بنائها، بل أن تتحول إلى @acme/shared — مكتبة واحدة يستوردها كل شيء، ما يعني أن كل تغيير يُبطل كل ذاكرة تخزين مؤقت، وأن لا فريق يملكها. وتتجنب ذلك بأن تقرر التصنيف أولاً وتُرمّزه في الوسوم.
محوران يكفيان لمعظم بيئات Angular:
- scope — من يملكها:
scope:shared، وscope:client-a، وscope:client-b. - type — أي طبقة هي:
type:app(قشرة رفيعة، توجيه، مزوّدات)، وtype:feature(مكونات ذكية مرتبطة بالمسارات)، وtype:ui(عرض فقط، بلا HTTP وبلا حالة)، وtype:data-access(عملاء HTTP، والحالة، والنماذج)، وtype:util(دوال نقية وأنابيب).
// libs/shared/ui/project.json
{
"name": "shared-ui",
"$schema": "../../../node_modules/nx/schemas/project-schema.json",
"projectType": "library",
"sourceRoot": "libs/shared/ui/src",
"prefix": "shared",
"tags": ["scope:shared", "type:ui"],
"targets": {
"lint": { "executor": "@nx/eslint:lint" },
"test": { "executor": "@nx/jest:jest" }
}
}
والوسم نصف القصة فقط؛ نصفها الآخر هو مسار الاستيراد المستعار الذي يحوّل مجلداً إلى حزمة قابلة للاستيراد بنقطة دخول عامة واحدة:
// tsconfig.base.json
{
"compilerOptions": {
"paths": {
"@acme/shared/ui": ["libs/shared/ui/src/index.ts"],
"@acme/shared/data-access": ["libs/shared/data-access/src/index.ts"],
"@acme/shared/util-forms": ["libs/shared/util-forms/src/index.ts"]
}
}
}
ملف الـ barrel هو العقد. فكل ما لا يُصدَّر من src/index.ts يُعدّ خاصاً، و Nx سيرفض أي استيراد عميق يحاول تجاوزه. وهذا شكل عملية استخراج حقيقية — الـ interceptor الخاص بأخطاء الـ API، الذي كان منسوخاً في كل مشروع:
// libs/shared/data-access/src/lib/api-error.interceptor.ts
import { HttpErrorResponse, HttpInterceptorFn } from '@angular/common/http';
import { inject } from '@angular/core';
import { catchError, throwError } from 'rxjs';
import { NotificationService } from './notification.service';
export const apiErrorInterceptor: HttpInterceptorFn = (req, next) =>
next(req).pipe(
catchError((error: HttpErrorResponse) => {
const notifications = inject(NotificationService);
if (error.status === 422) {
notifications.validation(error.error?.errors ?? {});
} else if (error.status >= 500) {
notifications.error('errors.server_unavailable');
}
return throwError(() => error);
}),
);
// libs/shared/data-access/src/index.ts — the entire public surface
export * from './lib/api-error.interceptor';
export * from './lib/notification.service';
export * from './lib/paginated-resource';
افرض الحدود بالـ lint لا بحسن النية
العُرف الذي يعيش في ملف README هو عُرف يصمد حتى موعد التسليم التالي. أما @nx/enforce-module-boundaries فيحوّل التصنيف إلى خطأ في البناء:
// eslint.config.js
const nx = require('@nx/eslint-plugin');
module.exports = [
{ plugins: { '@nx': nx } },
{
files: ['**/*.ts', '**/*.tsx'],
rules: {
'@nx/enforce-module-boundaries': [
'error',
{
enforceBuildableLibDependency: true,
allow: [],
depConstraints: [
{
sourceTag: 'type:app',
onlyDependOnLibsWithTags: [
'type:feature', 'type:ui', 'type:data-access', 'type:util',
],
},
{
sourceTag: 'type:feature',
onlyDependOnLibsWithTags: [
'type:feature', 'type:ui', 'type:data-access', 'type:util',
],
},
{ sourceTag: 'type:ui', onlyDependOnLibsWithTags: ['type:ui', 'type:util'] },
{
sourceTag: 'type:data-access',
onlyDependOnLibsWithTags: ['type:data-access', 'type:util'],
},
{ sourceTag: 'type:util', onlyDependOnLibsWithTags: ['type:util'] },
// A shared library may never reach into a client's code.
{ sourceTag: 'scope:shared', onlyDependOnLibsWithTags: ['scope:shared'] },
{
sourceTag: 'scope:client-a',
onlyDependOnLibsWithTags: ['scope:client-a', 'scope:shared'],
},
{
sourceTag: 'scope:client-b',
onlyDependOnLibsWithTags: ['scope:client-b', 'scope:shared'],
},
],
},
],
},
},
];
اقرأ هذه القيود كجُمل. مكتبة الـ UI يمكنها الاعتماد على مكتبات UI أخرى وعلى الأدوات المساعدة — لا على data-access أبداً — وبذلك لا يمكن لمكوّن عرضي أن يبدأ بهدوء في إجراء طلبات HTTP. ومكتبة خاصة بعميل يمكنها الاعتماد على المكتبات المشتركة، بينما لا يجوز لمكتبة مشتركة أن تعتمد على كود أي عميل، وهذا ما يمنع scope:shared من التضخم بفروع خاصة بعملاء بعينهم. هاتان القاعدتان تفعلان لقابلية الصيانة على المدى الطويل أكثر مما يفعله أي قدر من مراجعة الكود، لأنهما تفشلان في الـ CI في الثانية صباحاً دون حاجة إلى إنسان يهتم.
nx affected وأثره على الـ CI
الاعتراض الدائم على الـ monorepo هو نفسه دائماً: «إذاً كل طلب دمج سيشغّل كل الاختبارات». وهذا لا يحدث، والسبب هو رسم المشروع البياني. فـ Nx يشتق رسماً للاعتماديات من عمليات الاستيراد الفعلية لديك، ويحدد أي المشاريع تغيّرت مقارنةً بالتزام أساسي، ثم يشغّل الأهداف لتلك المشاريع وما يعتمد عليها فقط.
# What would run, before running it
npx nx show projects --affected --base=origin/main
# Run lint, test and build for the affected set only
npx nx affected -t lint test build --base=origin/main --parallel=3
# See the graph you are actually enforcing
npx nx graph
وفي GitHub Actions يكون الجزء الدقيق الوحيد هو تحديد الـ base SHA بشكل صحيح؛ فأنت تحتاج إلى التاريخ الكامل ونقطة مقارنة سليمة:
name: ci
on:
pull_request:
push:
branches: [main]
jobs:
affected:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # nx affected needs history, not a shallow clone
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- run: npm ci
- uses: nrwl/nx-set-shas@v4 # resolves NX_BASE / NX_HEAD correctly for PRs and pushes
- run: npx nx affected -t lint test build --parallel=3
هنا يتراكم أثران. الأول أن affected يضيّق أي المشاريع تعمل أصلاً. والثاني أن ذاكرة الحوسبة المؤقتة تعني أنه حتى بين المشاريع المتأثرة، أي هدف تُطابق بصمة مدخلاته بصمة سبق حسابها يُستعاد من الذاكرة المؤقتة بدل تنفيذه. واضبط namedInputs بشكل صحيح، فيتوقف تعديل ملف README عن إبطال هدف البناء تماماً:
// nx.json
{
"namedInputs": {
"default": ["{projectRoot}/**/*", "sharedGlobals"],
"production": [
"default",
"!{projectRoot}/**/?(*.)+(spec|test).[jt]s?(x)",
"!{projectRoot}/tsconfig.spec.json",
"!{projectRoot}/jest.config.[jt]s",
"!{projectRoot}/**/*.md"
],
"sharedGlobals": ["{workspaceRoot}/tsconfig.base.json"]
},
"targetDefaults": {
"build": {
"dependsOn": ["^build"],
"inputs": ["production", "^production"],
"cache": true
},
"test": {
"inputs": ["default", "^production"],
"cache": true
}
}
}
ولن أضع رقماً لتحسّن زمن الـ CI، لأنني لم أقِسه بطريقة أستطيع الدفاع عنها. لكن الآلية نفسها تستحق الفهم: يتوقف حجم العمل في كل طلب دمج عن كونه دالة في حجم المستودع، ويصبح دالة في نطاق أثر التغيير.
داخلية أم قابلة للبناء أم قابلة للنشر — اختر عن قصد
- داخلية (الافتراضي). بلا هدف بناء. يستورد المستهلكون شيفرة TypeScript عبر المسار المستعار، ويتولى بناء التطبيق نفسه ترجمتها. أسرع حلقة تطوير، وأبسط نموذج ذهني. يجب أن يكون هذا هو الافتراضي، وداخل مستودع واحد يكون عادةً هو الحالة النهائية أيضاً.
- قابلة للبناء. لها هدف بناء خاص ينتج مخرجات مترجَمة. تستحق العناء حين تكون المكتبة كبيرة ومستقرة بما يكفي ليوفّر تخزين مخرجات بنائها أكثر مما تكلّفه الزيادة في التعقيد، أو حين يعجز بناء التطبيق فعلاً عن ترجمة الشيفرة الخام.
- قابلة للنشر. تنتج حزمة قابلة للتوزيع بملف
package.jsonخاص بها، ورقم إصدار، وخطوة نشر إلى npm. لا تستحق العناء إلا إذا كان المستهلك خارج الـ monorepo. فمنذ لحظة النشر تكون قد التزمت بالإصدار الدلالي، وسجل التغييرات، وسياسة الإهمال، ومستهلكين عالقين على إصدارات قديمة — وهي بالضبط تكلفة التنسيق التي انتقلت إلى الـ monorepo هرباً منها.
الخطأ الذي أراه أكثر من غيره هو جعل المكتبات قابلة للنشر لأن ذلك يبدو أكثر «احترافية». وهو ليس أكثر احترافية، بل أكثر تكلفة. انشر حين يحتاج شيء خارج المستودع إلى الحزمة، ولا تفعل ذلك قبل ذلك بيوم واحد.
ترحيل بيئة قائمة متعددة المستودعات
لا تحاول الدمج دفعة واحدة. التسلسل الذي ينجح تدريجي، ويُبقي كل مشروع قابلاً للإطلاق في كل خطوة:
- استورد، ولا تغيّر شيئاً آخر. أنشئ مساحة العمل وأدخل كل تطبيق قائم كمشروع مستقل، مع الحفاظ على تاريخ git حيث يهم (
nx importيفعل ذلك، ودمج الشجرة الفرعية يفعله يدوياً). عند هذه النقطة يكون لديك monorepo بلا أي مشاركة، وبالتالي بلا أي فائدة — وهذا صحيح — لكن كل شيء ما زال يُبنى ويُنشر تماماً كما كان. - ضع الوسوم لكل مشروع وفعّل قاعدة الحدود فوراً، بقيود متساهلة. تثبيت القاعدة مبكراً هو ما يمنع عمل الأسابيع الستة القادمة من إنتاج مخالفات جديدة ستضطر لتفكيكها لاحقاً.
- استخرج من قائمة الحصر، بدءاً بالأعلى تكراراً. مكتبة واحدة في كل طلب دمج: انقل، وأعد توجيه الاستيرادات، واحذف النسخ، وسلّم. وقاوم رغبة إعادة تصميم الواجهة أثناء النقل — فالنقل الذي يغيّر السلوك أيضاً نقلٌ لا يستطيع أحد مراجعته.
- شدّد القيود بعد اكتمال عمليات الاستخراج الواضحة وظهور شكل الاعتماديات الحقيقي في
nx graph.
ما الذي يكلّفك إياه هذا
أي مقالة أمينة يجب أن تتضمن الفاتورة.
- الملكية تصبح أصعب قبل أن تصبح أسهل. المكتبة المشتركة بلا مالك طبيعي. ومن دون
CODEOWNERSوقاعدة مراجعة، تتحول «المشتركة» إلى «ملك من لمسها آخر مرة». - نطاق الأثر حقيقي. أي تغيير في مكتبة يعتمد عليها الجميع يعيد بناء واختبار كل ما يليها. وهذا سلوك صحيح — فهو يخبرك بالحقيقة عن مدى الترابط لديك — لكنه يعني أن التزاماً متسرعاً في
type:utilيحوّل خط إنتاج مدته دقيقتان إلى عشرين دقيقة. - ترابط الإصدارات. إذا كان على العملاء النشر بشكل مستقل وبجداول مختلفة، فستحتاج خطوط نشر لكل مشروع مدفوعة بـ affected. وإلا فرض الـ monorepo بهدوء إصدارات متزامنة لم يوافق عليها أحد.
- جاذبية الأدوات. المولّدات، والمنفّذات، وعمليات الترحيل، وإضافات الـ CI — Nx اعتمادية حقيقية بعمل ترقية حقيقي. وأداة
nx migrateجيدة، لكنها ليست مجانية. - لا يصلح تجريداً سيئاً. تجميع أربع نسخ من مكوّن سيئ التصميم يعطيك مكوّناً واحداً سيئ التصميم تعتمد عليه أربعة فرق الآن. أزل التكرار في الكود الذي هو فعلاً الشيء نفسه، لا الكود الذي يبدو متشابهاً هذا الربع فقط.
لا شيء مما سبق يغيّر الخلاصة في هذه الحالة. عدة منتجات Angular متشابهة يصونها فريق صغير واحد هي الحالة النموذجية للـ monorepo، وكانت النتيجة القابلة للقياس خفضاً بنسبة 35% في منطق الواجهة المكرر. لكن الأداة لم تُنتج ذلك الرقم، بل أنتجه الحصر. Nx جعل الحدود قابلة للفرض والـ CI قابلاً للتحمل؛ أما تحديد ما ينتمي فعلاً إلى مكتبة مشتركة فكان هو العمل الهندسي الحقيقي.
تسجيل الدخول للتعليق
لنشر تعليق، يجب تسجيل الدخول. يرجى تسجيل الدخول. تسجيل الدخول
تعليقات (0)