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

مسئولیت یکتا یعنی یک دلیل برای تغییر، نه فقط یک تابع کوچک‌تر

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

یک کلاس در مرکز که از چند سمت با خواسته‌های مالی، نمایشی، ذخیره‌سازی و گزارشی کشیده می‌شود.

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

class PaymentService {
async process(payment: Payment) {
const fee = payment.amount * 0.01
const finalAmount = payment.amount - fee

const savedPayment = await db.payments.insert({
userId: payment.userId,
amount: payment.amount,
fee,
finalAmount,
status: 'done',
})

return {
id: savedPayment.id,
amount: savedPayment.amount,
fee: savedPayment.fee,
finalAmount: savedPayment.finalAmount,
message: 'پرداخت با موفقیت انجام شد',
}
}
}

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

یادداشت

اصل مسئولیت یکتا (Single Responsibility Principle) می‌گوید هر واحد نرم‌افزاری باید فقط یک دلیل برای تغییر داشته باشد.

منظور از «واحد نرم‌افزاری» می‌تواند کلاس، ماژول، بسته یا حتی یک بخش بزرگ‌تر از سامانه باشد. نکته‌ی مهم این است که مسئولیت یکتا درباره‌ی «محور تغییر» حرف می‌زند، نه صرفاً اندازه‌ی کد.

در مثال بالا، دست‌کم سه گروه می‌توانند باعث تغییر این کلاس شوند.

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

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

«یک دلیل برای تغییر» یعنی چه؟

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

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

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

هشدار

سوءبرداشت رایج این است که اصل مسئولیت یکتا یعنی «هر تابع باید خیلی کوچک باشد» یا «هر کلاس فقط یک متد داشته باشد».

کوچک‌بودن می‌تواند به خوانایی کمک کند، اما تضمین‌کننده‌ی طراحی درست نیست. ممکن است کلاسی بسیار کوتاه باشد، ولی همچنان چند دلیل مستقل برای تغییر داشته باشد. برعکس، ممکن است کلاسی چند تابع داشته باشد، اما همه‌ی آن‌ها در خدمت یک محور تغییر واحد باشند.

ذی‌نفعان، دلیل تغییر می‌سازند

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

در مثال پرداخت، این ذی‌نفعان را داریم:

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

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

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

بازنویسی با محور تغییر

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

نسخه‌ی ساده‌تر و سالم‌تر می‌تواند چنین ساختاری داشته باشد:

class PaymentCalculator {
calculate(payment: Payment) {
const fee = payment.amount * 0.01

return {
amount: payment.amount,
fee,
finalAmount: payment.amount - fee,
}
}
}

class PaymentStore {
async save(userId: string, result: PaymentCalculationResult) {
return db.payments.insert({
userId,
amount: result.amount,
fee: result.fee,
finalAmount: result.finalAmount,
status: 'done',
})
}
}

class PaymentResponseBuilder {
build(savedPayment: SavedPayment) {
return {
id: savedPayment.id,
amount: savedPayment.amount,
fee: savedPayment.fee,
finalAmount: savedPayment.finalAmount,
message: 'پرداخت با موفقیت انجام شد',
}
}
}

اکنون می‌توانیم هماهنگ‌کننده‌ی اصلی را ساده‌تر نگه داریم:

class PaymentService {
constructor(
private readonly calculator: PaymentCalculator,
private readonly store: PaymentStore,
private readonly responseBuilder: PaymentResponseBuilder,
) {}

async process(payment: Payment) {
const result = this.calculator.calculate(payment)
const savedPayment = await this.store.save(payment.userId, result)

return this.responseBuilder.build(savedPayment)
}
}

در این نسخه، هنوز همه‌چیز به هم مربوط است؛ پرداخت بدون محاسبه، ذخیره‌سازی و پاسخ کامل نمی‌شود. اما دلیل تغییر هر بخش روشن‌تر شده است.

اگر قانون مالی تغییر کند، بیشتر به PaymentCalculator مربوط است. اگر قرارداد خروجی تغییر کند، بیشتر به PaymentResponseBuilder مربوط است. اگر پایگاه داده یا روش ذخیره‌سازی تغییر کند، بیشتر PaymentStore درگیر می‌شود.

اطلاع

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

آیا همیشه باید جدا کنیم؟

نه. این‌جا همان نقطه‌ای است که طراحی خوب از طراحی نمایشی جدا می‌شود.

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

اما اگر نشانه‌های زیر را می‌بینیم، بهتر است مکث کنیم:

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

برای یافتن محور تغییر، از خودتان بپرسید: «چه کسی ممکن است از من بخواهد این کد را تغییر دهم، و چرا؟»

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

مسئولیت یکتا و آزمون‌پذیری

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

این وابستگی‌ها نشانه‌ی خوبی نیستند. آزمون خوب باید تا حد ممکن به همان رفتاری بچسبد که می‌خواهد بررسی کند.

برای نمونه، آزمون قانون مالی می‌تواند فقط محاسبه را بررسی کند:

test('calculates payment fee and final amount', () => {
const calculator = new PaymentCalculator()

const result = calculator.calculate({
userId: 'user-1',
amount: 100_000,
})

expect(result).toEqual({
amount: 100_000,
fee: 1_000,
finalAmount: 99_000,
})
})

این آزمون نه به پایگاه داده نیاز دارد، نه به قالب پاسخ. چون محور تغییر آن فقط قانون مالی است.

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

test('builds payment response', () => {
const builder = new PaymentResponseBuilder()

const response = builder.build({
id: 'payment-1',
amount: 100_000,
fee: 1_000,
finalAmount: 99_000,
})

expect(response.message).toBe('پرداخت با موفقیت انجام شد')
expect(response.finalAmount).toBe(99_000)
})

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

ساختار پوشه هم باید از محور تغییر پیروی کند

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

payments/
controllers/
services/
repositories/
utils/

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

payments/
calculation/
payment-calculator.ts
payment-calculator.test.ts
persistence/
payment-store.ts
presentation/
payment-response-builder.ts
payment-service.ts

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

هشدار

نباید اصل مسئولیت یکتا را با «لایه‌بندی مکانیکی» اشتباه بگیریم. اینکه هر چیزی را به controller، service و repository تقسیم کنیم، به‌تنهایی طراحی خوب نمی‌سازد. اگر مرزها بر اساس دلیل تغییر نباشند، فقط پیچیدگی را جابه‌جا کرده‌ایم.

پیشنهاد عملی

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

چیزهایی که باید بررسی شوند:

  • این کلاس یا ماژول به چه دلیل‌هایی در چند ماه گذشته تغییر کرده است؟
  • چه تیم‌ها یا نقش‌هایی درخواست تغییر روی آن داده‌اند؟
  • آیا تغییر در قانون کسب‌وکار باعث تغییر در قالب خروجی یا ذخیره‌سازی شده است؟
  • آیا آزمون‌ها به خاطر جزئیاتی می‌شکنند که به موضوع اصلی آزمون ربطی ندارد؟
  • آیا می‌توان کار این بخش را با یک جمله‌ی دقیق توضیح داد؟

چیزهایی که نباید بی‌دلیل تغییر کنند:

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

برای اعتبارسنجی تغییر، بسته به پروژه، دست‌کم این گام‌ها را اجرا کنید:

npm test
npm run lint
npm run typecheck

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

جمع‌بندی

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

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

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