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

8 پست با برچسب "معماری تمیز"

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

مشاهده تمام برچسب‌ها

هرچه رابط کاربری کم‌ادعاتر باشد، آزمون‌پذیری بیشتر می‌شود

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

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

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

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

function AccountSummary({account}: {account: Account}) {
const isBlocked = account.status === 'blocked'
const isLowBalance = account.balance < 100_000
const canWithdraw = account.status === 'active' && account.balance > 0

const statusText = isBlocked ? 'مسدود' : 'فعال'
const statusColor = isBlocked ? 'red' : 'green'
const balanceText = `${account.balance.toLocaleString('fa-IR')} تومان`
const warningText = isLowBalance
? 'موجودی حساب کم است'
: null

return (
<section>
<h2>{account.ownerName}</h2>
<span className={statusColor}>{statusText}</span>
<p>{balanceText}</p>
{warningText && <p>{warningText}</p>}
<button disabled={!canWithdraw}>برداشت</button>
</section>
)
}

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

مسئله این نیست که رابط کاربری بد است. مسئله این است که رابط کاربری جای خوبی برای پنهان‌کردن منطق تصمیم‌گیری نیست.

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

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

هسته‌ی روشن سیستم در مرکز که ابزارهایی مانند دیتابیس و وب در پیرامون آن قرار دارند و جهت وابستگی از ابزارها به سمت هسته است.

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

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

import {PrismaClient} from '@prisma/client'
import axios from 'axios'

const prisma = new PrismaClient()

class WithdrawService {
async withdraw(userId: string, amount: number) {
const user = await prisma.user.findUnique({
where: {id: userId},
})

if (!user) {
throw new Error('User not found')
}

if (user.balance < amount) {
throw new Error('Insufficient balance')
}

await axios.post('https://wallet-service.example.com/withdraw', {
userId,
amount,
})

await prisma.withdrawRequest.create({
data: {
userId,
amount,
status: 'submitted',
},
})
}
}

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

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

بیشتر باگ‌ها از جایی آغاز می‌شوند که وضعیت عوض می‌شود

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

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

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

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

شی‌گرایی یعنی کنترل وابستگی، نه فقط ساختن کلاس

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

پروژه از بیرون کاملاً شی‌گرا به نظر می‌رسید. تقریباً برای هر مفهوم، یک کلاس وجود داشت: UserService، OrderManager، PaymentHandler، ReportGenerator و چندین کلاس دیگر. کدها دیگر مثل یک اسکریپت بلند و تخت نبودند. هر چیز، جایی داشت و هر فایل، اسمی آشنا. اما وقتی تیم خواست پایگاه داده را برای آزمون‌ها جایگزین کند، یا بخشی از منطق پرداخت را بدون فریم‌ورک وب اجرا کند، واقعیت خودش را نشان داد: کلاس‌ها زیاد بودند، اما وابستگی‌ها همچنان به دیتابیس، فریم‌ورک و جزئیات بیرونی قفل شده بودند.

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

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

گاهی مرز معماری را می‌کشیم، اما هنوز دیوار نمی‌سازیم

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

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

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

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

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

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

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

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

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