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

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

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

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

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

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

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

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

این همان جایی است که اصل وارونگی وابستگی وارد می‌شود.

یادداشت

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

برای نمونه، این‌ها معمولاً سیاست سطح بالا هستند:

  • شرط پذیرش یا رد یک عملیات
  • قواعد قیمت‌گذاری، کارمزد، اعتبارسنجی و مجوز
  • جریان اصلی یک مورد کاربرد
  • منطق اصلی‌ای که اگر ابزارها عوض شوند، هنوز باید پابرجا بماند

در مقابل، چیزهایی مثل دیتابیس، HTTP، صف پیام، ORM، یا چارچوب وب، معمولاً جزئیات سطح پایین‌اند.

مسئله دقیقاً چیست؟

در مثال بالا، WithdrawService قرار است یک تصمیم مهم بگیرد: آیا برداشت مجاز است یا نه؟ این همان بخش مهم و پایدار سیستم است. اما برای انجام این کار، مستقیماً به PrismaClient و axios چسبیده است.

این وابستگی چند دردسر می‌سازد:

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

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

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

اصل وارونگی وابستگی (Dependency Inversion Principle) می‌گوید:

  • ماژول‌های سطح بالا نباید به ماژول‌های سطح پایین وابسته باشند.
  • هر دو باید به انتزاع وابسته باشند.
  • انتزاع نباید به جزئیات وابسته باشد.
  • جزئیات باید به انتزاع وابسته باشند.

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

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

برگرداندن جهت وابستگی

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

interface UserBalanceReader {
findById(userId: string): Promise<User | null>
}

interface WithdrawRequestGateway {
submit(userId: string, amount: number): Promise<void>
saveRequest(userId: string, amount: number): Promise<void>
}

حالا سیاست اصلی فقط به این انتزاع‌ها وابسته می‌شود:

class WithdrawService {
constructor(
private readonly userBalanceReader: UserBalanceReader,
private readonly withdrawRequestGateway: WithdrawRequestGateway,
) {}

async withdraw(userId: string, amount: number) {
const user = await this.userBalanceReader.findById(userId)

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

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

await this.withdrawRequestGateway.submit(userId, amount)
await this.withdrawRequestGateway.saveRequest(userId, amount)
}
}

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

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

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

const prisma = new PrismaClient()

class PrismaUserBalanceReader implements UserBalanceReader {
async findById(userId: string) {
return prisma.user.findUnique({
where: {id: userId},
})
}
}

class WalletWithdrawGateway implements WithdrawRequestGateway {
async submit(userId: string, amount: number) {
await axios.post('https://wallet-service.example.com/withdraw', {
userId,
amount,
})
}

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

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

این همان وارونگی وابستگی است.

هشدار

یکی از خطاهای رایج این است که سیاست‌های اصلی سیستم را مستقیماً به ابزارها گره بزنیم؛ مثلاً:

  • سرویس کسب‌وکار مستقیم ORM را صدا بزند
  • منطق اصلی مستقیم درخواست HTTP بفرستد
  • قانون‌های کسب‌وکار مستقیم به چارچوب وب یا صف پیام وابسته باشند

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

چرا این اصل مهم است؟

شاید در پروژه‌ی کوچک، این‌همه مرزبندی اضافه به نظر برسد. اما وقتی سیستم رشد می‌کند، اهمیت این اصل روشن می‌شود.

۱. تغییر ابزار، قانون اصلی را تکان نمی‌دهد

اگر امروز از Prisma استفاده می‌کنید و فردا سراغ ابزار دیگری می‌روید، یا اگر امروز با HTTP به یک سرویس بیرونی وصل می‌شوید و فردا از صف پیام استفاده می‌کنید، نباید لازم باشد قانون برداشت را بازنویسی کنید.

سیاست اصلی باید تا حد ممکن از این تغییرها مصون بماند.

۲. آزمون‌پذیری بهتر می‌شود

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

class FakeUserBalanceReader implements UserBalanceReader {
async findById(userId: string) {
return {id: userId, balance: 1_000_000}
}
}

class FakeWithdrawRequestGateway implements WithdrawRequestGateway {
submitted: Array<{userId: string; amount: number}> = []

async submit(userId: string, amount: number) {
this.submitted.push({userId, amount})
}

async saveRequest(userId: string, amount: number) {
return
}
}

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

test('submits withdraw request when balance is sufficient', async () => {
const userReader = new FakeUserBalanceReader()
const gateway = new FakeWithdrawRequestGateway()
const service = new WithdrawService(userReader, gateway)

await service.withdraw('user-1', 100_000)

expect(gateway.submitted).toEqual([
{userId: 'user-1', amount: 100_000},
])
})

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

۳. مرز معماری روشن‌تر می‌شود

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

انتزاع را چه کسی باید تعریف کند؟

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

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

برای نمونه، این قرارداد خوب نیست:

interface PrismaCompatibleRepository {
findUnique(query: unknown): Promise<unknown>
create(data: unknown): Promise<unknown>
}

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

در عوض، این بهتر است:

interface UserBalanceReader {
findById(userId: string): Promise<User | null>
}

چون بر پایه‌ی نیاز کسب‌وکار تعریف شده، نه بر پایه‌ی شکل ابزار.

این اصل با معماری تمیز چه نسبتی دارد؟

اصل وارونگی وابستگی یکی از ستون‌های اصلی معماری تمیز (Clean Architecture) است. در معماری تمیز، لایه‌های درونی که منطق اصلی سیستم را در خود دارند، نباید به لایه‌های بیرونی وابسته باشند. لایه‌های بیرونی باید خودشان را با نیاز هسته سازگار کنند.

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

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

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

نه. این هم یکی از جاهایی است که می‌شود افراط کرد.

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

این اصل زمانی بیشترین ارزش را دارد که:

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

پس هدف، انتزاع‌سازی افراطی نیست؛ هدف، محافظت از هسته‌ی مهم سیستم در برابر جزئیات ناپایدار است.

نکته

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

درگاه خوب باید:

  • زبان نیاز کسب‌وکار را بازتاب دهد
  • کمینه و متمرکز باشد
  • بوی ابزار خاص ندهد
  • مصرف‌کننده را به جزئیات اجرایی آلوده نکند

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

یک نمونه از ساختار پوشه

اگر این مرزبندی را در ساختار پروژه نشان دهیم، شاید چیزی شبیه این داشته باشیم:

withdraw/
application/
withdraw-service.ts
user-balance-reader.ts
withdraw-request-gateway.ts
infrastructure/
prisma-user-balance-reader.ts
wallet-withdraw-gateway.ts

در این ساختار:

  • application محل منطق اصلی و قراردادهای موردنیاز آن است.
  • infrastructure محل پیاده‌سازی‌های وابسته به ابزارهاست.

این یعنی هسته‌ی سیستم می‌گوید «من چه می‌خواهم»، و زیرساخت می‌گوید «من آن را چگونه اجرا می‌کنم».

پیشنهاد عملی

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

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

  • آیا یک سرویس کسب‌وکار مستقیم ORM، HTTP client، صف پیام یا چارچوب وب را صدا می‌زند؟
  • آیا تغییر ابزارهای زیرساختی، فایل‌های مربوط به قانون‌های اصلی را هم مجبور به تغییر می‌کند؟
  • آیا آزمون منطق اصلی بدون راه‌اندازی ابزارهای بیرونی سخت است؟
  • آیا می‌توان نیاز واقعی سیاست سطح بالا را با یک درگاه کوچک و روشن بیان کرد؟
  • آیا انتزاع‌های فعلی واقعاً زبان کسب‌وکار دارند یا فقط شکل ابزار را پنهان کرده‌اند؟

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

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

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

npm test
npm run lint
npm run typecheck

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

جمع‌بندی

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

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

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