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

5 پست با برچسب "SOLID"

یادداشت‌هایی درباره اصول SOLID و نقد کاربرد آن‌ها در طراحی نرم‌افزار

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

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

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

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

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

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

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 وابسته است، هم به یک کارخواه وب. یعنی مهم‌ترین بخش تصمیم‌گیری سیستم، به چیزهایی وصل شده که در اصل فقط ابزار اجرا هستند.

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

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

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

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

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

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

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 ReceiptStore {
save(receipt: Receipt): void
}

حالا یک پیاده‌سازی معمولی داریم:

class DatabaseReceiptStore implements ReceiptStore {
save(receipt: Receipt) {
db.receipts.insert(receipt)
}
}

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

class ReadOnlyReceiptStore implements ReceiptStore {
save(receipt: Receipt) {
throw new Error('This store is read-only')
}
}

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

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

وقتی هر قابلیت تازه یعنی دست زدن به کدهای قدیمی

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

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

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

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

function calculateFee(transaction: Transaction) {
if (transaction.type === 'deposit') {
return 0
}

if (transaction.type === 'withdraw') {
return transaction.amount * 0.01
}

if (transaction.type === 'transfer') {
return 500
}

if (transaction.type === 'refund') {
return 0
}
}

در یک فایل دیگر هم همین الگو تکرار شده است:

function buildReportRow(transaction: Transaction) {
if (transaction.type === 'deposit') {
return {title: 'واریز', sign: '+'}
}

if (transaction.type === 'withdraw') {
return {title: 'برداشت', sign: '-'}
}

if (transaction.type === 'transfer') {
return {title: 'انتقال', sign: '-'}
}

if (transaction.type === 'refund') {
return {title: 'برگشت وجه', sign: '+'}
}
}

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

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

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

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

فرض کنید در یک سامانه‌ی مالی، کلاسی داریم که در نگاه نخست کار ساده‌ای انجام می‌دهد: اطلاعات یک پرداخت را می‌گیرد و آن را پردازش می‌کند. اما کمی که به درونش نگاه می‌کنیم، می‌بینیم این کلاس هم قانون مالی را محاسبه می‌کند، هم خروجی رابط برنامه‌نویسی کاربردی (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: 'پرداخت با موفقیت انجام شد',
}
}
}

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