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

تصميم البنية المعمارية

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

تصميم البنية المعمارية

في كل تطبيق Java غير تافه، تصل في لحظة ما إلى نقطة يصبح فيها حشو كل شيء في صنف واحد — أو حتى في مجموعة مسطّحة من الصفوف — أمرًا مؤلمًا. تتموّج التغييرات بشكل غير متوقع، يستحيل كتابة الاختبارات بمعزل، وإدخال زميل جديد في الفريق يعني قراءة قاعدة الكود بالكامل قبل لمس أي شيء. بنية معمارية مدروسة تمنع كل هذا.

في هذا الدرس ستصمّم البنية المطبّقة (layered architecture) لتطبيق المشروع النهائي، ستتعلّم لماذا توجد كل طبقة، وما الذي ينتمي إليها، وستنظّم كل شيء في هيكل حزم (packages) يُكرّس تلك الحدود بالاتفاق وبالأدوات أيضًا عند الحاجة.

النموذج الكلاسيكي ذو الثلاث طبقات

معظم تطبيقات الأعمال تعمل بشكل جيد مع ثلاث طبقات أفقية متراصّة:

  1. طبقة العرض / الواجهة — تستقبل المدخلات وتُقدّم المخرجات. في تطبيق CLI هذا يعني تحليل الوسطاء وإخراج النتائج على الطرفية. في خدمة REST هذا يعني المتحكّمات HTTP وتسلسل JSON.
  2. طبقة الأعمال / النطاق (Domain) — تحتوي القواعد التي تجعل التطبيق ذا قيمة. هذه الطبقة لا تعرف شيئًا عن كيفية وصول البيانات أو أين تذهب.
  3. طبقة البيانات / البنية التحتية — تُثبّت البيانات وتسترجعها. في مشروعنا النهائي هذا سيكون JDBC أو مخزنًا في الذاكرة؛ في نظام إنتاجي قد يكون JPA أو واجهة برمجية خارجية أو وسيط رسائل وما إلى ذلك.
قاعدة الاعتماد: تتدفق الاعتماديات للداخل فقط. طبقة العرض تعتمد على طبقة الأعمال؛ طبقة الأعمال تعتمد على التجريدات (الواجهات) التي تُنفّذها طبقة البيانات. طبقة الأعمال لا تستورد أبدًا صنف JDBC. إذا وجدت نفسك مغريًا بذلك، فقد اكتشفت انتهاكًا للطبقات.

تحويل الطبقات إلى حزم Java

الحزم هي الآلية الأساسية في Java للتعبير عن النية المعمارية. تصميم الحزم المختار بعناية يجعل البنية مرئية لكل من يقرأ قاعدة الكود، وتستطيع معظم أدوات التحليل الساكن (ArchUnit، Checkstyle، SpotBugs) فرض قواعد مستوى الحزمة في بيئة CI.

للمشروع النهائي — وهو CLI صغير لإدارة المهام — يبدو التصميم العملي على النحو التالي:

com.example.taskmanager ├── Main.java // نقطة الدخول فقط — تُفوّض فورًا │ ├── ui/ // طبقة العرض │ ├── ConsoleApp.java // حلقة التفاعل الرئيسية │ ├── CommandParser.java // يُحلّل String[] args أو stdin الخام │ └── Formatter.java // يُنسّق كائنات النطاق للعرض │ ├── service/ // طبقة الأعمال │ ├── TaskService.java // ينسّق حالات الاستخدام │ └── ValidationService.java // قواعد نطاق بحتة، بلا I/O │ ├── repository/ // تجريد طبقة البيانات (الواجهات هنا) │ ├── TaskRepository.java // الواجهة │ └── InMemoryTaskRepository.java // التنفيذ │ ├── model/ // نموذج النطاق المشترك — لا تملكه طبقة واحدة │ ├── Task.java │ ├── Priority.java │ └── Status.java │ └── exception/ // استثناءات خاصة بالتطبيق ├── TaskNotFoundException.java └── ValidationException.java
ضع الواجهات بجوار مستهلكيها، لا بجوار تنفيذاتها. تعيش TaskRepository في repository/ لأنها العقد؛ أما InMemoryTaskRepository (أو لاحقًا JdbcTaskRepository) فهي تفصيل تنفيذي يُحقّق ذلك العقد. تبديل التنفيذات لا يتطلّب أي تغيير في طبقة الخدمة.

لماذا نُفرد حزمة النموذج؟

حزمة model تحتوي كائنات Java البسيطة — سجلّات (records) أو صفوف خالية من تعليقات الإطار والـSQL والـHTTP — التي تمثّل المفاهيم التي يُفكّر فيها التطبيق. جميع الطبقات تستخدم كائنات النموذج بحرية، لذا وضعه داخل أي طبقة واحدة سيخلق اعتمادية مصطنعة. يقف وحيدًا.

// model/Task.java — سجل Java 17؛ غير قابل للتغيير، بلا اعتماديات package com.example.taskmanager.model; import java.time.LocalDate; import java.util.UUID; public record Task( UUID id, String title, String description, Priority priority, Status status, LocalDate dueDate ) { // منشئ مضغوط للتحقق public Task { if (title == null || title.isBlank()) { throw new IllegalArgumentException("Task title must not be blank"); } } }

واجهة المستودع — تجريد الإصرار

طبقة الأعمال لا تستدعي JDBC أبدًا ولا تلمس ملفًا مباشرة. بدلًا من ذلك تتحدّث إلى واجهة مستودع مُعرَّفة بمصطلحات كائنات النطاق. هذا هو نمط المستودع من التصميم المدفوع بالنطاق (DDD)، وهو أحد أعلى الأنماط قيمة في Java للمؤسسات.

// repository/TaskRepository.java package com.example.taskmanager.repository; import com.example.taskmanager.model.Task; import com.example.taskmanager.model.Status; import java.util.List; import java.util.Optional; import java.util.UUID; public interface TaskRepository { Task save(Task task); Optional<Task> findById(UUID id); List<Task> findAll(); List<Task> findByStatus(Status status); void delete(UUID id); }

التنفيذ — في الذاكرة حاليًا — يعيش في نفس الحزمة لكنه مجرد تفصيل:

// repository/InMemoryTaskRepository.java package com.example.taskmanager.repository; import com.example.taskmanager.model.Task; import com.example.taskmanager.model.Status; import java.util.*; public class InMemoryTaskRepository implements TaskRepository { private final Map<UUID, Task> store = new LinkedHashMap<>(); @Override public Task save(Task task) { store.put(task.id(), task); return task; } @Override public Optional<Task> findById(UUID id) { return Optional.ofNullable(store.get(id)); } @Override public List<Task> findAll() { return List.copyOf(store.values()); } @Override public List<Task> findByStatus(Status status) { return store.values().stream() .filter(t -> t.status() == status) .toList(); } @Override public void delete(UUID id) { store.remove(id); } }

طبقة الخدمة — حيث تعيش منطق الأعمال

تستقبل طبقة الخدمة TaskRepository عبر منشئها (حقن المنشئ — لا إطار مطلوب). تنسّق قواعد النطاق وتُفوّض الإصرار. لا تُنسّق المخرجات ولا تهتم بتفاصيل HTTP أو CLI.

// service/TaskService.java package com.example.taskmanager.service; import com.example.taskmanager.exception.TaskNotFoundException; import com.example.taskmanager.model.*; import com.example.taskmanager.repository.TaskRepository; import java.time.LocalDate; import java.util.List; import java.util.UUID; public class TaskService { private final TaskRepository repository; public TaskService(TaskRepository repository) { this.repository = repository; } public Task createTask(String title, String description, Priority priority, LocalDate dueDate) { Task task = new Task( UUID.randomUUID(), title, description, priority, Status.OPEN, dueDate ); return repository.save(task); } public Task completeTask(UUID id) { Task existing = repository.findById(id) .orElseThrow(() -> new TaskNotFoundException(id)); Task completed = new Task( existing.id(), existing.title(), existing.description(), existing.priority(), Status.DONE, existing.dueDate() ); return repository.save(completed); } public List<Task> listAllTasks() { return repository.findAll(); } }
لا تدع الراحة تجذب المنطق للأسفل. خطأ شائع هو كتابة منطق الاستعلام — الفلترة، الترتيب، حساب التجميعات — داخل المستودع لأن "قاعدة البيانات تؤدّيها بكفاءة". هذا صحيح أحيانًا، لكن حين لا يكون العمل خاصًا بالبيانات، أبقه في الخدمة. قاعدة مثل "المهام المتأخرة هي تلك التي مرّ موعدها وحالتها ليست DONE" هي قاعدة أعمال؛ يجب أن يُتيح المستودع findAll() وتُطبّق الخدمة الفلتر عبر stream.

المقايضات والبدائل

النموذج ذو الثلاث طبقات هو نقطة بداية عملية، لا عقيدة. خلال التصميم كن على دراية بـ:

  • البنية السداسية (Hexagonal / Ports & Adapters): تجعل قاعدة الاعتماد نحو الداخل صريحة بتسمية الحدود — المنفذ (الواجهة) والمحوّل (التنفيذ). تتوسّع بشكل أفضل لقنوات I/O متعددة (CLI + REST + مدفوع بالأحداث).
  • التقسيم الرأسي: بدلًا من الطبقات الأفقية، تجميع حسب الميزة (task/، user/). كل ميزة تمتلك خدمتها ومستودعها ونموذجها. يُقلّل الاقتران بين الميزات لكن قد يُكرّر كود البنية التحتية.
  • نموذج النطاق الهزيل مقابل الغني: سجلّ Task لدينا هو في معظمه بيانات؛ الخدمة تحتضن المنطق. نموذج أغنى سيُضمّن طرق انتقال الحالة (task.complete()) مباشرة في كائن النطاق. كلاهما صحيح — نهج السجلّ أبسط؛ النموذج الغني يتوسّع أفضل مع نمو القواعد.

الخلاصة

تفصل البنية المطبّقة بين ما يراه المستخدم (واجهة المستخدم)، وما يعرفه التطبيق (الخدمة)، وأين تعيش البيانات (المستودع). حزم Java تجعل هذا الهيكل مرئيًا وقابلًا للفرض بالأدوات. قاعدة الاعتماد — التوجّه دائمًا للداخل نحو النطاق — هي القيد الأهم الذي يجب الحفاظ عليه. مع هذا الهيكل جاهزًا، سيُفصّل الدرس التالي نموذج النطاق بعمق أكبر.