> ## Content Index
> Fetch the complete content index at: https://arabroot.io/llms.txt
> Use this file to discover other available public pages before exploring further.

# Clean Architecture: كيف تعزل منطق العمل عن قاعدة البيانات
- URL: https://arabroot.io/articles/clean-architecture-و-hexagonal-اتجاه-الاعتماديات/
- Published: 2026-10-07T12:45:00.000Z
- Updated: 2026-10-09T13:02:55.000Z
- Description: لماذا تحتاج قاعدة بيانات لكي تختبر قاعدة عمل من سطر واحد، وكيف يعزل اتجاه الاعتماديات منطق العمل عن التفاصيل.
- Author: فريق عرب رووت
- Tags: الأدلة التقنية, هندسة البرمجيات وتصميم الأنظمة, DevOps وCI/CD, قواعد البيانات, الاستضافة الذاتية

افتح أي مشروع ويب عمره سنتان أو ثلاث، وابحث عن قاعدة عمل بسيطة مثل «لا يجوز حجز قاعة اجتماعات لأكثر من أربع ساعات»، وغالباً سوف تجدها داخل كلاس اسمه `BookingService` بين سطرين من SQL، وقبلها سطر يفتح اتصالاً بقاعدة البيانات، وبعدها سطر يرسل بريداً إلكترونياً. الكود يعمل والعميل سعيد، لكن عندما يطلب منك أحد أن تكتب اختباراً لهذه القاعدة وحدها، تكتشف أنك تحتاج قاعدة بيانات تعمل، وعندما يقرر الفريق تغيير ال ORM أو إضافة واجهة سطر أوامر CLI تشغل نفس العملية، تكتشف أن قاعدة العمل مربوطة بكل شيء حولها.

هذه المشكلة هي التي حاولت حلها أفكار معروفة ظهرت في سنوات مختلفة: المعمارية السداسية Hexagonal Architecture ومعمارية البصلة Onion Architecture وClean Architecture، وكلها تقول نفس الشيء بصور مختلفة: ضع منطق العمل في المركز، واجعل قاعدة البيانات والويب والأطر Frameworks تفاصيل في الطرف، واجعل اتجاه الاعتماديات Dependencies في الكود يشير إلى المركز دائماً. وإذا كنت تريد الصورة العامة للفرق بين تصميم البرمجيات Software Design ومعماريتها Software Architecture قبل الدخول في التفاصيل، فقد شرحناها في [مدخل التصميم والمعمارية](https://arabroot.io/articles/%D8%A7%D9%84%D9%81%D8%B1%D9%82-%D8%A8%D9%8A%D9%86-%D8%AA%D8%B5%D9%85%D9%8A%D9%85-%D8%A7%D9%84%D8%A8%D8%B1%D9%85%D8%AC%D9%8A%D8%A7%D8%AA-%D9%88%D9%85%D8%B9%D9%85%D8%A7%D8%B1%D9%8A%D8%AA%D9%87%D8%A7-%D9%88%D8%AA%D8%B5%D9%85%D9%8A%D9%85-%D8%A7%D9%84%D8%A7%D9%86%D8%B8%D9%85%D8%A9/#software-architecture).

وسوف نناقش في هذا المقال ما يلي:

- المعمارية الطبقية التقليدية Layered داخل الكود، وأين يتعب الفريق معها بعد سنة أو سنتين.
- المنافذ والمحولات Ports and Adapters كما وصفها Alistair Cockburn عام 2005، ومعمارية البصلة كما سماها Jeffrey Palermo عام 2008، وClean Architecture كما نشرها Robert C. Martin عام 2012.
- قاعدة الاعتماديات Dependency Rule التي تجمع الأفكار الثلاث، والفرق بين اتجاه الاستدعاء واتجاه الاعتماد في الكود.
- كيف تقسم مشروع .NET إلى مشاريع Projects تمنع الخطأ قبل أن يحدث.
- تجربة عملية كاملة: نظام حجز قاعات بلغة C# على .NET 10، نختبر منطق العمل فيه دون قاعدة بيانات، ثم نشغله مع PostgreSQL، ثم نكسر القاعدة عمداً ونرى من يكتشف الخطأ.
- الأخطاء الشائعة عند التطبيق، وثمن هذه المعمارية، ومتى لا تحتاجها أصلاً.

## الترتيب المعتاد: المعمارية الطبقية Layered

أشهر طريقة لترتيب الكود هي ثلاث طبقات: طبقة العرض Presentation التي تستقبل الطلب، وطبقة منطق العمل Business Logic التي فيها القواعد، وطبقة الوصول إلى البيانات Data Access التي تكلم قاعدة البيانات، وكل طبقة تستدعي الطبقة التي تحتها وتعرف كلاساتها. ويصف Martin Fowler هذا التقسيم في مقاله [PresentationDomainDataLayering](https://martinfowler.com/bliki/PresentationDomainDataLayering.html?ref=arabroot.io)، والطبقة هنا تقسيم منطقي داخل البرنامج وليست جهازاً مستقلاً، وقد شرحنا الفرق بين الطبقة Layer داخل الكود والمستوى Tier في الشبكة في مقال [المعمارية متعددة الطبقات Tiers وLayers](https://arabroot.io/articles/%D8%A7%D9%84%D9%85%D8%B9%D9%85%D8%A7%D8%B1%D9%8A%D8%A9-%D9%85%D8%AA%D8%B9%D8%AF%D8%AF%D8%A9-%D8%A7%D9%84%D8%B7%D8%A8%D9%82%D8%A7%D8%AA-tiers-%D9%88-layers/#layer-vs-tier)، فلن نكرره هنا، وما يهمنا في هذا المقال هو اتجاه الاعتماد بين الطبقات داخل الكود.

والصورة التالية تبين هذا الترتيب، ولاحظ أن كل الأسهم تنتهي عند قاعدة البيانات:

![ثلاث طبقات فوق بعضها: Presentation layer ثم Business logic layer ثم Data access layer، وبينها أسهم إلى الأسفل مكتوب عليها depends on، وفي الأسفل قاعدة بيانات، وسهم أحمر منقط من طبقة العرض إلى قاعدة البيانات مكتوب عليه transitive dependency، وعلى اليسار ثلاث بطاقات عربية تشرح الاعتماد](https://arabroot.io/content/images/2026/10/clean-architecture-01-layered.webp)

في الترتيب الطبقي التقليدي يعتمد منطق العمل على طبقة البيانات، فينتهي كل شيء عند قاعدة البيانات

والكود الذي يخرج من هذا الترتيب يشبه الكلاس التالي، وهو كلاس حقيقي قمنا ببنائه على .NET 10، فيه قاعدة العمل وجمل SQL في مكان واحد:

```csharp
using Npgsql;

namespace Before;

// The usual shape: the business rule and the SQL in one class.
public class BookingService(NpgsqlDataSource db)
{
    public async Task<string> BookAsync(string room, string bookedBy, DateTime start, DateTime end)
    {
        if (end - start > TimeSpan.FromHours(4)) return "a booking cannot be longer than 4 hours";

        await using var check = db.CreateCommand(
            "SELECT count(*) FROM bookings WHERE room = $1 AND starts_at < $3 AND ends_at > $2");
        check.Parameters.AddWithValue(room);
        check.Parameters.AddWithValue(start);
        check.Parameters.AddWithValue(end);
        if ((long)(await check.ExecuteScalarAsync())! > 0) return "room is already booked";

        await using var insert = db.CreateCommand(
            "INSERT INTO bookings (id, room, booked_by, starts_at, ends_at) VALUES ($1, $2, $3, $4, $5)");
        insert.Parameters.AddWithValue(Guid.NewGuid());
        insert.Parameters.AddWithValue(room);
        insert.Parameters.AddWithValue(bookedBy);
        insert.Parameters.AddWithValue(start);
        insert.Parameters.AddWithValue(end);
        await insert.ExecuteNonQueryAsync();
        return "booked";
    }
}
```

في الكود أعلاه لاحظ التالي:

- قاعدة الأربع ساعات وقاعدة عدم التداخل هما منطق العمل الحقيقي، لكنهما محاطتان بكود `Npgsql` وأسماء أعمدة الجدول.
- لكي تختبر قاعدة الأربع ساعات وحدها تحتاج `NpgsqlDataSource`، أي قاعدة بيانات تعمل، أو حيلة تلتف بها على ذلك.
- إذا أردت أن تشغل نفس عملية الحجز من مهمة مجدولة Scheduled Job أو من واجهة سطر أوامر، فسوف تحمل معك PostgreSQL وNpgsql أينما ذهبت.

ويصف Jeffrey Palermo في [الجزء الأول من مقاله عن Onion Architecture](https://jeffreypalermo.com/2008/07/the-onion-architecture-part-1/?ref=arabroot.io) هذا الترتيب بأنه يصنع ارتباطاً Coupling غير ضروري، وأسوأ صوره ارتباط الواجهة ومنطق العمل بالوصول إلى البيانات، ويذكر أن الاعتماد غير المباشر Transitive Dependency يبقى اعتماداً، فالواجهة لا تعمل دون منطق العمل، ومنطق العمل لا يعمل دون طبقة البيانات. ويضيف سبباً عملياً: طرق الوصول إلى البيانات تتغير كل بضع سنوات، والنظام الذي لا يمكن تحديث أجزائه يبقى على حاله حتى يعاد كتابته من الصفر.

وقد يتساءل البعض: أليست الطبقات كافية إذا التزم الفريق بها؟ والإجابة أن Alistair Cockburn لاحظ في ورقته الأصلية أن الناس لا يأخذون الخطوط في رسم الطبقات بجدية، فيتسرب منطق العمل عبرها، ثم يضيف الفريق طبقة جديدة بوعد أن لا يدخلها منطق عمل هذه المرة، وبعد سنوات يجد الطبقة الجديدة ممتلئة بمنطق العمل، والسبب أنه لا توجد آلية تكتشف مخالفة هذا الوعد. لذلك الحل الذي سوف نراه في هذا المقال لا يعتمد على حسن النية، وإنما على اتجاه الاعتماد في الكود نفسه، وعلى اختبار يفشل عند المخالفة.

## المنافذ والمحولات Ports and Adapters أو المعمارية السداسية Hexagonal

في عام 2005 نشر Alistair Cockburn ورقة بعنوان [The Hexagonal (Ports & Adapters) Architecture](https://alistair.cockburn.us/hexagonal-architecture/?ref=arabroot.io)، وهدفها كما كتبه في بدايتها أن يعمل التطبيق دون واجهة مستخدم ودون قاعدة بيانات، بحيث يمكن تشغيله من مستخدم أو برنامج آخر أو اختبار آلي أو مهمة دفعية Batch، وتطويره واختباره بمعزل عن الأجهزة وقواعد البيانات التي سيعمل معها في النهاية.

والفكرة الأساسية فيها أن المهم ليس يمين التطبيق ويساره، ولا أعلاه وأسفله، وإنما داخله وخارجه، وعلى هذا الأساس عرف مصطلحين:

- **المنفذ Port**: حوار له غرض بين التطبيق والعالم الخارجي، مثل «احجز قاعة» أو «احفظ الحجز واسترجعه»، ويكتب في الكود على شكل واجهة برمجية API أو Interface بلغة التطبيق نفسه، والكلمة مأخوذة من منافذ نظام التشغيل والأجهزة التي يدخل فيها أي جهاز يلتزم ببروتوكولها.
- **المحول Adapter**: كود يترجم بين المنفذ وتقنية محددة، فواجهة الويب محول، واختبار آلي يستدعي التطبيق محول، ومستودع PostgreSQL محول، ومستودع في الذاكرة لا يحتاج قاعدة بيانات محول آخر لنفس المنفذ.

والصورة التالية تبين ذلك على المثال الذي سوف نبنيه في التجربة العملية:

![شكل سداسي في الوسط مكتوب عليه Application، فيه BookRoom use case وBooking domain rules، ومكتوب تحتهما no SQL, no HTTP, no framework. على الحافة اليسرى منفذ BookRoom port تصل إليه أسهم خضراء من HTTP endpoint وxUnit tests وCLI or batch job، وعلى الحافة اليمنى منفذان IBookingRepository وIClock تخرج منهما أسهم زرقاء إلى PostgreSQL adapter وin-memory fake وsystem or fixed clock](https://arabroot.io/content/images/2026/10/clean-architecture-02-hexagonal.webp)

التطبيق في الداخل يتكلم عبر منافذ، وكل تقنية في الخارج تتصل به عبر محول

ويقسم Cockburn المحولات إلى نوعين بحسب من يبدأ الحوار:

- **المحولات الأساسية Primary أو المحركة Driving**: تبدأ الحوار وتطلب من التطبيق أن يعمل، مثل واجهة الويب والاختبارات الآلية وسطر الأوامر، وهي على يسار الرسم.
- **المحولات الثانوية Secondary أو المدارة Driven**: يطلب منها التطبيق شيئاً، مثل قاعدة البيانات وخدمة البريد والساعة، وهي على يمين الرسم.

ولاحظ أن الشكل السداسي ليس له معنى في عدد الأضلاع، فقد ذكر Cockburn أنه اختاره ليترك مساحة في الرسم لإضافة المنافذ والمحولات بعيداً عن صورة الطبقات ذات البعد الواحد، وذكر أن أغلب التطبيقات فيها منفذان إلى أربعة. وفي الورقة نفسها مثال صغير بلغة Java فيه واجهة `RateRepository` ومستودع وهمي في الذاكرة يحل محل قاعدة البيانات، وهي نفس الفكرة التي سوف نطبقها في التجربة.

## معمارية البصلة Onion Architecture

في يوليو 2008 كتب Jeffrey Palermo سلسلة مقالات سمى فيها معمارية البصلة Onion Architecture، وقال إنها ليست جديدة تماماً لكنه يقترحها باسم واضح لكي يتكلم الناس عنها بلغة مشتركة، وقاعدتها الأساسية في [مقاله الأول](https://jeffreypalermo.com/2008/07/the-onion-architecture-part-1/?ref=arabroot.io) أن الكود يعتمد على الطبقات الأقرب إلى المركز، ولا يعتمد أبداً على الطبقات الأبعد منه، أي أن كل الارتباط يتجه نحو المركز.

![أربع دوائر متداخلة: في المركز Domain Model، ثم Domain Services، ثم Application Services ومكتوب فيها repository interfaces، ثم الدائرة الخارجية UI وInfrastructure وTests، وسهمان برتقاليان يشيران من الخارج إلى المركز، وعلى اليمين أربع بطاقات عربية مرقمة](https://arabroot.io/content/images/2026/10/clean-architecture-03-onion.webp)

معمارية البصلة تضع نموذج المجال في المركز، وتجعل قاعدة البيانات خدمة خارجية

والصورة أعلاه تلخص ما يقوله Palermo، وفيما يلي التفصيل:

- في المركز نموذج المجال Domain Model، وهو الحالة والسلوك الذي يمثل حقيقة العمل، ولا يعتمد إلا على نفسه، وقد شرحنا الكيانات Entities وكائنات القيمة Value Objects في مقال [التصميم الموجه بالمجال DDD](https://arabroot.io/articles/%D8%A7%D9%84%D8%AA%D8%B5%D9%85%D9%8A%D9%85-%D8%A7%D9%84%D9%85%D9%88%D8%AC%D9%87-%D8%A8%D8%A7%D9%84%D9%85%D8%AC%D8%A7%D9%84-ddd-%D8%A8%D8%A8%D8%B3%D8%A7%D8%B7%D8%A9/#entities-value-objects).
- حوله طبقات فيها سلوك أكثر، وأول ما تجده فيها واجهات المستودعات Repository Interfaces التي تحفظ الكائنات وتسترجعها، والواجهة فقط هي التي في المركز، أما تنفيذها فيعمل مع قاعدة بيانات محددة فيوضع في الطرف.
- في الطرف الخارجي الواجهة UI والبنية التحتية Infrastructure والاختبارات Tests، وهي الأجزاء التي تتغير كثيراً.
- قاعدة البيانات ليست المركز، فلا يوجد في هذه المعمارية «تطبيق قاعدة بيانات»، وإنما تطبيق يستخدم قاعدة البيانات خدمة تخزين عبر كود في الطرف ينفذ واجهة يفهمها المركز.

ويذكر Palermo أن هذه المعمارية تعتمد كثيراً على مبدأ عكس الاعتماديات Dependency Inversion الذي شرحناه في [مقال مبادئ التصميم](https://arabroot.io/articles/%D9%85%D8%A8%D8%A7%D8%AF%D8%A6-%D8%A7%D9%84%D8%AA%D8%B5%D9%85%D9%8A%D9%85-solid-%D9%88-dry-%D9%88-kiss/#dip)، وأن التطبيق يحتاج آلية تحقن التنفيذ الموجود في الطرف وقت التشغيل، وهي حقن الاعتماديات Dependency Injection التي شرحناها في [مقال أنماط التصميم](https://arabroot.io/articles/%D8%A7%D9%86%D9%85%D8%A7%D8%B7-%D8%A7%D9%84%D8%AA%D8%B5%D9%85%D9%8A%D9%85-design-patterns-%D8%A7%D9%84%D8%AA%D9%8A-%D8%AA%D9%82%D8%A7%D8%A8%D9%84%D9%87%D8%A7-%D9%81%D8%B9%D9%84%D8%A7/#dependency-injection). ويقول بوضوح في بداية المقال إنها لا تناسب المواقع الصغيرة، وإنما التطبيقات التي تعيش طويلاً أو فيها سلوك معقد، وسوف نعود لهذه النقطة في قسم الثمن.

## Clean Architecture ودوائرها الأربع

في أغسطس 2012 نشر Robert C. Martin مقالاً بعنوان [The Clean Architecture](https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html?ref=arabroot.io)، وذكر في أوله أن الأفكار السابقة مثل Hexagonal وOnion وغيرها تختلف في التفاصيل لكنها متشابهة جداً، فكلها تفصل الاهتمامات Separation of Concerns بتقسيم البرنامج إلى طبقات، فيها طبقة لقواعد العمل وطبقة للواجهات على الأقل، وأن مقاله محاولة لجمعها في فكرة واحدة قابلة للتطبيق. والأنظمة التي تنتج عنها بحسب المقال مستقلة عن الأطر Frameworks، وقابلة للاختبار دون الواجهة وقاعدة البيانات وسيرفر الويب، ومستقلة عن الواجهة وعن قاعدة البيانات وعن أي جهة خارجية.

![أربع دوائر متداخلة: في المركز Entities ومكتوب تحتها Booking، ثم Use Cases ومكتوب فيها BookRoom، ثم Interface Adapters ومكتوب فيها controllers, presenters، ثم Frameworks and Drivers ومكتوب فيها web, database, UI، وسهمان برتقاليان إلى المركز، وعلى اليمين أربع بطاقات عربية عن قاعدة الاعتماديات](https://arabroot.io/content/images/2026/10/clean-architecture-04-clean.webp)

الدوائر الأربع بأسماء مثالنا، والأسهم هي اتجاه الاعتماد في الكود

والدوائر الأربع في المقال من الداخل إلى الخارج كما يلي:

- **الكيانات Entities**: قواعد العمل العامة التي لا تتغير بتغير الشاشات أو الأمان أو أي تفصيل تشغيلي، وفي مثالنا كلاس `Booking` وقاعدة الأربع ساعات وقاعدة التداخل.
- **حالات الاستخدام Use Cases**: قواعد العمل الخاصة بهذا التطبيق، وهي التي تدير تدفق البيانات من الكيانات وإليها، وفي مثالنا `BookRoom`.
- **محولات الواجهات Interface Adapters**: كود يحول البيانات من الشكل المناسب لحالات الاستخدام إلى الشكل المناسب للويب أو قاعدة البيانات، وفيها Controllers وPresenters، ويقول المقال إن كل SQL يجب أن يبقى في هذه الدائرة.
- **الأطر والمشغلات Frameworks and Drivers**: قاعدة البيانات وإطار الويب والأدوات، وأغلب ما تكتبه فيها كود ربط قليل.

ويذكر المقال أن الدوائر الأربع رسم توضيحي، وقد تحتاج أكثر منها، أما قاعدة الاعتماديات فتنطبق دائماً. ويذكر أيضاً أن البيانات التي تعبر الحدود تكون هياكل بسيطة، مثل كائنات نقل البيانات DTO أو معاملات الدوال، ولا تمرر صفوف قاعدة البيانات أو الأشكال التي يولدها إطار في الخارج إلى الدوائر الداخلية، والسبب أن ذلك يجبر الداخل على معرفة شيء من الخارج.

## قاعدة واحدة تجمعها: اتجاه الاعتماديات

ينص مقال Martin على أن الاعتماديات في الكود المصدري Source Code Dependencies لا تشير إلا إلى الداخل، فلا يذكر الكود في دائرة داخلية أي اسم معرف في دائرة خارجية، سواء كان دالة أو كلاس أو متغيراً، وهذه هي قاعدة الاعتماديات Dependency Rule، وهي نفس قاعدة Palermo «كل الارتباط يتجه نحو المركز»، ونفس فكرة Cockburn «لا يتسرب كود الداخل إلى الخارج».

وهنا يظهر سؤال منطقي: حالة الاستخدام `BookRoom` تحتاج أن تحفظ الحجز في PostgreSQL، أي أن الاستدعاء يذهب من الداخل إلى الخارج، فكيف لا تعتمد عليه؟ والإجابة أن اتجاه الاستدعاء وقت التشغيل Flow of Control شيء، واتجاه الاعتماد في الكود شيء آخر، فنضع في الداخل واجهة `IBookingRepository` مكتوبة بلغة حالة الاستخدام، ونجعل الكلاس الذي يكلم PostgreSQL في الخارج ينفذها، فيبقى الاستدعاء ذاهباً إلى الخارج وقت التشغيل، لكن سهم الاعتماد في الكود يشير إلى الداخل، والصورة التالية تبين الحالتين:

![على اليمين BEFORE: صندوق BookRoom use case وسهم أحمر مكتوب عليه calls + references إلى PostgresRepository ثم قاعدة بيانات. على اليسار AFTER: داخل إطار منقط باسم RoomBooking.Application صندوق BookRoom وسهم بنفسجي إلى IBookingRepository interface، وتحته PostgresBookingRepository في RoomBooking.Infrastructure وسهم أخضر إلى الأعلى مكتوب عليه implements، وخط رمادي منقط مكتوب عليه runtime call من BookRoom إلى PostgresBookingRepository](https://arabroot.io/content/images/2026/10/clean-architecture-05-inversion.webp)

الاستدعاء ما زال يصل إلى PostgreSQL، لكن الكود الداخلي لا يعرفها باسمها

وهذا تطبيق مباشر لمبدأ عكس الاعتماديات DIP الذي شرحناه في [مقال مبادئ التصميم](https://arabroot.io/articles/%D9%85%D8%A8%D8%A7%D8%AF%D8%A6-%D8%A7%D9%84%D8%AA%D8%B5%D9%85%D9%8A%D9%85-solid-%D9%88-dry-%D9%88-kiss/#dip)، والكلاس الذي ينفذ الواجهة هو نمط المحول Adapter الذي شرحناه في [مقال أنماط التصميم](https://arabroot.io/articles/%D8%A7%D9%86%D9%85%D8%A7%D8%B7-%D8%A7%D9%84%D8%AA%D8%B5%D9%85%D9%8A%D9%85-design-patterns-%D8%A7%D9%84%D8%AA%D9%8A-%D8%AA%D9%82%D8%A7%D8%A8%D9%84%D9%87%D8%A7-%D9%81%D8%B9%D9%84%D8%A7/#adapter)، وكثير من الفرق تسمي هذه الواجهة مستودعاً Repository كما في [نمط المستودع](https://arabroot.io/articles/%D8%A7%D9%86%D9%85%D8%A7%D8%B7-%D8%A7%D9%84%D8%AA%D8%B5%D9%85%D9%8A%D9%85-design-patterns-%D8%A7%D9%84%D8%AA%D9%8A-%D8%AA%D9%82%D8%A7%D8%A8%D9%84%D9%87%D8%A7-%D9%81%D8%B9%D9%84%D8%A7/#repository). ولاحظ أن الواجهة يملكها الطرف الداخلي ويكتبها بلغته، فاسمها `ForRoomAsync` وليس `ExecuteQuery`، وهذا الفرق الصغير هو الذي يمنع تفاصيل قاعدة البيانات من التسرب إلى الداخل.

والجدول التالي يضع الأفكار الأربع جنباً إلى جنب:

| المعمارية                       | من وصفها ومتى           | ما في المركز                    | اتجاه الاعتماد                                  | ما الذي تضيفه                                                              |
| ------------------------------- | ----------------------- | ------------------------------- | ----------------------------------------------- | -------------------------------------------------------------------------- |
| الطبقية Layered                 | ترتيب شائع منذ عقود     | لا يوجد مركز، الطبقات فوق بعضها | من الأعلى إلى الأسفل، وينتهي عند قاعدة البيانات | ترتيب واضح وسهل، لكنه لا يحمي منطق العمل من التفاصيل                       |
| Hexagonal أو Ports and Adapters | Alistair Cockburn، 2005 | التطبيق كله                     | من المحولات إلى المنافذ                         | فكرة الداخل والخارج، والمحولات المحركة والمدارة، وأكثر من محول لنفس المنفذ |
| Onion                           | Jeffrey Palermo، 2008   | نموذج المجال Domain Model       | نحو المركز                                      | حلقات داخل التطبيق، وواجهات المستودعات قرب المركز                          |
| Clean Architecture              | Robert C. Martin، 2012  | الكيانات Entities               | نحو الداخل، بقاعدة اسمها Dependency Rule        | فصل حالات الاستخدام عن الكيانات، وقواعد للبيانات التي تعبر الحدود          |

وتقول Microsoft في كتابها عن [معماريات تطبيقات الويب الشائعة](https://learn.microsoft.com/en-us/dotnet/architecture/modern-web-apps-azure/common-web-application-architectures?ref=arabroot.io) إن التطبيقات التي تتبع مبدأ عكس الاعتماديات ومبادئ التصميم الموجه بالمجال تصل غالباً إلى نفس المعمارية، وإن اسمها تغير عبر السنين من Hexagonal إلى Ports and Adapters ثم Onion ثم Clean Architecture، وهذا ما يجعلنا نتعامل معها في هذا المقال كفكرة واحدة بأربعة رسوم، ونستخدم أسماء Clean Architecture في التجربة لأنها الأشهر في مجتمع .NET اليوم.

## كيف تقسم المشروع إلى مشاريع ومجلدات؟

يمكنك أن تطبق الفكرة في مشروع واحد بمجلدات اسمها Domain وApplication وInfrastructure، لكن المترجم Compiler في هذه الحالة لا يمنع أي ملف من استخدام أي ملف آخر، فيبقى الالتزام بالقاعدة معتمداً على مراجعة الكود Code Review فقط. أما إذا جعلت كل دائرة مشروعاً Project مستقلاً، فالمرجع بين المشاريع ProjectReference هو سهم الاعتماد نفسه، ولا يستطيع كود في Domain أن يستخدم كلاساً في Infrastructure لأن المشروع لا يراه أصلاً. والصورة التالية تبين المشاريع الخمسة في التجربة ومن يعرف من:

![خمسة صناديق: RoomBooking.Api في الأعلى مع ملاحظة composition root، وتخرج منه أسهم إلى RoomBooking.Infrastructure وRoomBooking.Application، ومن Infrastructure المرتبط بحزمة Npgsql سهم إلى Application، ومن RoomBooking.Tests سهم إلى Application مع ملاحظة no Npgsql.dll in its bin folder، ومن Application سهم إلى RoomBooking.Domain. وسهم أحمر منقط من Application إلى Infrastructure عليه علامة خطأ ومكتوب تحته circular reference, build fails](https://arabroot.io/content/images/2026/10/clean-architecture-06-project-map.webp)

مراجع المشاريع هي أسهم الاعتماد، وكلها تنتهي عند Domain

وشكل المجلدات في التجربة كما يلي:

```text
room-booking/
  global.json
  RoomBooking.slnx
  src/
    RoomBooking.Domain/            Booking.cs: the entity and its rules, no packages
    RoomBooking.Application/       Ports.cs, BookRoom.cs: use cases and the ports they need
    RoomBooking.Infrastructure/    PostgresBookingRepository.cs: the only project with Npgsql
    RoomBooking.Api/               Program.cs: HTTP endpoints and the wiring
  tests/
    RoomBooking.Tests/             use case tests with fakes, dependency rule tests
```

في الشكل أعلاه لاحظ التالي:

- المشروع `RoomBooking.Domain` لا يعتمد على أي مشروع أو حزمة، ولا يعرف أن هناك ويب أو قاعدة بيانات.
- المشروع `RoomBooking.Application` يعتمد على Domain فقط، وفيه المنافذ على شكل Interfaces.
- المشروع `RoomBooking.Infrastructure` هو الوحيد الذي يعرف Npgsql وجمل SQL، ويعتمد على Application لكي ينفذ واجهاتها.
- المشروع `RoomBooking.Api` هو جذر التركيب Composition Root، أي المكان الوحيد الذي يعرف كل المشاريع ويختار أي محول يربط بأي منفذ.
- مشروع الاختبارات يعتمد على Application فقط، فلا تصل إليه حزمة Npgsql أصلاً.

ويمكنك أن تبدأ من قوالب جاهزة معروفة في مجتمع .NET مثل [ardalis/CleanArchitecture](https://github.com/ardalis/CleanArchitecture?ref=arabroot.io) و[jasontaylordev/CleanArchitecture](https://github.com/jasontaylordev/CleanArchitecture?ref=arabroot.io)، لكن ننصحك أن تبني مثالاً صغيراً بيدك أولاً كما سوف نفعل الآن، والسبب أن القوالب تأتي بمكتبات وطبقات إضافية قد لا تحتاجها، ومن لا يفهم سبب كل مشروع فيها ينتهي بنسخ الشكل دون الفكرة.

## تجربة عملية: نظام حجز قاعات بمعمارية Clean

سوف نبني نظاماً صغيراً لحجز قاعات الاجتماعات فيه قاعدتا عمل: لا يتجاوز الحجز أربع ساعات، ولا يتداخل حجزان في نفس القاعة، مع منع الحجز في الماضي. ونحتاج .NET SDK 10، وDocker لتشغيل PostgreSQL في الجزء الثاني فقط. ستجد كل ملفات المشروع في الأسفل كاملة، وثبتنا إصدار ال SDK بملف [global.json](https://learn.microsoft.com/en-us/dotnet/core/tools/global-json?ref=arabroot.io) لكي يبني المشروع بنفس الإصدار على أي جهاز:

```json
{
  "sdk": { "version": "10.0.400", "rollForward": "latestPatch" }
}
```

وأنشأنا المشاريع وربطناها بالأوامر التالية، ولاحظ أن ترتيب أوامر `reference` هو نفسه اتجاه الأسهم في الصورة السابقة:

```bash
dotnet new sln -n RoomBooking -o .
dotnet new classlib -n RoomBooking.Domain -o src/RoomBooking.Domain -f net10.0
dotnet new classlib -n RoomBooking.Application -o src/RoomBooking.Application -f net10.0
dotnet new classlib -n RoomBooking.Infrastructure -o src/RoomBooking.Infrastructure -f net10.0
dotnet new web -n RoomBooking.Api -o src/RoomBooking.Api -f net10.0
dotnet new xunit -n RoomBooking.Tests -o tests/RoomBooking.Tests -f net10.0
dotnet sln add src/RoomBooking.Domain src/RoomBooking.Application src/RoomBooking.Infrastructure src/RoomBooking.Api tests/RoomBooking.Tests
dotnet add src/RoomBooking.Application reference src/RoomBooking.Domain
dotnet add src/RoomBooking.Infrastructure reference src/RoomBooking.Application
dotnet add src/RoomBooking.Api reference src/RoomBooking.Application src/RoomBooking.Infrastructure
dotnet add tests/RoomBooking.Tests reference src/RoomBooking.Application
dotnet add src/RoomBooking.Infrastructure package Npgsql --version 10.0.3
```

### المركز: الكيان وقواعده

الملف التالي هو كل مشروع Domain، ولا يوجد فيه أي `using` لمكتبة خارجية:

```csharp
namespace RoomBooking.Domain;

// The business rules of a meeting-room booking. No database, no HTTP, no framework.
public sealed class Booking
{
    public static readonly TimeSpan MaxLength = TimeSpan.FromHours(4);

    public Guid Id { get; }
    public string Room { get; }
    public string BookedBy { get; }
    public DateTime Start { get; }
    public DateTime End { get; }

    private Booking(Guid id, string room, string bookedBy, DateTime start, DateTime end)
    {
        Id = id; Room = room; BookedBy = bookedBy; Start = start; End = end;
    }

    public static Booking Create(string room, string bookedBy, DateTime start, DateTime end, DateTime now)
    {
        if (string.IsNullOrWhiteSpace(room)) throw new BookingRuleException("room is required");
        if (end <= start) throw new BookingRuleException("end must be after start");
        if (start < now) throw new BookingRuleException("cannot book in the past");
        if (end - start > MaxLength) throw new BookingRuleException("a booking cannot be longer than 4 hours");
        return new Booking(Guid.NewGuid(), room.Trim(), bookedBy, start, end);
    }

    // Used by adapters that load a booking that was already validated when it was created.
    public static Booking Restore(Guid id, string room, string bookedBy, DateTime start, DateTime end)
        => new(id, room, bookedBy, start, end);

    public bool Overlaps(Booking other)
        => Room == other.Room && Start < other.End && other.Start < End;
}

public sealed class BookingRuleException(string message) : Exception(message);
```

في الكود أعلاه لاحظ التالي:

- الطريقة الوحيدة لإنشاء حجز جديد هي `Create`، وهي التي تتحقق من القواعد، فلا يوجد حجز جديد مخالف في أي مكان من النظام.
- الوقت الحالي يدخل كمعامل `now`، ولا يقرأ الكيان ساعة الجهاز بنفسه، والسبب أن الوقت تفصيل خارجي، وهذا ما يسمح لنا باختبار قاعدة «لا حجز في الماضي» بوقت ثابت.
- الدالة `Restore` للمحولات التي تقرأ حجزاً محفوظاً سبق التحقق منه، ولا تمر على القواعد مرة أخرى.

### الحلقة التالية: حالة الاستخدام ومنافذها

في مشروع Application ملفان: الأول فيه المنافذ المدارة Driven Ports التي تحتاجها حالة الاستخدام من الخارج:

```csharp
using RoomBooking.Domain;

namespace RoomBooking.Application;

// Driven ports: what the use cases need from the outside, described in the use cases' own words.
public interface IBookingRepository
{
    Task<IReadOnlyList<Booking>> ForRoomAsync(string room, DateTime from, DateTime to);
    Task AddAsync(Booking booking);
}

public interface IClock
{
    DateTime UtcNow { get; }
}
```

والثاني فيه حالة الاستخدام نفسها، وهي المنفذ المحرك Driving Port الذي يستدعيه أي محول من الخارج:

```csharp
using RoomBooking.Domain;

namespace RoomBooking.Application;

public sealed record BookRoomCommand(string Room, string BookedBy, DateTime Start, DateTime End);

public enum BookRoomStatus { Booked, Invalid, Conflict }

public sealed record BookRoomResult(BookRoomStatus Status, Guid? Id = null, string? Error = null);

// Driving port: the use case that any adapter (HTTP, CLI, test) calls.
public sealed class BookRoom(IBookingRepository bookings, IClock clock)
{
    public async Task<BookRoomResult> HandleAsync(BookRoomCommand cmd)
    {
        Booking booking;
        try
        {
            booking = Booking.Create(cmd.Room, cmd.BookedBy, cmd.Start, cmd.End, clock.UtcNow);
        }
        catch (BookingRuleException ex)
        {
            return new(BookRoomStatus.Invalid, Error: ex.Message);
        }

        var existing = await bookings.ForRoomAsync(booking.Room, booking.Start, booking.End);
        if (existing.Any(b => b.Overlaps(booking)))
            return new(BookRoomStatus.Conflict, Error: $"room {booking.Room} is already booked at that time");

        await bookings.AddAsync(booking);
        return new(BookRoomStatus.Booked, booking.Id);
    }
}

public sealed class ListRoomBookings(IBookingRepository bookings)
{
    public Task<IReadOnlyList<Booking>> HandleAsync(string room, DateOnly day)
        => bookings.ForRoomAsync(room, day.ToDateTime(TimeOnly.MinValue), day.AddDays(1).ToDateTime(TimeOnly.MinValue));
}
```

في الكود أعلاه لاحظ التالي:

- حالة الاستخدام تعرف `IBookingRepository` و`IClock` فقط، ولا تعرف هل الحفظ في PostgreSQL أو في قائمة في الذاكرة.
- النتيجة `BookRoomResult` بيانات بسيطة فيها حالة ونص خطأ، ولا تحمل أي شيء من HTTP مثل رموز الحالة Status Codes، فتحويلها إلى 201 أو 409 عمل المحول في الخارج.
- الكلاسان يستقبلان ما يحتاجانه في الباني Constructor، وهذا ما يسمح للمحول الخارجي أن يحقن التنفيذ المناسب.

### الطرف: محول PostgreSQL

هذا هو المكان الوحيد في المشروع الذي فيه SQL، ويستخدم [NpgsqlDataSource](https://www.npgsql.org/doc/basic-usage.html?ref=arabroot.io) من مكتبة Npgsql:

```csharp
using Npgsql;
using RoomBooking.Application;
using RoomBooking.Domain;

namespace RoomBooking.Infrastructure;

// Driven adapter: implements the port with PostgreSQL. All the SQL of the project lives here.
public sealed class PostgresBookingRepository(NpgsqlDataSource db) : IBookingRepository
{
    public async Task EnsureSchemaAsync()
    {
        await using var cmd = db.CreateCommand("""
            CREATE TABLE IF NOT EXISTS bookings (
                id uuid PRIMARY KEY,
                room text NOT NULL,
                booked_by text NOT NULL,
                starts_at timestamp NOT NULL,
                ends_at timestamp NOT NULL)
            """);
        await cmd.ExecuteNonQueryAsync();
    }

    public async Task<IReadOnlyList<Booking>> ForRoomAsync(string room, DateTime from, DateTime to)
    {
        await using var cmd = db.CreateCommand("""
            SELECT id, room, booked_by, starts_at, ends_at FROM bookings
            WHERE room = $1 AND starts_at < $3 AND ends_at > $2
            ORDER BY starts_at
            """);
        cmd.Parameters.AddWithValue(room);
        cmd.Parameters.AddWithValue(from);
        cmd.Parameters.AddWithValue(to);
        var list = new List<Booking>();
        await using var r = await cmd.ExecuteReaderAsync();
        while (await r.ReadAsync())
            list.Add(Booking.Restore(r.GetGuid(0), r.GetString(1), r.GetString(2), r.GetDateTime(3), r.GetDateTime(4)));
        return list;
    }

    public async Task AddAsync(Booking b)
    {
        await using var cmd = db.CreateCommand(
            "INSERT INTO bookings (id, room, booked_by, starts_at, ends_at) VALUES ($1, $2, $3, $4, $5)");
        cmd.Parameters.AddWithValue(b.Id);
        cmd.Parameters.AddWithValue(b.Room);
        cmd.Parameters.AddWithValue(b.BookedBy);
        cmd.Parameters.AddWithValue(b.Start);
        cmd.Parameters.AddWithValue(b.End);
        await cmd.ExecuteNonQueryAsync();
    }
}

public sealed class SystemClock : IClock
{
    public DateTime UtcNow => DateTime.UtcNow;
}
```

ولاحظ أن الاستعلام يرجع الحجوزات التي تتقاطع مع الفترة المطلوبة، ثم يحولها إلى كائنات `Booking` قبل أن يعيدها، فلا يخرج من هذا الكلاس أي `NpgsqlDataReader` أو صف من قاعدة البيانات، وهذا ما يطلبه مقال Clean Architecture عن البيانات التي تعبر الحدود.

### المحول المحرك وجذر التركيب Composition Root

الملف `Program.cs` في مشروع Api يربط كل شيء باستخدام حاوية الاعتماديات المدمجة في .NET كما في [توثيق Dependency Injection](https://learn.microsoft.com/en-us/dotnet/core/extensions/dependency-injection?ref=arabroot.io)، ويعرف نقطتي ويب بأسلوب [Minimal APIs](https://learn.microsoft.com/en-us/aspnet/core/fundamentals/minimal-apis?ref=arabroot.io):

```csharp
using Npgsql;
using RoomBooking.Application;
using RoomBooking.Infrastructure;

// Composition root: the only place that knows every project and chooses the adapters.
var builder = WebApplication.CreateBuilder(args);
var connectionString = builder.Configuration.GetConnectionString("Bookings")
    ?? throw new InvalidOperationException("ConnectionStrings:Bookings is missing");

builder.Services.AddSingleton(NpgsqlDataSource.Create(connectionString));
builder.Services.AddSingleton<PostgresBookingRepository>();
builder.Services.AddSingleton<IBookingRepository>(sp => sp.GetRequiredService<PostgresBookingRepository>());
builder.Services.AddSingleton<IClock, SystemClock>();
builder.Services.AddScoped<BookRoom>();
builder.Services.AddScoped<ListRoomBookings>();

var app = builder.Build();
await app.Services.GetRequiredService<PostgresBookingRepository>().EnsureSchemaAsync();

// Driving adapter: turns HTTP into a call on the use case, and the result back into HTTP.
app.MapPost("/bookings", async (BookRoomCommand cmd, BookRoom useCase) =>
{
    var result = await useCase.HandleAsync(cmd);
    return result.Status switch
    {
        BookRoomStatus.Booked => Results.Created($"/bookings/{result.Id}", new { id = result.Id }),
        BookRoomStatus.Conflict => Results.Conflict(new { error = result.Error }),
        _ => Results.BadRequest(new { error = result.Error }),
    };
});

app.MapGet("/rooms/{room}/bookings", async (string room, DateOnly day, ListRoomBookings useCase) =>
    (await useCase.HandleAsync(room, day)).Select(b => new { b.Id, b.Room, b.BookedBy, b.Start, b.End }));

app.Run();
```

في الكود أعلاه لاحظ التالي:

- هذا هو الملف الوحيد الذي يذكر `NpgsqlDataSource` و`PostgresBookingRepository` مع حالات الاستخدام، ولو أردت أن تستبدل PostgreSQL بقاعدة أخرى فالتغيير هنا وفي مشروع Infrastructure فقط.
- نقطة `MapPost` لا تحتوي أي قاعدة عمل، فهي تحول JSON إلى `BookRoomCommand`، وتحول النتيجة إلى رمز حالة HTTP، وهذا هو عمل المحول.
- لو أضفت غداً واجهة سطر أوامر أو مهمة مجدولة تحجز القاعات، فسوف تكتب محولاً جديداً يستدعي نفس `BookRoom` دون أن تلمس سطراً في Domain أو Application.

### اختبار منطق العمل دون قاعدة بيانات

الآن نصل إلى الفائدة الأوضح لهذه المعمارية، وهي أن اختبار قواعد الحجز لا يحتاج قاعدة بيانات أو شبكة أو سيرفر ويب. سوف نكتب محولين للاختبار ينفذان نفس المنفذين: مستودع في الذاكرة، وساعة ثابتة لا تتحرك، وهذان من البدائل Test Doubles التي شرحناها في [مقال أنواع الاختبارات](https://arabroot.io/articles/%D8%A7%D9%86%D9%88%D8%A7%D8%B9-%D8%A7%D9%84%D8%A7%D8%AE%D8%AA%D8%A8%D8%A7%D8%B1%D8%A7%D8%AA-%D9%84%D9%84%D9%85%D8%A8%D8%AA%D8%AF%D8%A6%D9%8A%D9%86-unit-%D9%88-integration-%D9%88-e2e/#test-doubles)، وتحديداً من نوع Fake:

```csharp
using RoomBooking.Application;
using RoomBooking.Domain;

namespace RoomBooking.Tests;

// Test adapters for the same ports: a list in memory, and a clock that does not move.
public sealed class InMemoryBookingRepository : IBookingRepository
{
    public List<Booking> Saved { get; } = new();

    public Task<IReadOnlyList<Booking>> ForRoomAsync(string room, DateTime from, DateTime to)
        => Task.FromResult<IReadOnlyList<Booking>>(
            Saved.Where(b => b.Room == room && b.Start < to && b.End > from).ToList());

    public Task AddAsync(Booking booking)
    {
        Saved.Add(booking);
        return Task.CompletedTask;
    }
}

public sealed class FixedClock(DateTime now) : IClock
{
    public DateTime UtcNow => now;
}
```

والاختبارات نفسها باستخدام [xUnit](https://xunit.net/?ref=arabroot.io):

```csharp
using RoomBooking.Application;
using RoomBooking.Domain;

namespace RoomBooking.Tests;

public class BookRoomTests
{
    static readonly DateTime Now = new(2026, 10, 20, 8, 0, 0);
    readonly InMemoryBookingRepository repo = new();
    BookRoom UseCase() => new(repo, new FixedClock(Now));
    static DateTime At(int hour, int minute = 0) => new(2026, 10, 20, hour, minute, 0);

    [Fact]
    public async Task Books_a_free_room()
    {
        var result = await UseCase().HandleAsync(new("A-101", "sara", At(9), At(10)));

        Assert.Equal(BookRoomStatus.Booked, result.Status);
        Assert.Single(repo.Saved);
    }

    [Fact]
    public async Task Rejects_an_overlapping_booking()
    {
        await UseCase().HandleAsync(new("A-101", "sara", At(9), At(10)));

        var result = await UseCase().HandleAsync(new("A-101", "omar", At(9, 30), At(11)));

        Assert.Equal(BookRoomStatus.Conflict, result.Status);
        Assert.Single(repo.Saved);
    }

    [Fact]
    public async Task Allows_back_to_back_bookings()
    {
        await UseCase().HandleAsync(new("A-101", "sara", At(9), At(10)));

        var result = await UseCase().HandleAsync(new("A-101", "omar", At(10), At(11)));

        Assert.Equal(BookRoomStatus.Booked, result.Status);
    }

    [Fact]
    public async Task Rejects_a_booking_in_the_past()
    {
        var result = await UseCase().HandleAsync(new("A-101", "sara", At(7), At(8)));

        Assert.Equal(BookRoomStatus.Invalid, result.Status);
        Assert.Equal("cannot book in the past", result.Error);
    }

    [Fact]
    public void A_booking_cannot_be_longer_than_four_hours()
    {
        var ex = Assert.Throws<BookingRuleException>(() =>
            Booking.Create("A-101", "sara", At(9), At(14), Now));

        Assert.Equal("a booking cannot be longer than 4 hours", ex.Message);
    }
}
```

والصورة التالية تبين ما يحدث: نفس النواة تعمل في الإنتاج مع PostgreSQL، وفي الاختبار مع قائمة في الذاكرة:

![صندوق Application core في الوسط فيه BookRoom وBooking ومكتوب تحته same code, no change، وهو يمتد على شريطين: شريط PRODUCTION فيه متصفح وMapPost /bookings على اليسار، وPostgresBookingRepository وSystemClock وقاعدة PostgreSQL 18 على اليمين، وشريط TEST فيه قائمة اختبارات وBookRoomTests على اليسار، وInMemoryBookingRepository وFixedClock على اليمين](https://arabroot.io/content/images/2026/10/clean-architecture-07-test-without-db.webp)

الاختبار يستبدل المحولات فقط، والنواة نفسها لا تتغير

نشغل الاختبارات بالأمر [dotnet test](https://learn.microsoft.com/en-us/dotnet/core/tools/dotnet-test?ref=arabroot.io) مع عرض اسم كل اختبار:

```bash
dotnet test --logger "console;verbosity=normal"
```

والمخرج سوف يكون كما يلي (حذفنا أسطر البناء الأولى):

```text
[xUnit.net 00:00:00.58]   Discovered:  RoomBooking.Tests
[xUnit.net 00:00:00.74]   Starting:    RoomBooking.Tests
[xUnit.net 00:00:00.82]   Finished:    RoomBooking.Tests
  Passed RoomBooking.Tests.DependencyRuleTests.Application_references_only_the_domain [7 ms]
  Passed RoomBooking.Tests.DependencyRuleTests.Domain_references_nothing_outside_dotnet [4 ms]
  Passed RoomBooking.Tests.BookRoomTests.Allows_back_to_back_bookings [13 ms]
  Passed RoomBooking.Tests.BookRoomTests.Rejects_an_overlapping_booking [2 ms]
  Passed RoomBooking.Tests.BookRoomTests.Rejects_a_booking_in_the_past [1 ms]
  Passed RoomBooking.Tests.BookRoomTests.A_booking_cannot_be_longer_than_four_hours [1 ms]
  Passed RoomBooking.Tests.BookRoomTests.Books_a_free_room [1 ms]

Test Run Successful.
Total tests: 7
     Passed: 7
 Total time: 3.3542 Seconds
```

في المخرج أعلاه لاحظ التالي:

- اختبارات الحجز الخمسة استغرقت بضع أجزاء من الثانية لكل منها، لأنها لا تفتح اتصالاً بأي شيء، وأغلب ال 3.3 ثانية هي تشغيل أداة الاختبار نفسها.
- الاختبار `Allows_back_to_back_bookings` يثبت قاعدة دقيقة: حجز ينتهي الساعة 10 وحجز يبدأ الساعة 10 لا يتداخلان، وهذه من القواعد التي تختفي بين جمل SQL في الطريقة البسيطة ولا ينتبه لها أحد حتى يشتكي مستخدم.
- الاختباران الأولان من نوع آخر سوف نشرحه بعد قليل.

وللتأكد من أن الاختبارات لم تلمس Npgsql أصلاً، يكفي أن تنظر في مجلد البناء الخاص بها:

```bash
ls tests/RoomBooking.Tests/bin/Debug/net10.0/ | grep -i -E 'npgsql|RoomBooking'
```

والمخرج سوف يكون كما يلي:

```text
RoomBooking.Application.dll
RoomBooking.Application.pdb
RoomBooking.Domain.dll
RoomBooking.Domain.pdb
RoomBooking.Tests.deps.json
RoomBooking.Tests.dll
RoomBooking.Tests.pdb
RoomBooking.Tests.runtimeconfig.json
```

لا يوجد ملف `Npgsql.dll` ولا `RoomBooking.Infrastructure.dll`، أي أن منطق العمل اختبر دون أن تكون مكتبة قاعدة البيانات موجودة في نفس المجلد.

### التشغيل مع PostgreSQL حقيقية

الاختبار في الذاكرة لا يغني عن التشغيل الحقيقي، لأن محول PostgreSQL نفسه قد يحتوي خطأ في SQL، وهذا مكان اختبار التكامل Integration Test. سوف نشغل PostgreSQL 18 في Container على المنفذ 25432 مربوطاً بعنوان 127.0.0.1 فقط، وكلمة المرور للتجربة المحلية فقط:

```bash
docker run -d --name lab-arch-clean-db \
  -e POSTGRES_PASSWORD=lab-only-pass -e POSTGRES_DB=booking \
  -p 127.0.0.1:25432:5432 postgres:18
```

ثم نشغل ال API ونعطيه نص الاتصال Connection String عبر متغير بيئة، وفي .NET يتحول `ConnectionStrings__Bookings` إلى `ConnectionStrings:Bookings` في الإعدادات:

```bash
export ConnectionStrings__Bookings="Host=127.0.0.1;Port=25432;Database=booking;Username=postgres;Password=lab-only-pass"
dotnet run --project src/RoomBooking.Api --urls http://127.0.0.1:25080
```

ومن نافذة أخرى نرسل ثلاثة طلبات: حجز صحيح، ثم حجز يتداخل معه، ثم حجز مدته خمس ساعات:

```bash
curl -i -X POST http://127.0.0.1:25080/bookings -H 'Content-Type: application/json' \
  -d '{"room":"A-101","bookedBy":"sara","start":"2026-10-20T09:00:00","end":"2026-10-20T10:00:00"}'
curl -i -X POST http://127.0.0.1:25080/bookings -H 'Content-Type: application/json' \
  -d '{"room":"A-101","bookedBy":"omar","start":"2026-10-20T09:30:00","end":"2026-10-20T11:00:00"}'
curl -i -X POST http://127.0.0.1:25080/bookings -H 'Content-Type: application/json' \
  -d '{"room":"A-101","bookedBy":"omar","start":"2026-10-20T10:00:00","end":"2026-10-20T15:00:00"}'
```

والمخرج سوف يكون كما يلي (أبقينا سطر الحالة والجسم من كل رد):

```text
HTTP/1.1 201 Created
Location: /bookings/be951cb6-c282-4b1d-8112-7f26e21c4584
{"id":"be951cb6-c282-4b1d-8112-7f26e21c4584"}

HTTP/1.1 409 Conflict
{"error":"room A-101 is already booked at that time"}

HTTP/1.1 400 Bad Request
{"error":"a booking cannot be longer than 4 hours"}
```

ثم نقرأ حجوزات القاعدة عبر ال API، ومباشرة من قاعدة البيانات:

```bash
curl -s "http://127.0.0.1:25080/rooms/A-101/bookings?day=2026-10-20"
docker exec lab-arch-clean-db psql -U postgres -d booking -c "SELECT room, booked_by, starts_at, ends_at FROM bookings;"
```

والمخرج سوف يكون كما يلي:

```text
[{"id":"be951cb6-c282-4b1d-8112-7f26e21c4584","room":"A-101","bookedBy":"sara","start":"2026-10-20T09:00:00","end":"2026-10-20T10:00:00"}]
 room  | booked_by |      starts_at      |       ends_at
-------+-----------+---------------------+---------------------
 A-101 | sara      | 2026-10-20 09:00:00 | 2026-10-20 10:00:00
(1 row)
```

نفس القواعد التي اختبرناها في الذاكرة عملت هنا مع قاعدة بيانات حقيقية دون أي تغيير في Domain أو Application، فرسالة التداخل جاءت من `BookRoom`، ورسالة الأربع ساعات جاءت من `Booking`، والمحول الوحيد الذي تغير هو المستودع.

### قاعدة الاعتماديات كاختبار

مراجع المشاريع تمنع Domain من رؤية Infrastructure، لكنها لا تمنع أحداً من إضافة حزمة NuGet إلى مشروع Application واستخدامها مباشرة، لذلك أضفنا اختباراً يتحقق من مراجع كل مشروع داخلي باستخدام [Assembly.GetReferencedAssemblies](https://learn.microsoft.com/en-us/dotnet/api/system.reflection.assembly.getreferencedassemblies?ref=arabroot.io):

```csharp
using System.Reflection;
using RoomBooking.Application;
using RoomBooking.Domain;

namespace RoomBooking.Tests;

// The dependency rule as a test: an inner project may reference only .NET itself and projects further in.
public class DependencyRuleTests
{
    static string[] ReferencesOf(Assembly assembly) =>
        assembly.GetReferencedAssemblies()
            .Select(a => a.Name!)
            .Where(n => !n.StartsWith("System") && n != "netstandard")
            .ToArray();

    [Fact]
    public void Domain_references_nothing_outside_dotnet()
        => Assert.Empty(ReferencesOf(typeof(Booking).Assembly));

    [Fact]
    public void Application_references_only_the_domain()
        => Assert.Equal(["RoomBooking.Domain"], ReferencesOf(typeof(BookRoom).Assembly));
}
```

في الكود أعلاه لاحظ التالي:

- نستبعد مكتبات .NET نفسها التي تبدأ بكلمة `System`، لأن الاعتماد عليها مقبول في كل الدوائر.
- المترجم يسجل في ملف ال DLL المكتبات التي يستخدمها الكود فعلاً، فالحزمة التي تضاف إلى المشروع دون أن يستخدمها أي سطر لن تظهر هنا، وهذا مقبول لأن الضرر يبدأ عند الاستخدام.
- هذا الاختبار بسيط عمداً، وإذا احتجت قواعد أدق مثل «لا يستخدم أي كلاس في Application كلاساً من فضاء الأسماء Namespace الفلاني» فهناك مكتبات مخصصة لذلك مثل [NetArchTest](https://github.com/BenMorris/NetArchTest?ref=arabroot.io) و[ArchUnitNET](https://github.com/TNG/ArchUnitNET?ref=arabroot.io) في .NET، و[ArchUnit](https://www.archunit.org/?ref=arabroot.io) في Java.

### نكسر القاعدة عمداً ونرى من يكتشف الخطأ

لنفرض أن مطوراً في الفريق احتاج `SystemClock` داخل حالة الاستخدام، فأضاف مرجعاً من Application إلى Infrastructure:

```bash
dotnet add src/RoomBooking.Application reference src/RoomBooking.Infrastructure
dotnet build
```

والمخرج سوف يكون كما يلي (اختصرنا المسارات في أول السطر وآخره):

```text
NuGet.targets(1298,5): error MSB4006: There is a circular dependency in the target dependency graph involving target "_GenerateRestoreProjectPathWalk".
    1 Error(s)
```

البناء فشل لأن Infrastructure يعتمد أصلاً على Application، فأصبح بين المشروعين اعتماد دائري Circular Dependency، وهنا حمانا تقسيم المشاريع دون أي جهد منا. أعدنا ملف المشروع كما كان، ثم جربنا الخطأ الأخطر لأنه لا يكسر البناء: مطور آخر أراد تقريراً سريعاً عن عدد حجوزات القاعة، فأضاف حزمة Npgsql إلى Application وكتب الكلاس التالي:

```csharp
using Npgsql;

namespace RoomBooking.Application;

// A "quick" report added straight into the use cases project
public sealed class RoomUsageReport(NpgsqlDataSource db)
{
    public async Task<long> CountAsync(string room)
    {
        await using var cmd = db.CreateCommand("SELECT count(*) FROM bookings WHERE room = $1");
        cmd.Parameters.AddWithValue(room);
        return (long)(await cmd.ExecuteScalarAsync())!;
    }
}
```

```bash
dotnet add src/RoomBooking.Application package Npgsql --version 10.0.3
dotnet build
dotnet test
```

البناء نجح دون أي تحذير (`0 Warning(s)` و`0 Error(s)`)، أما الاختبار فكان مخرجه كما يلي (حذفنا أسطر Stack Trace):

```text
[xUnit.net 00:00:00.90]     RoomBooking.Tests.DependencyRuleTests.Application_references_only_the_domain [FAIL]
  Failed RoomBooking.Tests.DependencyRuleTests.Application_references_only_the_domain [39 ms]
  Error Message:
   Assert.Equal() Failure: Collections differ
Expected: ["RoomBooking.Domain"]
Actual:   ["RoomBooking.Domain", "Npgsql"]
                                 ↑ (pos 1)
Failed!  - Failed:     1, Passed:     6, Skipped:     0, Total:     7, Duration: 81 ms - RoomBooking.Tests.dll (net10.0)
```

الرسالة واضحة: مشروع حالات الاستخدام أصبح يعرف Npgsql، وهذا ما كان Cockburn يقصده بآلية تكتشف مخالفة الوعد، فبدلاً من أن يكتشف الفريق بعد سنة أن منطق العمل امتلأ بجمل SQL، يفشل خط CI في نفس اليوم. والحل الصحيح لهذا التقرير أن يكون منفذاً جديداً في Application مثل `IRoomUsageReader` ينفذه محول في Infrastructure، أو أن يبقى استعلاماً للقراءة في طبقة الطرف إذا كان تقريراً لا يحتوي أي قاعدة عمل. بعد ذلك حذفنا الملف والحزمة، وعادت الاختبارات السبعة إلى النجاح.

### حذف التجربة

أوقف ال API بالضغط على `Ctrl+C`، ثم احذف ال Container مع ال Volume المجهول Anonymous Volume الذي أنشأته Image الخاصة بـ PostgreSQL:

```bash
docker rm -f -v lab-arch-clean-db
```

## أخطاء شائعة عند التطبيق

- **كيانات هي كلاسات ال ORM نفسها**: عندما يحمل `Booking` خصائص Entity Framework أو أسماء أعمدة الجدول، فقد دخل الإطار إلى المركز، وأصبح تغيير الجدول يغير الكيان. افصل نموذج الحفظ عن الكيان عندما يختلفان فعلاً، وإذا كانا متطابقين في تطبيق بسيط فقرر ذلك بوعي.
- **منفذ يرجع `IQueryable`**: الواجهة التي ترجع `IQueryable` تسمح لحالة الاستخدام أن تبني الاستعلام بنفسها، فتتسرب تفاصيل ال ORM إلى الداخل، والأفضل أن يصف المنفذ ما يحتاجه بلغة العمل مثل `ForRoomAsync`.
- **واجهة لكل كلاس**: المنافذ مطلوبة عند الحدود مع الخارج، أي قاعدة البيانات والبريد والساعة والخدمات الخارجية، أما كلاس داخلي له تنفيذ واحد ولا يكلم الخارج فلا يحتاج واجهة، وكتابتها مقدماً من صور مخالفة مبدأ [YAGNI](https://arabroot.io/articles/%D9%85%D8%A8%D8%A7%D8%AF%D8%A6-%D8%A7%D9%84%D8%AA%D8%B5%D9%85%D9%8A%D9%85-solid-%D9%88-dry-%D9%88-kiss/#yagni).
- **المنطق في ال Controller والمشاريع الأربعة موجودة**: أسوأ حالة نراها هي مشروع بأسماء Clean Architecture كاملة، وقواعد العمل كلها في ال Controllers، ومشروع Domain فيه كلاسات بيانات بلا سلوك، فيدفع الفريق ثمن المعمارية دون فائدتها.
- **تحويل لكل شيء**: كائن للطلب وكائن للأمر وكائن للكيان وكائن للحفظ وكائن للرد، مع مكتبة تحويل بينها، في شاشة تحفظ ثلاثة حقول. التحويل عند الحدود مطلوب حيث يختلف الشكل فعلاً، وليس في كل خطوة.
- **الاكتفاء بالمجلدات**: مجلدات Domain وInfrastructure في مشروع واحد بلا اختبار مثل الذي كتبناه تعيد مشكلة الطبقات التي وصفها Cockburn، فالوعد بلا آلية تكشفه ينكسر مع الوقت.

## الثمن، ومتى لا تحتاج هذه المعمارية

لا توجد معمارية مجانية، وهذه المعمارية تضيف إلى مشروعك ما يلي:

- **ملفات ومشاريع أكثر**: في التجربة احتجنا خمسة مشاريع لقاعدتي عمل، ولو كتبنا نفس النظام في ملف واحد لكان أقصر بكثير.
- **طبقة غير مباشرة Indirection**: عندما تقرأ `BookRoom` وتريد أن تعرف أين يحفظ الحجز، فعليك أن تبحث عن من ينفذ الواجهة ومن يربطها في `Program.cs`، وهذا يبطئ المطور الجديد في الأسابيع الأولى.
- **تحويل البيانات Mapping**: بين الكيان وصف قاعدة البيانات وال DTO، وهو كود يكتب ويختبر ويخطئ.
- **تعلم الفريق**: الفكرة تحتاج اتفاقاً على أين يوضع كل شيء، وإلا أصبح كل مطور يطبقها بطريقته.

لذلك لا تحتاجها في الحالات التالية:

- **تطبيقات CRUD بسيطة**: لوحة إدارة تقرأ جداول وتكتبها دون قواعد حقيقية، فهنا قاعدة البيانات هي المنتج فعلاً، والمنافذ والمحولات لا تحمي شيئاً.
- **نموذج أولي Prototype قصير العمر**: كود تتوقع أن ترميه بعد شهر، والأهم فيه سرعة التجربة.
- **سكربتات وأدوات صغيرة**: أداة تنقل ملفات أو مهمة تنظيف يكفيها ملف واحد مرتب.
- **موقع صغير**: وهذا كلام Palermo نفسه في مقاله الذي سمى فيه المعمارية، فقد كتب أنها لا تناسب المواقع الصغيرة، وأنها للتطبيقات طويلة العمر أو ذات السلوك المعقد.

وتحتاجها عندما يعيش النظام سنوات، وفيه قواعد عمل حقيقية تتغير ويجب اختبارها بسرعة، أو يدخل إليه أكثر من محرك مثل API ومهام مجدولة ورسائل من طابور Queue، أو تتوقع أن تغير قاعدة البيانات أو مزود خدمة خارجية. وهناك طريق وسط معقول: ابدأ بمشروع واحد مرتب، واجعل المنافذ عند الحدود المؤلمة فقط مثل قاعدة البيانات والبريد والساعة، ثم افصل المشاريع عندما يكبر الفريق أو تكثر القواعد، وإذا كان التطبيق يكبر في عدة مجالات فانظر أيضاً إلى [الوحدة الواحدة المقسمة Modular Monolith](https://arabroot.io/articles/%D8%A7%D9%86%D9%85%D8%A7%D8%B7-%D8%A7%D9%84%D9%85%D8%B9%D9%85%D8%A7%D8%B1%D9%8A%D8%A9-layered-%D9%88-event-driven-%D9%88-modular-monolith/#modular-monolith) التي تقسم النظام بحسب المجال قبل أن تقسمه بحسب الدوائر. وأياً كان قرارك، فاكتبه في سجل قرار معماري ADR كما شرحنا في [مقال توثيق المعمارية](https://arabroot.io/articles/%D8%AA%D9%88%D8%AB%D9%8A%D9%82-%D8%A7%D9%84%D9%85%D8%B9%D9%85%D8%A7%D8%B1%D9%8A%D8%A9-%D9%85%D8%AE%D8%B7%D8%B7%D8%A7%D8%AA-c4-%D9%88-adr/#adr)، لكي يعرف من يأتي بعدك لماذا يوجد خمسة مشاريع لقاعدتي عمل، أو لماذا لا توجد.

## تدريب عملي

1. أضف إلى المشروع محولاً محركاً جديداً: تطبيق Console صغير يحجز قاعة من سطر الأوامر باستدعاء `BookRoom`، ولاحظ أنك لن تعدل أي ملف في Domain أو Application.
2. اكتب محولاً ثانياً لنفس المنفذ `IBookingRepository` يحفظ الحجوزات في ملف JSON، واربطه في `Program.cs` بدل PostgreSQL، ثم شغل نفس طلبات `curl` وتأكد أن الردود لم تتغير.

## الخلاصة

وصلنا لنهاية الموضوع، وأهم ما فيه:

- الترتيب الطبقي التقليدي يجعل منطق العمل يعتمد على الوصول إلى البيانات، فينتهي كل اعتماد عند قاعدة البيانات، ولا يمكن اختبار القواعد أو تشغيلها دونها.
- Hexagonal (Cockburn، 2005) وOnion (Palermo، 2008) وClean Architecture (Martin، 2012) فكرة واحدة بأربعة رسوم: منطق العمل في المركز، والتقنيات في الطرف، وكل اعتماد في الكود يشير إلى الداخل.
- المنفذ Port واجهة يكتبها الداخل بلغته، والمحول Adapter ينفذها لتقنية محددة، ولنفس المنفذ أكثر من محول، منها محول الاختبار في الذاكرة.
- اتجاه الاستدعاء وقت التشغيل قد يذهب إلى الخارج، أما اتجاه الاعتماد في الكود فينعكس بواجهة في الداخل ينفذها الخارج.
- تقسيم الدوائر إلى مشاريع منفصلة يجعل المترجم يمنع الاعتماد الدائري، واختبار صغير على مراجع كل مشروع يكشف الحزم التي تتسرب إلى الداخل.
- الثمن مشاريع وملفات وتحويلات أكثر، لذلك لا تستخدمها لتطبيق CRUD بسيط أو نموذج أولي أو موقع صغير، واستخدمها للأنظمة طويلة العمر ذات القواعد الحقيقية.

وأقرب المقالات إلى هذا الموضوع على الموقع:

[![](https://arabroot.io/content/images/2026/10/arch-intro-cover-cover.webp)دليلالتصميم والمعمارية: أربعة مستويات لنشاط واحدما الفرق بين تصميم البرمجيات ومعماريتها وتصميم الأنظمة ومعمارية المؤسسة، ومن يقرر في كل مستوى وكم يعيش قراره.](https://arabroot.io/articles/%D8%A7%D9%84%D9%81%D8%B1%D9%82-%D8%A8%D9%8A%D9%86-%D8%AA%D8%B5%D9%85%D9%8A%D9%85-%D8%A7%D9%84%D8%A8%D8%B1%D9%85%D8%AC%D9%8A%D8%A7%D8%AA-%D9%88%D9%85%D8%B9%D9%85%D8%A7%D8%B1%D9%8A%D8%AA%D9%87%D8%A7-%D9%88%D8%AA%D8%B5%D9%85%D9%8A%D9%85-%D8%A7%D9%84%D8%A7%D9%86%D8%B8%D9%85%D8%A9/)[![](https://arabroot.io/content/images/2026/10/design-principles-cover-cover.webp)دليلمبادئ التصميم Design Principles بأمثلة قبل وبعدلماذا يكسر تعديل صغير في الفاتورة شيئاً في المحاسبة، وكيف تساعدك مبادئ مثل SOLID وDRY على كود يتغير دون مفاجآت.](https://arabroot.io/articles/%D9%85%D8%A8%D8%A7%D8%AF%D8%A6-%D8%A7%D9%84%D8%AA%D8%B5%D9%85%D9%8A%D9%85-solid-%D9%88-dry-%D9%88-kiss/)[![](https://arabroot.io/content/images/2026/10/design-patterns-cover-cover.webp)دليلأنماط التصميم Design Patterns التي تقابلها فعلاًأشهر أنماط التصميم التي تقابلها في كل مشروع، والمشكلة التي يحلها كل نمط، ومتى يصبح عبئاً على من يعدل الكود بعدك.](https://arabroot.io/articles/%D8%A7%D9%86%D9%85%D8%A7%D8%B7-%D8%A7%D9%84%D8%AA%D8%B5%D9%85%D9%8A%D9%85-design-patterns-%D8%A7%D9%84%D8%AA%D9%8A-%D8%AA%D9%82%D8%A7%D8%A8%D9%84%D9%87%D8%A7-%D9%81%D8%B9%D9%84%D8%A7/)[![](https://arabroot.io/content/images/2026/10/ddd-basics-cover-cover.webp)دليلالتصميم الموجه بالمجال Domain-Driven Design ببساطةلماذا تعني كلمة «الطلب» ثلاثة أشياء في نفس الشركة، وكيف يقسم DDD النظام إلى سياقات لكل منها لغته ونموذجه.](https://arabroot.io/articles/%D8%A7%D9%84%D8%AA%D8%B5%D9%85%D9%8A%D9%85-%D8%A7%D9%84%D9%85%D9%88%D8%AC%D9%87-%D8%A8%D8%A7%D9%84%D9%85%D8%AC%D8%A7%D9%84-ddd-%D8%A8%D8%A8%D8%B3%D8%A7%D8%B7%D8%A9/)[![](https://arabroot.io/content/images/2026/10/architecture-styles-cover-cover.webp)دليلأنماط المعمارية Architecture Styles ومتى تختار كلاً منهاكيف تختار بين التطبيق الواحد والخدمات التي تتبادل الأحداث، وما الذي يناسب نظامك من أشهر أنماط المعمارية.](https://arabroot.io/articles/%D8%A7%D9%86%D9%85%D8%A7%D8%B7-%D8%A7%D9%84%D9%85%D8%B9%D9%85%D8%A7%D8%B1%D9%8A%D8%A9-layered-%D9%88-event-driven-%D9%88-modular-monolith/)[![](https://arabroot.io/content/images/2026/10/tiers-layers-cover-cover.webp)دليلالفرق بين Tiers وLayers: متى يكون نظامك ثلاثي الطبقات فعلاً؟ماذا يعني أن يكون نظامك ثلاثي الطبقات 3-tier فعلاً، والفرق بين الطبقة داخل الكود والمستوى في الشبكة، وما تطلبه الضوابط بالنص.](https://arabroot.io/articles/%D8%A7%D9%84%D9%85%D8%B9%D9%85%D8%A7%D8%B1%D9%8A%D8%A9-%D9%85%D8%AA%D8%B9%D8%AF%D8%AF%D8%A9-%D8%A7%D9%84%D8%B7%D8%A8%D9%82%D8%A7%D8%AA-tiers-%D9%88-layers/)[![](https://arabroot.io/content/images/2026/10/testing-types-cover-cover.webp)دليلأنواع الاختبارات للمبتدئين: من Unit إلى E2E مع Playwrightما الذي يكشفه كل نوع من الاختبارات وما الذي يفوته، وكم اختباراً تكتب من كل نوع، وأين يعمل كل منها في خط CI.](https://arabroot.io/articles/%D8%A7%D9%86%D9%88%D8%A7%D8%B9-%D8%A7%D9%84%D8%A7%D8%AE%D8%AA%D8%A8%D8%A7%D8%B1%D8%A7%D8%AA-%D9%84%D9%84%D9%85%D8%A8%D8%AA%D8%AF%D8%A6%D9%8A%D9%86-unit-%D9%88-integration-%D9%88-e2e/)

## سجل التحديثات

- أكتوبر 2026: كتابة المقال، ومراجعة ورقة Cockburn عن Hexagonal Architecture ومقال Palermo عن Onion Architecture ومقال Martin عن Clean Architecture، وبناء المشروع وتشغيل اختباراته على .NET SDK 10.0.400 مع xUnit 2.9.3 وNpgsql 10.0.3 وPostgreSQL 18.6.