مشروع التخرّج: تطبيق جافا حقيقي

نمذجة النطاق

25 دقيقة الدرس 3 من 13

نمذجة النطاق

كل تطبيق حقيقي يُبنى على نموذج نطاق — مجموعة الكيانات وكائنات القيمة والتعدادات التي تمنح مفاهيم العمل موطنًا في الكود. النموذج المصمم بعناية هو الفارق بين قاعدة كود يسهل توسيعها وأخرى تتراكم فيها الأخطاء مع تطور العمل. في هذا الدرس نأخذ المتطلبات من الدرس الأول ونترجمها إلى أنواع Java ملموسة: كلاسات وسجلات وتعدادات.

ما الذي نُنمذجه؟

تطبيقنا الختامي هو تطبيق إدارة مهام لفريق صغير. من تحليل المتطلبات حددنا هذه المفاهيم الأساسية:

  • المستخدم (User) — شخص يمكنه امتلاك المهام والتكليف بها.
  • المشروع (Project) — حاوية تجمع المهام ذات الصلة.
  • المهمة (Task) — عنصر العمل الأساسي، ينتمي إلى مشروع ويمكن تكليفه لمستخدم.
  • الأولوية (Priority) — تصنيف مرتب للمهام (منخفضة، متوسطة، عالية، حرجة).
  • حالة المهمة (TaskStatus) — حالة دورة حياة المهمة (للقيام، قيد التنفيذ، منجزة، ملغاة).
  • الوسم (Tag) — تسمية خفيفة يمكن إرفاقها بمهام متعددة.

التعدادات أولاً: التقاط المفردات الثابتة

تُرمّز التعدادات مجموعة قيم محدودة ومعروفة مسبقًا. استخدام ثوابت String بدلاً منها خطأ كلاسيكي — يسمح بالأخطاء الإملائية، ويجعل فحوصات switch الشاملة مستحيلة، ويُفقد دعم IDE. تعدادات Java يمكنها حمل حقول وسلوك مما يجعلها أكثر تعبيرية.

public enum Priority { LOW(1), MEDIUM(2), HIGH(3), CRITICAL(4); private final int level; Priority(int level) { this.level = level; } public int level() { return level; } public boolean isUrgentOrAbove() { return this.level >= HIGH.level; } }
public enum TaskStatus { TODO, IN_PROGRESS, DONE, CANCELLED; /** يعيد true إذا لم يعد بالإمكان إجراء أي عمل على مهمة بهذه الحالة. */ public boolean isTerminal() { return this == DONE || this == CANCELLED; } }
أضف السلوك إلى التعدادات لا إلى المُستدعِين. توابع مثل isUrgentOrAbove() وisTerminal() تمركّز المنطق الذي كان سيتفرق في سلاسل if عبر قاعدة الكود. عند تغيير التعريف (مثل إضافة حالة جديدة) يوجد مكان واحد بالضبط للتحديث.

كائنات القيمة باستخدام السجلات

سجلات Java 16 مثالية لـ كائنات القيمة — حاملات بيانات غير قابلة للتعديل، هويتها محددة بمحتواها لا بعنوانها في الذاكرة. Tag مثال مثالي: وسمان بنفس الاسم هما نفس الوسم.

/** * كائن قيمة غير قابل للتعديل يمثل تسمية مرفقة بالمهام. * المساواة هيكلية: وسمان بنفس الاسم متساويان. */ public record Tag(String name) { // المُنشئ المضغوط للتحقق من الصحة public Tag { if (name == null || name.isBlank()) { throw new IllegalArgumentException("Tag name must not be blank"); } name = name.strip().toLowerCase(); // الشكل القانوني } }

المُنشئ المضغوط (بلا أقواس) يعمل داخل المُنشئ الأساسي مما يتيح التحقق والتطبيع دون تكرار كود الإسناد. بعد الإنشاء يكون Tag مضمونًا ألا يكون null ولا فارغًا ويكون بأحرف صغيرة.

السجلات مقابل الكلاسات لكائنات القيمة: تولّد السجلات تلقائيًا equals وhashCode وtoString والمُتاحات. استخدم كلاسًا حين تحتاج وراثة أو تهيئة كسولة أو حالة قابلة للتعديل. استخدم سجلاً حين يكون الكائن مجرد حامل بيانات شفاف غير قابل للتعديل.

كيان المستخدم

الكيان له هوية — كائنا User بنفس id يشيران إلى نفس الشخص حتى لو اختلفت الحقول الأخرى. نستخدم كلاسًا (لا سجلاً) لأن الكيانات عادةً لها دورة حياة قابلة للتعديل وتُتتبع بالهوية لا بالقيمة.

import java.util.UUID; public final class User { private final UUID id; private String displayName; private String email; public User(String displayName, String email) { this.id = UUID.randomUUID(); this.displayName = requireNonBlank(displayName, "displayName"); this.email = requireValidEmail(email); } // مُنشئ ذو وصول للحزمة لطبقة الاستمرارية (يُمرر id موجود) User(UUID id, String displayName, String email) { this.id = id; this.displayName = displayName; this.email = email; } public UUID id() { return id; } public String displayName() { return displayName; } public String email() { return email; } public void rename(String newName) { this.displayName = requireNonBlank(newName, "displayName"); } @Override public boolean equals(Object o) { if (this == o) return true; if (!(o instanceof User u)) return false; return id.equals(u.id); } @Override public int hashCode() { return id.hashCode(); } @Override public String toString() { return "User[id=" + id + ", name=" + displayName + "]"; } private static String requireNonBlank(String value, String field) { if (value == null || value.isBlank()) throw new IllegalArgumentException(field + " must not be blank"); return value.strip(); } private static String requireValidEmail(String email) { if (email == null || !email.contains("@")) throw new IllegalArgumentException("Invalid email: " + email); return email.strip().toLowerCase(); } }
لا تكشف المجموعات القابلة للتعديل مباشرة. إذا احتوى User لاحقًا على قائمة مهام مُعيّنة، أعد Collections.unmodifiableList(tasks) أو نسخة دفاعية من المُتاح. تسريب مرجع قابل للتعديل يسمح للمُستدعِين بإفساد الحالة الداخلية بصمت.

كيان المهمة

Task هو الكيان المحوري. يُجمّع عدة أنواع من أنواعنا ويُطبّق قواعد العمل عبر توابعه الخاصة بدلاً من إيكال التطبيق إلى المُستدعِين.

import java.time.Instant; import java.util.Collections; import java.util.HashSet; import java.util.Set; import java.util.UUID; public final class Task { private final UUID id; private final UUID projectId; private String title; private String description; private Priority priority; private TaskStatus status; private UUID assigneeId; // nullable — المهام غير المُعيّنة صالحة private final Instant createdAt; private Instant updatedAt; private final Set<Tag> tags; public Task(UUID projectId, String title, Priority priority) { this.id = UUID.randomUUID(); this.projectId = Objects.requireNonNull(projectId, "projectId"); this.title = requireNonBlank(title, "title"); this.priority = Objects.requireNonNull(priority, "priority"); this.status = TaskStatus.TODO; this.createdAt = Instant.now(); this.updatedAt = this.createdAt; this.tags = new HashSet<>(); } // --- انتقالات الحالة ------------------------------------------------- public void assign(UUID userId) { guardNotTerminal(); this.assigneeId = userId; touch(); } public void start() { if (status != TaskStatus.TODO) throw new IllegalStateException("Only TODO tasks can be started; current: " + status); this.status = TaskStatus.IN_PROGRESS; touch(); } public void complete() { guardNotTerminal(); this.status = TaskStatus.DONE; touch(); } public void cancel() { guardNotTerminal(); this.status = TaskStatus.CANCELLED; touch(); } public void addTag(Tag tag) { guardNotTerminal(); tags.add(Objects.requireNonNull(tag)); touch(); } // --- المُتاحات ------------------------------------------------------- public UUID id() { return id; } public UUID projectId() { return projectId; } public String title() { return title; } public Priority priority() { return priority; } public TaskStatus status() { return status; } public UUID assigneeId() { return assigneeId; } public Instant createdAt() { return createdAt; } public Instant updatedAt() { return updatedAt; } public Set<Tag> tags() { return Collections.unmodifiableSet(tags); } // --- مساعدات خاصة ---------------------------------------------------- private void guardNotTerminal() { if (status.isTerminal()) throw new IllegalStateException("Cannot modify a task in status: " + status); } private void touch() { this.updatedAt = Instant.now(); } private static String requireNonBlank(String v, String field) { if (v == null || v.isBlank()) throw new IllegalArgumentException(field + " must not be blank"); return v.strip(); } }

كيان المشروع

Project يجمع المهام وله هويته الخاصة. للإيجاز يُبقى بسيطًا هنا — في الدروس اللاحقة ستمتلك طبقة الخدمة منطق إضافة المهام إلى المشاريع.

import java.time.Instant; import java.util.UUID; public final class Project { private final UUID id; private String name; private String description; private final Instant createdAt; public Project(String name, String description) { this.id = UUID.randomUUID(); this.name = requireNonBlank(name, "name"); this.description = description == null ? "" : description.strip(); this.createdAt = Instant.now(); } public UUID id() { return id; } public String name() { return name; } public String description() { return description; } public Instant createdAt() { return createdAt; } public void rename(String newName) { this.name = requireNonBlank(newName, "name"); } @Override public boolean equals(Object o) { if (!(o instanceof Project p)) return false; return id.equals(p.id); } @Override public int hashCode() { return id.hashCode(); } private static String requireNonBlank(String v, String field) { if (v == null || v.isBlank()) throw new IllegalArgumentException(field + " must not be blank"); return v.strip(); } }

مقايضات التصميم الجديرة بالمعرفة

  • UUID مقابل المعرّف ذاتي الزيادة: المعرّفات UUID فريدة عالميًا وآمنة للتوليد من جانب العميل، وهذا مهم للأنظمة الموزعة والتصاميم دون اتصال. الثمن تخزين أكبر وإستعلامات مُفهرَسة أبطأ قليلاً في قواعد البيانات. الزيادة التلقائية أبسط وأسرع للتطبيقات أحادية العقدة. نستخدم UUID هنا لأن معمارنا يستبق التكامل مع طبقة البيانات عبر JDBC.
  • النموذج الغني مقابل النموذج الهزيل: كياناتنا تتحقق من نفسها وتُطبّق انتقالات الحالة. البديل هو النموذج الهزيل — حقائب بيانات بسيطة يسكن منطقها كليًا في كلاسات الخدمة. النموذج الغني يُبقي قواعد العمل قريبة من البيانات التي تحميها؛ النموذج الهزيل أبسط ويتعامل مع صفوف قاعدة البيانات بشكل أكثر مباشرة.
  • السجلات للكيانات: السجلات لا يمكن توريثها وليس لها حالة خاصة قابلة للتعديل. إنها خاطئة للكيانات لكنها صحيحة لكائنات القيمة مثل Tag.

الخلاصة

يتكون نموذج النطاق لتطبيقنا الختامي من تعدادين (Priority وTaskStatus)، وسجل كائن قيمة (Tag)، وثلاثة كلاسات كيانات (User وProject وTask). كل نوع يتحقق من ثوابته الخاصة في المُنشئ، ويكشف فقط ما يحتاجه المُستدعون، ويُطبّق انتقالات الحالة عبر التوابع بدلاً من إيكال تلك المسؤولية للمُستدعين. مع اكتمال نموذج النطاق سيربط الدرس الرابع هذه الأنواع بقاعدة بيانات علائقية عبر JDBC.