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

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

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

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

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

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

فصل پنجم کتاب معماری تمیز (Clean Architecture) درباره‌ی برنامه‌نویسی شی‌گرا (Object-Oriented Programming) است، اما برداشت آن از شی‌گرایی با برداشت رایج «کلاس بساز، شیء بساز، متد بنویس» فرق دارد. در نگاه معماری، قدرت مهم شی‌گرایی فقط در بسته‌بندی داده و رفتار نیست. نقطه‌ی مهم‌تر، چندریختی و توان کنترل جهت وابستگی‌هاست.

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

کلاس‌محوری سطحی

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

مشکل از کجاست؟

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

برای نمونه، چنین کدی ممکن است در پروژه‌ای ظاهراً شی‌گرا دیده شود:

class OrderService {
async createOrder(request) {
const user = await Database.users.findById(request.userId);

if (!user.isActive) {
throw new HttpError(400, 'کاربر فعال نیست.');
}

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

await SmsClient.send(user.phoneNumber, 'سفارش شما ثبت شد.');

return order;
}
}

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

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

چندریختی چرا برای معماری مهم است؟

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

چندریختی در نگاه معماری

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

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

برای نمونه، کاربرد ثبت سفارش می‌تواند به جای شناختن SmsClient و Database، با قراردادهای کوچک کار کند:

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

type OrderRepository = {
create(input: CreateOrderInput): Promise<Order>;
};

type NotificationPort = {
sendOrderCreated(userId: string, orderId: string): Promise<void>;
};

بعد کاربرد اصلی فقط این قراردادها را می‌شناسد:

class CreateOrderUseCase {
constructor(
private readonly users: UserRepository,
private readonly orders: OrderRepository,
private readonly notifications: NotificationPort,
) {}

async execute(input: CreateOrderInput) {
const user = await this.users.findById(input.userId);

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

const order = await this.orders.create({
userId: user.id,
items: input.items,
});

await this.notifications.sendOrderCreated(user.id, order.id);

return {id: order.id};
}
}

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

class PostgresOrderRepository implements OrderRepository {
async create(input: CreateOrderInput) {
return postgres.orders.insert(input);
}
}

class SmsNotificationPort implements NotificationPort {
async sendOrderCreated(userId: string, orderId: string) {
await smsClient.send(userId, `سفارش ${orderId} ثبت شد.`);
}
}

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

وارونه‌کردن وابستگی یعنی چه؟

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

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

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

کلاس بدون مرز، معماری نمی‌سازد

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

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

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

نمونه‌ی آزمایشی ساده

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

const users: UserRepository = {
async findById() {
return {id: 'u1', isActive: true};
},
};

const orders: OrderRepository = {
async create(input) {
return {id: 'o1', ...input};
},
};

const notifications: NotificationPort = {
async sendOrderCreated() {},
};

const useCase = new CreateOrderUseCase(users, orders, notifications);
const result = await useCase.execute({userId: 'u1', items: []});

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

طراحی مرز از کجا شروع می‌شود؟

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

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

طراحی مرز

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

پیشنهاد عملی

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

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

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

pnpm test
pnpm typecheck
pnpm build

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

جمع‌بندی

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

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

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