مقاله تخصصی باورژن

DDD و پایگاه داده شما

در این مقاله با رابطه DDD و پایگاه داده آشنا شوید و ببینید چگونه ORM، الگوی ریپازیتوری و آزمون‌های واحد به طراحی بهتر مدل دامنه کمک می‌کنند.

ارتباط طراحی دامنه‌محور DDD با پایگاه داده و استفاده از ORM در معماری نرم‌افزار
این مقاله را با تمرکز بر مفاهیم و مثال‌های فنی بخوانید. زمان مطالعه تقریبی: 10 دقیقه

رواج طراحی دامنه‌محور (Domain-Driven Design یا DDD) در میان طیف گسترده‌تری از توسعه‌دهندگان، باعث شکل‌گیری برخی برداشت‌های واقعاً آسیب‌زا شده است، مانند این دیدگاه (که شاید بیش از آنکه یک ایده باشد، نوعی طرز فکر باشد):

داده بد است، رفتار خوب است، مدل دامنه عالی است و پایگاه داده افتضاح است.

(در این مقاله حتی وارد بحث CRUD هم نمی‌شویم، موضوعی که ظاهراً از بدترینِ بدهاست.)

امروزه بسیاری از ما از استفاده از ORM در کنار یک «مدل دامنه مبتنی بر DDD» احساس شرمندگی می‌کنیم. گاهی از اینکه بخشی از پیکربندی نگاشت را در مدل قرار دهیم یا درون موجودیت‌ها (یا به‌اصطلاح، Aggregateها) کارهایی انجام دهیم تا بتوان آن‌ها را به‌راحتی در پایگاه داده سریال‌سازی و ذخیره کرد، اجتناب می‌کنیم.

کد زیرساختی در موجودیت‌های شما

ما می‌خواهیم مدل دامنه‌مان از آبجکت‌های خالص تشکیل شده باشد و صرفاً به مسائل و منطق مربوط به دامنه بپردازد. این طرز فکر گاهی ما را به بازنویسی کتابخانه‌های Assertion سوق می‌دهد، فقط به این دلیل که نسخه آماده آن‌ها در پوشه vendor/ قرار دارد. همچنین تصور می‌کنیم پیکربندی نگاشت (Mapping)، صرف‌نظر از اینکه از Annotationها استفاده کنیم یا کد نگاشت را خودمان بنویسیم، بخشی از کدهای زیرساختی محسوب می‌شود.

برای درک بهتر تفاوت میان کد هسته (Core Code) و کد زیرساختی (Infrastructure Code)، می‌توان به مقاله پیشین نویسنده با عنوان «آیا تمام کدهای موجود در پوشه vendor زیرساختی هستند؟» اشاره کرد. Matthias Noback در این مقاله توضیح می‌دهد که محل قرارگیری کد، به‌تنهایی تعیین‌کننده ماهیت آن نیست، بنابراین، صرف اینکه یک کتابخانه در پوشه vendor/ قرار گرفته باشد، به این معنا نیست که کد آن زیرساختی است.

از دیدگاه نویسنده، کد هسته باید دو ویژگی اساسی داشته باشد:

  1. نباید وابستگی مستقیمی به سیستم‌های خارجی داشته باشد: کد هسته نباید مستقیماً با سیستم‌هایی خارج از برنامه تعامل کند و نباید به کدهایی وابسته باشد که برای ارتباط با نوع مشخصی از سیستم‌های خارجی نوشته شده‌اند. برای مثال، برقراری ارتباط با شبکه، دسترسی به سیستم فایل یا دریافت اطلاعات از منابع خارجی، از جمله مواردی هستند که کد را به زیرساخت وابسته می‌کنند.

  2. نباید برای اجرا به محیط یا بستر خاصی نیاز داشته باشد: کد هسته باید بدون نیاز به آماده‌سازی یک محیط اجرایی مشخص قابل استفاده باشد و نباید به وابستگی‌هایی متکی باشد که صرفاً در یک بستر خاص، مانند برنامه‌های وب یا برنامه‌های خط فرمان (CLI)، قابل اجرا هستند.

بر این اساس، هر کدی که دست‌کم یکی از این دو شرط را برآورده نکند، در دسته کدهای زیرساختی قرار می‌گیرد.

نویسنده همچنین تاکید می‌کند که این معیارها ارتباطی با محل نگهداری کد در پروژه ندارند. برای مثال، یک کتابخانه مدیریت رویداد (Event Dispatcher)، کتابخانه Assertion یا کتابخانه اشیای مقداری (Value Object) ممکن است در پوشه vendor/ قرار داشته باشد، اما همچنان بخشی از کد هسته محسوب شود، زیرا برای اجرا به سیستم خارجی یا محیط خاصی وابسته نیست.

در نتیجه، معیار اصلی برای تشخیص کد هسته این است که بتوان آن را به‌صورت مستقل اجرا کرد، بدون آنکه به فراهم‌کردن وابستگی‌های خارجی یا آماده‌سازی محیط ویژه‌ای نیاز داشته باشد. نویسنده بر همین اساس نتیجه می‌گیرد که کد هسته تنها بخشی از برنامه است که می‌توان آن را واقعاً با آزمون واحد (Unit Test) آزمایش کرد. البته این به معنای غیرقابل‌آزمایش بودن کدهای زیرساختی نیست، بلکه آزمون چنین کدهایی در دسته آزمون‌های یکپارچه‌سازی (Integration Tests) قرار می‌گیرد، حتی اگر با همان چارچوب آزمون‌نویسی اجرا شوند.

بنابراین، برای تشخیص مرز میان کد دامنه و کد زیرساختی، باید به میزان وابستگی کد به سیستم‌های خارجی و محیط اجرای آن توجه کرد، نه صرفاً به محل قرارگیری فایل‌ها یا نوع کتابخانه‌های مورداستفاده.

بیایید از همین تعریف برای مدل‌های دامنه‌ای استفاده کنیم که برای ماندگاری داده‌ها (Persistence) آماده شده‌اند، یعنی مدل‌هایی که به‌اصطلاح «آماده استفاده با ORM» هستند.

برای اینکه مطمئن شویم کد زیرساختی را به‌درستی از کد دامنه جدا کرده‌ایم، کافی است بررسی کنیم آیا می‌توانیم رفتار مدل را با یک آزمون واحد آزمایش کنیم یا خیر. بیایید بار دیگر تعریف مایکل فیدرز (Michael Feathers) از آزمون واحد را مرور کنیم:

یک آزمون، آزمون واحد محسوب نمی‌شود اگر:

  • با پایگاه داده ارتباط برقرار کند.
  • از طریق شبکه با سیستم‌های دیگر ارتباط داشته باشد.
  • با سیستم فایل تعامل داشته باشد.
  • نتوان آن را هم‌زمان با سایر آزمون‌های واحد اجرا کرد.
  • برای اجرای آن لازم باشد تغییرات یا اقدامات ویژه‌ای در محیط اجرا انجام دهید، مانند ویرایش فایل‌های پیکربندی.

بنابراین، برای آزمایش رفتار یک مدل دامنه با استفاده از آزمون واحد، نباید به پایگاه داده واقعی، اتصال شبکه، سیستم فایل و موارد مشابه نیاز داشته باشیم. هیچ‌گونه آماده‌سازی ویژه‌ای نباید لازم باشد. باید بتوانید به‌سادگی از کلاس یک نمونه ایجاد کنید و متدی از آن را فراخوانی کنید.

موجودیت‌ها باید به‌صورت مستقل قابل آزمایش باشند

بیایید چند نمونه رایج را بررسی کنیم که ممکن است باعث شوند تصور کنید کدهای زیرساختی در مدل دامنه خود دارید. برای مثال، کد زیر را در نظر بگیرید:

/**
 * @Entity
 * @Table(name="todo_items")
 */
final class ToDoItem
{
    /**
     * @Id
     * @Column(type="integer")
     * @GeneratedValue
     */
    private int $id;

    /**
     * @Column(type="string")
     */
    private string $description;

    public function __construct()
    {
    }

    public function setDescription(string $description): void
    {
        $this->description = $description;
    }

    // ...
}

آیا این کد معیار موردنظر را برآورده می‌کند؟ بله. شما می‌توانید این کد را بدون نیاز به پایگاه داده یا سایر وابستگی‌های خارجی اجرا کنید. همچنین برای اجرای آن نیازی نیست محیط را به شکل خاصی آماده کنید. در یک آزمون واحد، کافی است به‌سادگی کد زیر را اجرا کنید:

$toDoItem = new ToDoItem();
$toDoItem->setDescription('The description');

// ...

بنابراین، هیچ‌یک از بخش‌های این کد، کد زیرساختی محسوب نمی‌شود، هرچند این Annotationها قطعاً با هدف پشتیبانی از ماندگاری داده‌ها به کار رفته‌اند و می‌توان گفت این کلاس برخی ملاحظات زیرساختی را نیز در خود دارد.

حالا درباره مثال زیر که شامل کد نگاشت نوشته‌شده به‌صورت دستی است، چه نظری دارید؟

final class ToDoItem
{
    private int $id;

    private string $description;

    public function __construct()
    {
    }

    public function setDescription(string $description): void
    {
        $this->description = $description;
    }

    public function getState(): array
    {
        return [
            'id' => $this->id,
            'description' => $this->description
        ];
    }

    public static function fromState(array $state): self
    {
        $instance = new self();
        $instance->id = (int)$state['id'];
        $instance->description = (string)$state['description'];

        return $instance;
    }

    public static function getTableName(): string
    {
        return 'todo_items';
    }

    // ...
}

این مدل چندان مطلوبی نیست. اما باز هم می‌توانید به‌سادگی از این کلاس نمونه‌ای ایجاد کنید، هر متدی را روی آن فراخوانی کنید و نتایج را با استفاده از Assertion بررسی کنید. هیچ وابستگی خارجی یا آماده‌سازی ویژه‌ای لازم نیست.

تا اینجا هنوز نمونه‌ای از کد زیرساختی ندیده‌ایم، با وجود اینکه نام جدول‌ها، نام ستون‌ها و نوع ستون‌ها را در کد مشاهده کردیم.

آزمون ماندگاری داده‌ها (Persistence)

نکته مهم این است که آزمون‌های واحد ثابت نمی‌کنند آبجکت شما واقعاً قابلیت ذخیره‌سازی پایدار در پایگاه داده را دارد. همچنین نوشتن آزمون واحد برای کدهای نگاشت، مانند متدهای getState() و fromState()، عملاً فایده چندانی ندارد، زیرا این متدها صرفاً با هدف پشتیبانی از ماندگاری داده‌ها طراحی شده‌اند.

بنابراین، به‌جای نوشتن آزمون واحد برای کدهای نگاشت، باید با استفاده از یک آزمون یکپارچه‌سازی (Integration Test) ثابت کنید که یک آبجکت واقعاً قابلیت ذخیره‌سازی و بازیابی دارد، البته تا جایی که فلسفه شیءگرایی شما اجازه دهد چنین ادعایی را مطرح کنید.

این آزمون، ریپازیتوری (Repository) را بررسی می‌کند و نشان می‌دهد که می‌توانید یک آبجکت را ذخیره کنید، دوباره آن را بازیابی کنید و مطمئن شوید که همچنان رفتار مشابهی دارد (یا به‌اصطلاح، با آبجکت اولیه برابر است).

اما اگر سوال این باشد که آیا مدل دامنه من به‌اندازه کافی مستقل و کم‌وابسته است (مستقل از چه چیزی؟ از پایگاه داده و زیرساخت)، پاسخ مثبت است.

از نظر من، این پاسخ به این معناست که برخی رویکردهایی که در پروژه‌های واقعی دیده‌ام، نمونه‌های آشکار مهندسی بیش‌ازحد (Over-engineering) هستند.

متاسفانه پروژه‌های زیادی وجود دارند که از این قاعده پیروی می‌کنند: هر موجودیت باید یک آبجکت ساده و معمولی (در PHP، جاوا و غیره) باشد که هیچ ارتباطی با ORM یا به‌طور کلی پایگاه داده نداشته باشد.

علاوه بر این، برای هر موجودیت به یک آبجکت اضافی نیاز دارند که گاهی «موجودیت زیرساختی» (Infrastructure Entity) نامیده می‌شود. سپس کدی می‌نویسند تا موجودیت دامنه را به موجودیت زیرساختی نگاشت کند و در نهایت، آن را با استفاده از ORM در پایگاه داده ذخیره کنند.

اگر چنین رویکردی را در پیش بگیرید، توسعه و تغییر مدل دامنه به هر شکلی بسیار دشوار و زمان‌بر خواهد شد. این موضوع نشانه‌ای جدی است که طراحی شما احتمالاً انتخاب مناسبی نیست.

به اعتقاد من، باید برای تغییر آماده باشید، نه اینکه از آن بترسید.

شاید دلیل معرفی موجودیت‌های زیرساختی مجزا این باشد که توسعه‌دهندگان می‌خواهند تغییرات موردنیاز هنگام مهاجرت به یک ORM دیگر را محدود و متمرکز کنند.

البته باید بپذیرم که نمونه‌کدهای قبلی شامل بخش‌هایی وابسته به ORM در خود کلاس‌های موجودیت بودند. این موضوع باعث می‌شود فرایند مهاجرت کمی پیچیده‌تر شود و قطعاً برای انجام آن مجبور خواهید بود کلاس‌های متعددی را تغییر دهید.

بااین‌حال، زمانی که برای توسعه و تکامل مدل دامنه صرف می‌شود، احتمالاً بسیار بیشتر از زمانی است که برای مهاجرت به یک ORM جدید نیاز دارید. به همین دلیل، از نظر من کفه ترازو به نفع ترکیب این دو مسئولیت در یک آبجکت سنگینی می‌کند.

برخی ممکن است بگویند: اما این رویکرد از اصل تک‌مسئولیتی (Single Responsibility Principle یا SRP) پیروی نمی‌کند.

صادقانه بگویم، من به‌ندرت این استدلال را از همان ابتدا استدلال قانع‌کننده‌ای می‌دانم. هرکسی تعریف موردعلاقه خودش را از این اصل دارد و معمولاً هیچ‌کس بر سر یک تعریف مشترک به توافق نمی‌رسد. بنابراین، قصد ندارم وارد بحثی شوم که محور آن مطرح‌کردن اصل SRP باشد.

در واقع، اگر قصد مهاجرت به یک ORM متفاوت را دارید، داشتن مجموعه‌ای مناسب از آزمون‌های یکپارچه‌سازی به کمک شما خواهد آمد. در چنین شرایطی، اهمیت چندانی ندارد کدی که باید مهاجرت داده شود دقیقاً در کدام بخش قرار گرفته است.

آزمون‌های یکپارچه‌سازی همچنین می‌توانند در نقش آزمون‌های قرارداد (Contract Tests) عمل کنند و نشان دهند که یک پیاده‌سازی جایگزین از مخزن، قرارداد تعریف‌شده در اینترفیس ریپازیتوری (Repository Interface) را به‌درستی اجرا می‌کند. البته این موضوع به این معناست که باید آن رابط را نیز تعریف کرده باشید، کاری که به‌هرحال یک مزیت بزرگ محسوب می‌شود.

یک آزمون قرارداد ساده برای یک ریپازیتوری می‌تواند چیزی شبیه به مثال زیر باشد:

$repository = ...;

$repository->save($entity);

$fromDatabase = $repository->getById($entity->id());

self::assertEquals($entity, $fromDatabase);

این فقط نقطه شروع کار است. شما باید موجودیت‌ها را در وضعیت‌های مختلف و با تعداد متفاوتی از موجودیت‌های فرزند نیز آزمایش کنید.

اما زمانی که چنین مجموعه‌ای از آزمون‌ها را در اختیار داشته باشید، می‌توانید پیاده‌سازی ریپازیتوری را تغییر دهید و بررسی کنید که آیا پیاده‌سازی جایگزین قادر است همان عملکردهای پیاده‌سازی اصلی را ارائه دهد یا خیر.

به عبارت دیگر، این آزمون‌ها نشان می‌دهند که پیاده‌سازی جایگزین، قرارداد تعریف‌شده برای ابسترکشن ریپازیتوری (Repository Abstraction) را به‌درستی رعایت می‌کند.

در استفاده از الگوی Active Record تجدیدنظر کنید

اگر به دنبال مدلی از دامنه هستید که بتوان آن را به‌صورت مستقل آزمایش کرد (با استفاده از یک آزمون واحد واقعی، بدون نیاز به پایگاه داده یا آماده‌سازی ویژه محیط)، الگوی اکتیو رکورد گزینه مناسبی نخواهد بود.

فراخوانی متدهای چنین آبجکت‌هایی احتمالاً باعث ایجاد درخواست‌هایی به پایگاه داده می‌شود. البته اگر چنین فراخوانی‌هایی رخ ندهد، شاید این الگو مصالحه‌ای قابل‌قبول باشد.

مشکلی که دستیابی به استقلال در مدل‌های اکتیو رکورد را دشوار می‌کند، این است که این مدل‌ها هم‌زمان نقش سرویس را نیز ایفا می‌کنند.

متدهایی مانند save()، delete() و getById() همگی مسئولیت‌هایی هستند که به یک موجودیت اضافه شده‌اند، درحالی‌که موجودیت اساساً یک سرویس نیست.

جداکردن این مسئولیت‌ها می‌تواند راه‌حل مناسبی باشد. برای مثال، می‌توانید یک اینترفیس ریپازیتوری تعریف کنید تا مشخص باشد که این آبجکت با چیزی خارج از برنامه تعامل دارد.

سپس می‌توانید پیاده‌سازی‌ای داشته باشید که با استفاده از سازوکار ارسال دوگانه (Double Dispatch)، فراخوانی را به موجودیت اکتیو رکورد واگذار کند، مانند مثال زیر:

final class ToDoItemActiveRecordRepository implements ToDoItemRepository
{
    public function save(ToDoItem $entity): void
    {
        $entity->save();
    }
}

این رویکرد تنها زمانی منطقی است که باعث شود مدل شما به‌صورت مستقل و ساده‌تر قابل آزمایش باشد. از نظر من، این یک مزیت بسیار بزرگ محسوب می‌شود.

اما مانند همیشه، ممکن است این مزیت از نظر شما چندان قابل‌توجه نباشد. در این صورت، شاید چنین رویکردی واقعاً مزیتی برای شما نداشته باشد، یا می‌توانید آن را در عمل آزمایش کنید و با بررسی نتایج، تصمیم بهتری بگیرید.

نتیجه‌گیری

اجازه ندهید جزم‌اندیشی بی‌دلیل، مدل دامنه و توانایی شما برای توسعه و تکامل آن را مختل کند.

مطمئن شوید که می‌توانید مدل خود را به‌صورت مستقل آزمایش کنید، اما در عین حال، هرگونه پیکربندی یا کدی را که فرایند ذخیره‌سازی پایدار آن را ساده‌تر می‌کند، به مدل اضافه کنید.

دیدگاه‌ها


ثبت دیدگاه