الدالة التي تنفذ عملية أعمال مهمة لا ينبغي أن توحي بأنها تنجح أو تفشل بطريقة غير معروفة. في Kotlin، قد تبدو دالة توقّع مستنداً بهذا الشكل: fun signDocument(documentId: UUID, code: String): Unit. لكن هذه الصيغة لا تخبر مستدعيها بأن رمز التوقيع قد يكون خاطئاً، أو أن نافذة التوقيع أُغلقت، أو أن المستند وُقّع مسبقاً. يرى Sergey Chernov، مهندس البرمجيات الرئيسي في Salmon، أن هذه النتائج المتوقعة يجب أن تظهر في توقيع الدالة نفسه، لا أن تبقى مخفية داخل التنفيذ.
المقاربة المقترحة هي إعادة تعريف الدالة بحيث تعيد نوعاً يصف النجاح والفشل معاً، مثل Either<DocumentSignError, Unit>. في هذه الحالة، يصبح من الواضح أن الدالة إما تعيد خطأً من النوع المحدد أو تنجح من دون قيمة مفيدة. ولا يتعلق الأمر بإضافة تعقيد نظري إلى Kotlin، بل بجعل عقد الواجهة قابلاً للقراءة والتحقق من جانب المترجم.
أي الأخطاء يجب أن تظهر في العقد؟
يفرق Chernov بين ثلاثة أنواع من الإخفاقات، لأن خلطها يؤدي إلى نماذج أخطاء غير دقيقة:
- أخطاء عميل الواجهة البرمجية: مثل JSON غير صالح، أو رأس مفقود، أو عملية غير مدعومة، أو طلب غير مسموح به. هذه الحالات لا تمثل عادة نتائج أعمال يتعين على التطبيق السليم تصميم شاشات خاصة لها، ولذلك يمكن اختزالها في استجابات HTTP عامة مثل 400 أو 403 أو 404.
- الاستثناءات غير المتوقعة: مثل توقف قاعدة البيانات، أو انتهاء مهلة خدمة خارجية، أو انقطاع الشبكة، أو ظهور حالة غير قانونية. هذه ليست نتائج أعمال، بل إشارات تشغيلية ينبغي تسجيلها ومراقبتها وإرجاع 500 عند الحاجة، مع إبقائها استثناءات يتعامل معها المعالج العام.
- أخطاء المجال: وهي الحالات التي يتصرف فيها العميل بصورة صحيحة، لكن العملية لا يمكن إتمامها. من أمثلتها رفض رمز التوقيع، وانتهاء نافذة التوقيع، ووجود توقيع سابق، وغياب الموافقة أو رفض السياسة. هذه الحالات هي التي يحتاج المستخدم إلى تمييزها، ولذلك يجب أن تكون جزءاً من نوع الإرجاع.
الفارق المهم هنا أن استجابة HTTP مثل 400 ليست مفهوماً من مفاهيم المجال، بينما عبارة «انتهت نافذة التوقيع» تمثل نتيجة أعمال محددة. وإذا كان على العميل التعامل مع نتيجتين بطريقة مختلفة، فيجب أن يكون النوع قادراً على التمييز بينهما.
لماذا لا تكفي الاستثناءات؟
في الأسلوب التقليدي، تتحقق الدالة من الشروط ثم ترمي استثناءات مثل SigningWindowClosedException أو SignatureRejectedException. لكن توقيعها يظل يوحي بأنها تعيد Unit وتنجح ما لم يحدث شيء غير معلن. كما أن إضافة استثناء جديد لا تمنع مواقع الاستدعاء من الاستمرار في الترجمة، حتى لو لم تعالجه. وقد لا يظهر النقص إلا في بيئة الإنتاج.
تقدم Kotlin وسيلة أوضح عبر sealed interface. يمكن تعريف DocumentSignError بحيث يضم أنواعاً محددة مثل SignatureRejected وSigningWindowClosed وAlreadySigned. وعند استخدام تعبير when لمعالجة هذه النتائج، يستطيع المترجم التحقق من استيفاء جميع الحالات. وإذا أضيف خطأ رابع إلى النوع المغلق، فسيتوقف الكود الذي لم يعالجه عن الترجمة حتى تتم إضافة الفرع المناسب.
بعد تحديد مجموعة الأخطاء، يأتي دور Either، وهو نوع يمثل قيمة واحدة من احتمالين: Left للفشل وRight للنجاح. ويمكن استخدام دوال مساعدة مثل map وflatMap وfold وgetOrElse لتكوين العمليات. ولا يعتمد المبدأ على مكتبة بعينها، رغم أن المقال يذكر Arrow ويوضح أن either مع bind يمكن أن يجعل تسلسل الخطوات أكثر وضوحاً، مع إيقاف المسار عند أول Left.
تجنب نوعي Either غير المفيدين
يحذر المقال من استخدام Either<Throwable, Unit>، لأن Throwable نوع مفتوح لا يصف الأخطاء المتوقعة. وعند معالجة هذا النوع سيحتاج الكود غالباً إلى فرع else، فتعود المشكلة الأصلية: لا يعرف المستدعي ما الحالات التي ينبغي عليه التعامل معها.
كما أن إنشاء اتحاد واسع واحد لكل عمليات المستندات يضر بالدقة. فإذا كان DocumentError يضم أخطاء التوقيع والتجهيز والتصدير معاً، فستبدو كل دالة وكأنها قادرة على إعادة جميع هذه الأخطاء، رغم أن signDocument لا يمكنها مثلاً إعادة TemplateNotFound. والنتيجة فروع مستحيلة يتعين على المستدعي كتابتها.
البديل هو تعريف اتحاد ضيق لكل دالة عامة: DocumentSignError للتوقيع، وPrepareSigningError لتجهيز جلسة التوقيع، وExportError للتصدير. قد يتطلب ذلك كتابة أنواع أكثر، لكنه يجعل كل عقد يعكس الحالات التي يمكن للعملية إنتاجها فعلاً.
ما الذي يتغير عملياً في بنية النظام؟
ينبغي أن تبقى أخطاء المجال typed عبر طبقات النظام. تعيد الخدمات والمستودعات أخطاء المجال، بينما يحدث تحويلها إلى HTTP في نقطة واحدة فقط، هي حدود المسار أو route boundary. عندها تتحول أخطاء المجال المتوقعة إلى استجابة Left ثم إلى HTTP مناسب، في حين تظل أخطاء البنية التحتية والأعطال البرمجية استثناءات تتحول إلى 500. وبهذا لا تضطر الطبقات الداخلية إلى معرفة تفاصيل HTTP.
وإذا نُشر عميل الواجهة البرمجية مع الخدمة، يقترح المقال نشر أنواع الأخطاء معه أيضاً، حتى يستخدم العميل نفس الاتحاد المغلق الذي تنتجه الخدمة. هذا يقلل احتمالات اختلاف فهم الطرفين للحالات الممكنة.
تظهر فائدة إضافية في مراجعة الكود، ولا سيما مع ازدياد الاعتماد على الكود الذي تنتجه الوكلاء البرمجية. يستطيع المراجع البدء من العقد: ما الأخطاء الممكنة؟ هل أضيفت حالة جديدة؟ هل هي خطأ مجال فعلاً أم مشكلة بنية تحتية؟ وهل لها تحويل HTTP مناسب؟ ويظل التنفيذ بحاجة إلى مراجعة، لكن التوقيع يوفر نقطة فحص مختصرة لا تتطلب تتبع عشرات الملفات.
تقر هذه المقاربة بتكلفتها: أنواع أكثر، وتفاصيل تحويل إضافية، وتوقيعات أطول. لكنها لا تنشئ التعقيد من العدم؛ بل تنقله من داخل التنفيذ المخفي إلى نوع معلن يمكن اختباره والتحقق منه أثناء الترجمة. وبذلك تصبح الدالة أكثر صدقاً بشأن ما تستطيع فعله، ويصبح التعامل مع أخطائها مسؤولية ظاهرة على المستدعي بدلاً من أن تكون مفاجأة لاحقة.