التعدادات والسجلّات والأنواع المختومة

مشروع: نمذجة نطاق عمل

15 دقيقة الدرس 10 من 13

مشروع: نمذجة نطاق عمل

أفضل طريقة لترسيخ فهمك للـ enums والـ records والأنواع المغلقة هي استخدامها معًا على مشكلة حقيقية. في هذا الدرس نُنمذج نطاق الطلبات في تطبيق تجارة إلكترونية: للطلب دورة حياة محدّدة، وعناصره كائنات قيمة غير قابلة للتغيير، وكل نتيجة دفع تنتمي إلى مجموعة ثابتة من الحالات. في نهاية الدرس ستمتلك نطاقًا صغيرًا صحيحًا بطبيعة بنائه — الحالات غير الصالحة غير قابلة للتمثيل حرفيًا.

نظرة عامة على النطاق

  • OrderStatus — enum يُحرّك دورة حياة الطلب (PENDING ← CONFIRMED ← SHIPPED ← DELIVERED، أو CANCELLED).
  • OrderItem — record يمثّل سطرًا واحدًا في الطلب (اسم المنتج، الكمية، سعر الوحدة).
  • Order — record يجمع قائمة من OrderItem وحالة OrderStatus.
  • PaymentResult — واجهة مغلقة بثلاثة تطبيقات: Success وDeclined وError.
  • OrderProcessor — صنف يربط القطع معًا، مستخدمًا مطابقة الأنماط للتعامل مع PaymentResult.
لماذا هذا الأسلوب؟ كل نوع يحمل البيانات التي يحتاجها فقط. تحمل Success معرّف المعاملة، وتحمل Declined رمز الرفض، وتحمل Error رسالة الخطأ. لا يمكن الخلط بينها في وقت الترجمة.

الخطوة 1 — Enum دورة حياة الطلب

يجعل الـ enum دورة الحياة صريحة. نضيف أيضًا دالة مساعدة canTransitionTo لتوضع قاعدة العمل في مكان واحد بدلًا من تشتيتها في سلاسل if.

public enum OrderStatus { PENDING, CONFIRMED, SHIPPED, DELIVERED, CANCELLED; public boolean canTransitionTo(OrderStatus next) { return switch (this) { case PENDING -> next == CONFIRMED || next == CANCELLED; case CONFIRMED -> next == SHIPPED || next == CANCELLED; case SHIPPED -> next == DELIVERED; case DELIVERED, CANCELLED -> false; // حالات نهائية }; } }

لأن الـ switch على enum شامل (يتحقق منه المُترجم)، فإن إضافة حالة جديدة لاحقًا ستُسبّب خطأ في الترجمة هنا — تمامًا حيث تنتمي منطق العمل.

الخطوة 2 — Record عنصر الطلب

سطر الطلب قيمة خالصة: نفس اسم المنتج ونفس الكمية ونفس السعر يعني نفس العنصر. يمنحنا الـ record تلك المساواة، وعدم القابلية للتغيير، وإعلانًا مختصرًا مجانًا.

import java.math.BigDecimal; public record OrderItem(String productName, int quantity, BigDecimal unitPrice) { // منشئ مختصر — التحقق عند الإنشاء public OrderItem { if (quantity <= 0) throw new IllegalArgumentException("Quantity must be positive"); if (unitPrice.signum() < 0) throw new IllegalArgumentException("Unit price cannot be negative"); } public BigDecimal lineTotal() { return unitPrice.multiply(BigDecimal.valueOf(quantity)); } }
استخدم BigDecimal للمبالغ المالية. لا يستطيع double أو float تمثيل معظم الكسور العشرية بدقة. حسابات الأسعار بالفاصلة العائمة ستنتج في النهاية نتائج كـ 9.999999999 بدلًا من 10.00 — استخدم BigDecimal في أي نطاق مالي.

الخطوة 3 — Record الطلب

يغلّف record الـ Order قائمة العناصر والحالة الحالية. يُشتق الإجمالي من عناصره ويوفّر دالة للتقدّم في دورة الحياة.

import java.math.BigDecimal; import java.util.List; public record Order(String id, List<OrderItem> items, OrderStatus status) { public Order { if (items == null || items.isEmpty()) throw new IllegalArgumentException("An order must have at least one item"); items = List.copyOf(items); // نسخة دفاعية — يجب أن تكون الـ records غير قابلة للتغيير حقًا } public BigDecimal total() { return items.stream() .map(OrderItem::lineTotal) .reduce(BigDecimal.ZERO, BigDecimal::add); } /** تُعيد Order جديدة بالحالة المُحدَّثة، أو ترمي استثناءً إن كانت الانتقالة غير مشروعة. */ public Order withStatus(OrderStatus next) { if (!status.canTransitionTo(next)) throw new IllegalStateException( "Cannot transition from " + status + " to " + next); return new Order(id, items, next); } }

لاحظ أن withStatus تُعيد Order جديدة بدلًا من تعديل الموجودة. تُعزّز الـ records اللاتغييرية؛ كل تغيير في الحالة يُنتج قيمة جديدة.

الخطوة 4 — الواجهة المغلقة PaymentResult

نتائج الدفع مجموعة مغلقة. تتيح الواجهة المغلقة للمُترجم معرفة كل الحالات الممكنة، وكل حالة تحمل بيانات مختلفة.

public sealed interface PaymentResult permits PaymentResult.Success, PaymentResult.Declined, PaymentResult.Error { record Success(String transactionId) implements PaymentResult {} record Declined(String reasonCode) implements PaymentResult {} record Error(String message) implements PaymentResult {} }
تداخل الـ records داخل الواجهة المغلقة يُبقي مساحة الأسماء مرتّبة. تُشير إليها بـ PaymentResult.Success مما يُقرأ كجملة مفهومة: "نتيجة الدفع هي نجاح."

الخطوة 5 — معالج الطلبات

المعالج يُقيّد العميل وبناءً على نتيجة الدفع يُقدّم الطلب أو يُلغيه. تتولّى مطابقة الأنماط في switch كل متغيّر دون الحاجة إلى cast.

public class OrderProcessor { /** محاكاة بوابة دفع وإعادة الطلب المُحدَّث. */ public Order process(Order order, PaymentResult result) { return switch (result) { case PaymentResult.Success s -> { System.out.println("Payment OK — txn " + s.transactionId()); yield order.withStatus(OrderStatus.CONFIRMED); } case PaymentResult.Declined d -> { System.out.println("Payment declined: " + d.reasonCode()); yield order.withStatus(OrderStatus.CANCELLED); } case PaymentResult.Error e -> { System.out.println("Gateway error: " + e.message()); yield order; // إبقاء الحالة دون تغيير؛ إعادة المحاولة لاحقًا } }; } }

الخطوة 6 — تجميع القطع

import java.math.BigDecimal; import java.util.List; public class Main { public static void main(String[] args) { var item1 = new OrderItem("Wireless Keyboard", 1, new BigDecimal("49.99")); var item2 = new OrderItem("USB Hub", 2, new BigDecimal("19.99")); var order = new Order("ORD-001", List.of(item1, item2), OrderStatus.PENDING); System.out.println("Total: " + order.total()); // 89.97 var processor = new OrderProcessor(); // المسار الطبيعي var confirmed = processor.process(order, new PaymentResult.Success("TXN-XYZ")); System.out.println("Status: " + confirmed.status()); // CONFIRMED // مسار الرفض var cancelled = processor.process(order, new PaymentResult.Declined("INSUFFICIENT_FUNDS")); System.out.println("Status: " + cancelled.status()); // CANCELLED } }

ما الذي يجعل هذا التصميم جيدًا؟

  • الحالات غير الصالحة غير قابلة للتمثيل. لا يمكن إنشاء OrderItem بكمية سالبة؛ ترفض Order قوائم العناصر الفارغة؛ وترفض canTransitionTo انتقالات الحالة غير المشروعة.
  • المُترجم يتحقق من الشمولية. إن أضفت متغيّرًا جديدًا لـ PaymentResult، لن يُكمل switch في OrderProcessor الترجمة حتى تُعالجه.
  • البيانات والسلوك معًا. تعيش lineTotal() في OrderItem؛ وتعيش total() في Order؛ وتعيش قواعد الانتقال في OrderStatus.
  • اللاتغييرية في كل مكان. كل تغيير في الحالة يُنتج كائنًا جديدًا — لا أخطاء تعديل خفية.
لا تُفرط في استخدام الـ records لكيانات ذات هوية. الـ records كائنات قيمة. يعمل record الـ Order هنا لأننا نتعامل مع الطلبات كلقطات غير قابلة للتغيير. إن احتجت كيانات JPA بحقول قابلة للتعديل، استخدم صنفًا عاديًا. امزج الاثنين: استخدم الـ records لكائنات القيمة (المال، العناوين، النتائج) والصنوف العادية للكيانات القابلة للتعديل.

الخلاصة

في هذا الدرس الختامي جمعت الميزات الثلاث — الـ enums والـ records والأنواع المغلقة — في نموذج نطاق متماسك. النتيجة معبّرة ومختصرة وصعبة الاستخدام الخاطئ: يُطبّق نظام الأنواع قواعد العمل بحيث لا تحتاج إلى كتابة فحوصات null دفاعية ومقارنات سلاسل الحالة في كل مكان من قاعدة الكود. هذا هو العائد من تعلّم هذه الميزات الحديثة في Java.