المعمارية السداسية داخل Lambda، مع الكود

كيف بقيت أكثر من خمسين دالة Lambda بلغة TypeScript قابلة للاختبار ومتّسقة: معالجات من عشرة أسطر، منافذ ومحوّلات، وحالة استخدام واحدة تخدم مداخل الـ API والطوابير والدفعات.

اللحظة التي أيقنت فيها أن البنية سدّدت ثمنها كانت لحظة غير لامعة: تغيّرت قاعدة تحقق واحدة، فعُدِّلت حالة استخدام واحدة، والتقطت التغييرَ ثلاثةُ مسارات تنفيذ مختلفة — الفحص المتزامن عبر الـ API، والمسار الجماعي عبر SQS، وإعادة التحقق قبل التسجيل — دون أي عمل إضافي. في منصة تضم أكثر من خمسين دالة Lambda بلغة TypeScript، هذه هي اللعبة كلها: الكود الذي يتغيّر يجب أن يسكن في مكان واحد بالضبط، والخمسون مدخلًا يجب أن تكون من الرقّة بحيث لا تخفي شيئًا.

هذه هي البنية التي أوصلتنا إلى هناك، بالشكل الحقيقي للكود.

التخطيط

كل دالة في قاعدة الكود تتبع الهيكل نفسه:

src/
  core/domains/<domain>/
    domain/       # الكيانات، كائنات القيمة، أخطاء المجال
    application/  # حالات الاستخدام — صنف لكل عملية
    ports/        # الواجهات التي يعرّفها المجال
  adapters/
    primary/      # المداخل: معالجات http، المخططات، المقدّمات
    secondary/    # التنفيذات: الإتاحة (Prisma)، التخزين، الطوابير
  containers/     # مصانع حالات الاستخدام — التوصيل في مكان واحد

القاعدة الحاملة للوزن تخص المعالجات: المعالج عشرة أسطر. حلّل الحدث، استدعِ حالة الاستخدام، حوّل النتيجة إلى وسيلة النقل. إذا نبت if في معالج، فمنطق العمل يتسرّب إلى طبقة النقل.

// adapters/primary/http/handlers/validate-record.ts
export const handler = async (event: APIGatewayProxyEvent) => {
  const input = parseValidateRequest(event);        // يرمي أخطاء 400 منمّطة
  const result = await getValidateRecordUsecase().run(input); // من containers/
  return toApiResponse(result);                     // تحويل الحالة + الجسم
};

المنافذ تجعل حالة الاستخدام قابلة للنقل

حالة الاستخدام لا تعرف شيئًا عن Lambda ولا Prisma ولا SQS. هي تعتمد على منافذ — واجهات يعرّفها المجال:

// core/domains/records/application/validate-record.usecase.ts
export class ValidateRecordUseCase {
  constructor(
    private readonly masters: MasterDataPort,   // فحوص الوجود
    private readonly records: RecordRepository, // الإتاحة
  ) {}

  async run(input: ValidateInput): Promise<ValidationResult> {
    const branch = await this.masters.findBranch(input.branchCode);
    if (!branch) return ValidationResult.reject("UNKNOWN_BRANCH");
    // ... القواعد الفعلية، في مكان واحد
  }
}

المحوّلات الثانوية تنفّذ المنافذ: مستودع مبني على Prisma في الإنتاج، وبديل في الذاكرة في الاختبارات؛ بينما تملك المحوّلات الأولية (المعالجات والمخططات والمقدّمات) حافة النقل. العائد يظهر في موضعين:

الاختبارات تعمل في أجزاء من الثانية. اختبارات الوحدة تمرّن حالات الاستخدام على بدائل في الذاكرة — لا محاكي Lambda، لا قاعدة بيانات في Docker، ولا AWS في الحلقة. ظلّت الحزمة في نطاق مئات المللي ثانية مع نموّ المنصة، وهذا هو الفارق بين اختبارات يشغّلها الناس قبل كل commit واختبارات يشغّلونها قبل كل إصدار.

المداخل تتكاثر مجانًا. نفس ValidateRecordUseCase تتقدّمه واجهة API Gateway (سجل واحد، متزامن)، ومعالج SQS (جماعي، غير متزامن)، وخطوة التسجيل (إعادة تحقق قبل الإرسال النهائي). ثلاث وسائل نقل، وتنفيذ واحد للقواعد — ولهذا كلّف تغيير القاعدة في مطلع هذا المقال تعديلًا واحدًا.

ما الذي يشتريه الاتّساق عبر خمسين دالة

بنية كهذه ضريبة صغيرة عند الدالة الأولى وعائد مركّب عند الدالة الخمسين:

  • التنقل موحّد. أي مهندس يفتح أي دالة يعرف أين المنطق، وأين الإدخال/الإخراج، وما الذي يُحاكى في الاختبار. في قاعدة كود تتناوب عليها الأيدي، كان هذا أهم من أي قرار تصميمي منفرد.
  • المراجعة تزداد حدّة. «لماذا يوجد import لـ Prisma في معالج؟» تعليق مراجعة من سطر واحد يصطاد فئة كاملة من التآكل.
  • المجال يبقى صادقًا. لأن حالات الاستخدام لا تستطيع مدّ يدها إلى إطار العمل، فإن إغراءات مثل «فقط اقرأ هذا الشيء الواحد من الحدث الخام» لا تجد مكانًا تذهب إليه.

التكاليف الصادقة والحواف

  • الكود المتكرر حقيقي. كل عملية تحمل معالجًا وحالة استخدام ومنافذ ومحوّلات. لعملية بحث من سطرين تبدو المراسم سخيفة — دفعناها مع ذلك، لأن البديل المختلط («البسيط معفى») هو بالضبط الطريقة التي تموت بها الاتفاقية.
  • البدء البارد يهتم بالاستيرادات لا بالطبقات. الطبقات نفسها لم تكلّف شيئًا وقت التشغيل، لكن المحوّلات التي تبني عملاء ثقالًا بحماس كلّفت. أنشئ العملاء مرة واحدة خارج المعالج، وأبقِ شجرة الاستيرادات خفيفة.
  • الاختبار الشامل محليًا ما يزال يحتاج بيئة حقيقية. أنقذت البنية السداسية اختبارات الوحدة، لكنها لا تحاكي IAM ولا redrive في SQS ولا غرائب API Gateway. احتفظنا بعشر بيئات مُدارة بـ Terraform لهذا السبب تحديدًا: ليكون لاختبار التكامل مكان حقيقي يحدث فيه.

إن كنت تبدأ قاعدة كود serverless تتوقع أن تتجاوز اثنتي عشرة دالة، فقرّر البنية الداخلية قبل الدالة الخامسة — واجعل قاعدة «المعالج عشرة أسطر» غير قابلة للتفاوض. كل ما عدا ذلك في هذا المقال يمكن اشتقاقه من ذلك القيد الواحد.