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

اینترفیس بزرگ، کلاس کوچک را هم آلوده می‌کند

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

یک پریز بزرگ با کابل‌های زیاد که یک دستگاه کوچک فقط به یکی از آن‌ها نیاز دارد.

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

برای نمونه، یک سرویس داریم که فقط می‌خواهد یک تراکنش را بر اساس شناسه پیدا کند:

interface TransactionRepository {
findById(id: string): Promise<Transaction | null>
save(transaction: Transaction): Promise<void>
delete(id: string): Promise<void>
exportDailyReport(date: string): Promise<ReportFile>
rebuildBalances(): Promise<void>
closeMonth(month: string): Promise<void>
archiveOldTransactions(before: string): Promise<number>
}

و مصرف‌کننده‌ی ما فقط همین را لازم دارد:

class TransactionDetailsService {
constructor(
private readonly repository: TransactionRepository,
) {}

async getDetails(id: string) {
return this.repository.findById(id)
}
}

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

این‌جا پای یکی از مهم‌ترین اصل‌های طراحی به میان می‌آید: اصل جداسازی اینترفیس.

اطلاع

اصل جداسازی اینترفیس (Interface Segregation Principle) می‌گوید مصرف‌کننده نباید به متدهایی وابسته شود که به آن‌ها نیازی ندارد.

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

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

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

این وابستگی پنهان معمولاً سه پیامد مهم دارد:

  1. تغییر پرهزینه‌تر می‌شود.
  2. آزمون‌نویسی سخت‌تر می‌شود.
  3. مرز مسئولیت‌ها مبهم‌تر می‌شود.

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

وابستگی پنهان چگونه ساخته می‌شود؟

در مثال بالا، TransactionDetailsService فقط به findById نیاز دارد. اما چون این متد در دل یک اینترفیس بزرگ قرار گرفته، هر تغییری در تعریف آن اینترفیس می‌تواند روی این سرویس هم اثر بگذارد، حتی اگر آن تغییر هیچ ربط مستقیمی به نیاز این سرویس نداشته باشد.

برای نمونه، فرض کنید تیم دیگری تصمیم بگیرد exportDailyReport باید یک پارامتر تازه بگیرد، یا closeMonth قرارداد متفاوتی پیدا کند. اگر در پروژه‌ی شما پیاده‌سازی‌های آزمایشی، ماک‌ها، یا کلاس‌های ساختگی بر اساس این اینترفیس ساخته شده باشند، ناگهان مصرف‌کننده‌ای که فقط findById می‌خواست هم درگیر می‌شود.

یعنی تغییر در بخشی که برای او بی‌ربط است، او را هم تکان می‌دهد.

هشدار

اینترفیس چاق، فقط یک قرارداد بزرگ نیست؛ یک سطح پخش وابستگی است.

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

آزمون‌ها چرا سخت‌تر می‌شوند؟

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

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

مثلاً ممکن است در آزمون مجبور شویم چنین چیزی بسازیم:

class FakeTransactionRepository implements TransactionRepository {
async findById(id: string) {
return {id, amount: 1000}
}

async save(transaction: Transaction) {
throw new Error('not implemented')
}

async delete(id: string) {
throw new Error('not implemented')
}

async exportDailyReport(date: string) {
throw new Error('not implemented')
}

async rebuildBalances() {
throw new Error('not implemented')
}

async closeMonth(month: string) {
throw new Error('not implemented')
}

async archiveOldTransactions(before: string) {
throw new Error('not implemented')
}
}

این کد بوی خوبی نمی‌دهد. چرا برای آزمون سرویسی که فقط یک داده را می‌خواند، باید شش متد بی‌ربط دیگر را هم پر کنیم؟ حتی اگر با throw new Error پر شوند، باز هم نشان می‌دهد که قرارداد ما برای این مصرف‌کننده بیش از حد بزرگ است.

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

تغییر پرهزینه‌تر یعنی چه؟

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

اگر اینترفیس بزرگ باشد، هر بار که یکی از متدهای آن تغییر می‌کند، باید این چیزها را دوباره بررسی کنیم:

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

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

یک مثال واقعی‌تر: وابستگی مدیریتی در یک سرویس کوچک

فرض کنید یک کنترلر وب فقط می‌خواهد وضعیت یک کاربر را نشان دهد:

interface UserService {
getUserProfile(userId: string): Promise<UserProfile>
updateUserProfile(userId: string, data: UpdateProfileInput): Promise<void>
resetPassword(userId: string): Promise<void>
suspendUser(userId: string): Promise<void>
exportUsersCsv(): Promise<string>
rebuildSearchIndex(): Promise<void>
}

کنترلر فقط از این استفاده می‌کند:

class UserProfileController {
constructor(private readonly userService: UserService) {}

async show(userId: string) {
return this.userService.getUserProfile(userId)
}
}

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

راه بهتر: اینترفیس را از نگاه مصرف‌کننده ببینیم

راه‌حل معمولاً این نیست که صرفاً اینترفیس را «تمیزتر» کنیم یا متدها را گروه‌بندی ظاهری بدهیم. راه‌حل اصلی این است که اینترفیس را از زاویه‌ی مصرف‌کننده ببینیم.

در مثال نخست، اگر TransactionDetailsService فقط به خواندن تراکنش نیاز دارد، شاید این قرارداد برای او کافی باشد:

interface TransactionReader {
findById(id: string): Promise<Transaction | null>
}

و اگر بخشی دیگر مسئول ذخیره‌سازی است، قرارداد خودش را داشته باشد:

interface TransactionWriter {
save(transaction: Transaction): Promise<void>
delete(id: string): Promise<void>
}

قابلیت‌های مدیریتی یا پردازش‌های سنگین هم می‌توانند اینترفیس‌های جداگانه‌ی خودشان را داشته باشند:

interface TransactionReportExporter {
exportDailyReport(date: string): Promise<ReportFile>
}

interface TransactionMaintenanceService {
rebuildBalances(): Promise<void>
closeMonth(month: string): Promise<void>
archiveOldTransactions(before: string): Promise<number>
}

حالا سرویس جزئیات فقط به چیزی وابسته می‌شود که واقعاً لازم دارد:

class TransactionDetailsService {
constructor(
private readonly reader: TransactionReader,
) {}

async getDetails(id: string) {
return this.reader.findById(id)
}
}

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

این اصل با اصل مسئولیت یکتا هم‌خانواده است

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

هر دو اصل، در نهایت دارند از یک چیز محافظت می‌کنند: مرز روشن.

وقتی مرزها روشن باشند:

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

آیا همیشه باید اینترفیس را خرد کنیم؟

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

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

پس معیار، صرفاً تعداد متدها نیست. معیار این است که آیا مصرف‌کننده‌های متفاوت، زیرمجموعه‌های متفاوتی از متدها را لازم دارند یا نه.

اگر پاسخ مثبت است، احتمال خوبی دارد که اینترفیس بیش از حد پهن شده باشد.

یک نشانه‌ی مهم: متدهای not implemented

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

throw new Error('not implemented')

یا:

throw new Error('not supported')

یا حتی:

return null

فقط برای اینکه امضای اینترفیس را کامل کرده باشند.

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

یک نمونه از شکستن درست اینترفیس

فرض کنید ابتدا چنین ساختاری داشتیم:

transactions/
transaction-repository.ts
transaction-details-service.ts
daily-report-service.ts
month-close-service.ts

و transaction-repository.ts همه‌چیز را در خودش جمع کرده بود.

بعد از بازنگری، شاید چنین ساختاری روشن‌تر باشد:

transactions/
contracts/
transaction-reader.ts
transaction-writer.ts
transaction-report-exporter.ts
transaction-maintenance-service.ts
services/
transaction-details-service.ts
daily-report-service.ts
month-close-service.ts

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

نکته

برای شکستن اینترفیس، از این پرسش شروع کنید: «این مصرف‌کننده دقیقاً کدام متدها را لازم دارد؟»

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

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

آزمون بعد از جداسازی، چه‌قدر ساده‌تر می‌شود؟

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

class FakeTransactionReader implements TransactionReader {
async findById(id: string) {
return {id, amount: 1000}
}
}

و خود آزمون هم سرراست‌تر می‌شود:

test('returns transaction details by id', async () => {
const reader = new FakeTransactionReader()
const service = new TransactionDetailsService(reader)

const result = await service.getDetails('tx-1')

expect(result).toEqual({id: 'tx-1', amount: 1000})
})

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

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

یادداشت

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

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

پیشنهاد عملی

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

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

  • آیا یک کلاس فقط از بخش کوچکی از یک اینترفیس استفاده می‌کند؟
  • آیا در آزمون‌ها مجبور می‌شوید چند متد بی‌ربط را ساختگی پیاده‌سازی کنید؟
  • آیا پیاده‌سازی‌ها بعضی متدها را با not implemented یا not supported پر می‌کنند؟
  • آیا تغییر در یک متد کم‌اهمیت، روی مصرف‌کننده‌های نامرتبط هم اثر می‌گذارد؟
  • آیا می‌توان قرارداد را بر اساس الگوی مصرف به چند بخش منسجم‌تر شکست؟

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

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

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

npm test
npm run lint
npm run typecheck

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

جمع‌بندی

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

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

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