پرش به مطلب اصلی

معماری تمیز را با نقاشی دایره‌ها اشتباه نگیریم

· ۹ دقیقه مطالعه
مهدی مالوردی
مهندس نرم‌افزار و نویسندهٔ این سایت

تیم تصمیم گرفته بود «معماری تمیز» را جدی‌تر وارد پروژه کند. چند پوشه‌ی تازه ساخته شد: entity، usecase و adapter. نمودار دایره‌ای معروف هم در جلسه‌ی فنی روی تخته کشیده شد. از بیرون، همه‌چیز شبیه یک حرکت درست به نظر می‌رسید: نام‌ها آشنا بودند، ساختار پروژه مرتب‌تر شده بود و کدها دیگر همگی در یک پوشه‌ی بزرگ کنار هم نبودند.

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

چند دایره‌ی معماری روی کاغذ، همراه با فلش‌هایی که جهت درست و نادرست وابستگی را نشان می‌دهند؛ تصویری مفهومی، ساده و آموزشی.

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

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

خطر معماری نمایشی

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

مسئله دایره‌ها نیستند

دایره‌های معماری تمیز معمولاً این پیام را منتقل می‌کنند: هرچه به مرکز نزدیک‌تر می‌شویم، به سیاست‌های پایدارتر و مهم‌تر سامانه نزدیک‌تر هستیم؛ هرچه به بیرون می‌رویم، به جزئیات اجرایی، ابزارها و سازوکارهای تغییرپذیرتر می‌رسیم.

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

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

قانون وابستگی یعنی چه؟

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

قانون وابستگی

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

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

به جای این‌که بگوییم «ثبت سفارش چه رفتاری دارد؟»، می‌گوییم «در این کنترلر چه چیزی ذخیره کنیم؟» به جای این‌که بپرسیم «قانون اعتبارسنجی سفارش چیست؟»، می‌پرسیم «این فیلد در سریالایزر چطور بررسی شود؟» به جای این‌که کاربرد را مستقل بفهمیم، آن را به مسیر وب یا جدول پایگاه داده گره می‌زنیم.

سیاست و جزئیات را قاطی نکنیم

در زبان معماری تمیز، سیاست همان تصمیم مهم و پایدارتر سامانه است. مثلاً این‌که «سفارش بدون پرداخت معتبر نیست»، یا «کاربر غیرفعال اجازه‌ی برداشت ندارد»، یا «تخفیف نباید قیمت نهایی را منفی کند». این‌ها به مسئله مربوط‌اند، نه به ابزار.

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

مشکل زمانی آغاز می‌شود که سیاست در دل جزئیات نوشته شود. برای نمونه، چنین کدی شاید در پروژه‌ای با پوشه‌بندی ظاهراً تمیز هم دیده شود:

// usecase/create-order.ts
async function createOrder(req, res, db) {
const user = await db.users.findById(req.user.id);

if (!user.isActive) {
return res.status(400).json({message: 'کاربر غیرفعال است.'});
}

const order = await db.orders.insert({
userId: user.id,
items: req.body.items,
});

return res.status(201).json({id: order.id});
}

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

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

type CreateOrderInput = {
userId: string;
items: OrderItem[];
};

type UserRepository = {
findById(userId: string): Promise<User>;
};

type OrderRepository = {
createPending(input: CreatePendingOrderInput): Promise<Order>;
};

async function createOrder(input: CreateOrderInput) {
const user = await userRepository.findById(input.userId);

if (!user.isActive) {
throw new InactiveUserError();
}

const order = await orderRepository.createPending({
userId: user.id,
items: input.items,
});

return {id: order.id};
}

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

وابستگی همیشه با import دیده نمی‌شود

گاهی وابستگی مستقیم در قالب import دیده می‌شود؛ مثلاً کاربرد، کلاس کنترلر یا مدل پایگاه داده را وارد کرده است. این حالت ساده‌تر پیدا می‌شود. اما همیشه ماجرا به این وضوح نیست.

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

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

چرا تیم‌ها به معماری نمایشی می‌افتند؟

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

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

معماری تمیز قرار نیست جلوی تحویل را بگیرد. اما قرار است نگذارد ابزارهای بیرونی، شکل منطق اصلی سامانه را تعیین کنند. تفاوت مهمی میان «ساده نگه‌داشتن» و «وابسته‌کردن همه‌چیز به جزئیات» وجود دارد.

چک‌لیست ساده‌ی تشخیص معماری واقعی

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

پیشنهاد عملی

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

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

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

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

pnpm test
pnpm typecheck
pnpm build

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

جمع‌بندی

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

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

در پایان، پرسش خوب این نیست که «آیا پوشه‌ی entity و usecase داریم؟» پرسش بهتر این است: «اگر فردا فریم‌ورک، پایگاه داده یا مسیر ورودی عوض شود، قانون‌های اصلی سامانه چقدر دست‌نخورده می‌مانند؟» پاسخ همین پرسش، تفاوت معماری تمیز با تصویر تمیز معماری را روشن می‌کند.