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

قانون کسب‌وکار نباید ته کنترلر گم شود

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

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

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

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

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

فصل بیستم کتاب معماری تمیز (Clean Architecture) درباره‌ی همین مرزهاست: قواعد کسب‌وکار، موجودیت‌ها و کاربردها. حرف اصلی این فصل این نیست که همه‌ی پروژه‌ها باید تعداد زیادی لایه و پوشه داشته باشند. حرف مهم‌تر این است که باید بفهمیم قلب نرم‌افزار کجاست و اجازه ندهیم زیر جزئیات حاشیه‌ای گم شود.

قانون کسب‌وکار چیست؟

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

کنترلر چه کاری باید انجام دهد؟

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

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

برای نمونه، این‌که «اگر کاربر از سقف برداشت روزانه عبور کرده، درخواست رد شود»، یک قانون کسب‌وکار است. اما این‌که این خطا با کد وضعیت ۴۰۰ برگردد یا ۴۲۲، جزئیات ارائه‌ی پاسخ است. این دو نباید در ذهن ما یکی شوند، چون دلیل تغییرشان یکی نیست.

نمونه‌ی رایج: کنترلری که آرام‌آرام چاق می‌شود

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

async function createWithdrawalRequest(req, res) {
const user = await usersRepository.findById(req.user.id);
const amount = Number(req.body.amount);

if (!user.isVerified) {
return res.status(400).json({message: 'کاربر احراز هویت نشده است.'});
}

if (amount < 100_000) {
return res.status(400).json({message: 'مبلغ برداشت کمتر از حد مجاز است.'});
}

const todayTotal = await withdrawalsRepository.sumToday(user.id);
if (todayTotal + amount > 500_000_000) {
return res.status(400).json({message: 'سقف برداشت روزانه پر شده است.'});
}

if (user.balance - amount < user.minimumRequiredBalance) {
return res.status(400).json({message: 'مانده‌ی حساب کافی نیست.'});
}

const withdrawal = await withdrawalsRepository.create({
userId: user.id,
amount,
status: 'pending',
});

await auditLogger.log('withdrawal_created', withdrawal.id);
await notificationGateway.send(user.phoneNumber, 'درخواست برداشت شما ثبت شد.');

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

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

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

کنترلر چاق فقط کد بلند نیست

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

قانون کسب‌وکار، کاربرد، کنترلر و جزئیات بیرونی

برای جداکردن مسئله، باید تفاوت چند مفهوم را روشن کنیم.

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

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

کاربرد (Use Case) یک سناریوی مشخص از کار سامانه است. «ثبت درخواست برداشت» یک کاربرد است. کاربرد معمولاً چند موجودیت و چند درگاه بیرونی را هماهنگ می‌کند: کاربر را می‌خواند، قانون را اجرا می‌کند، درخواست را ذخیره می‌کند، و شاید رخدادی برای اطلاع‌رسانی منتشر کند. کاربرد نباید درگیر این باشد که درخواست از چه مسیر وبی آمده یا پاسخ نهایی چه قالبی دارد.

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

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

یک بازچینی کوچک

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

withdrawal/
create-withdrawal-request.ts
withdrawal-policy.ts
withdrawal-repository.ts
http/
withdrawal-controller.ts
notification/
notification-gateway.ts

در این چینش، کنترلر فقط ورودی را آماده می‌کند:

async function createWithdrawalRequestController(req, res) {
const result = await createWithdrawalRequest.execute({
userId: req.user.id,
amount: Number(req.body.amount),
});

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

و کاربرد، سناریوی اصلی را پیش می‌برد:

async function createWithdrawalRequest(input) {
const user = await usersRepository.findById(input.userId);
const todayTotal = await withdrawalsRepository.sumToday(input.userId);

withdrawalPolicy.ensureCanCreate({
user,
amount: input.amount,
todayTotal,
});

const withdrawal = await withdrawalsRepository.createPending({
userId: user.id,
amount: input.amount,
});

await events.publish({type: 'withdrawal_created', withdrawalId: withdrawal.id});

return {id: withdrawal.id};
}

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

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

سریالایزر هم جای قانون اصلی نیست

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

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

چرا این جداسازی به درد محصول هم می‌خورد؟

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

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

نشانه‌های عملی جداسازی

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

پیشنهاد عملی

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

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

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

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

pnpm test
pnpm typecheck
pnpm build

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

جمع‌بندی

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

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

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