تفکیک UI، Business Logic و Data Access؛ راهنمای معماری پروژه‌های React و Next.js

تفکیک UI، Business Logic و Data Access؛ راهنمای معماری پروژه‌های React و Next.js

توسعه‌دهنده فرانت‌اند

چرا این تفکیک در پروژه‌های بزرگ اهمیت دارد؟

تفکیک UI، Business Logic و Data Access؛ راهنمای معماری پروژه‌های React و Next.js

صارم توکلی

۱۲ دقیقه مطالعه

۰ نفر

۱۴۰۵/۶/۲۹

تفکیک UI، Business Logic و Data Access؛ راهنمای معماری پروژه‌های React و Next.js

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

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

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

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

راه‌حل، فقط کوچک‌تر کردن کامپوننت‌ها نیست.

یکی از اصول مهم در معماری Frontend، تفکیک مسئولیت‌ها یا Separation of Concerns است؛ یعنی هر بخش از برنامه باید مسئول نوع مشخصی از تصمیم‌ها باشد.

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

UI → Business Logic → Data Access

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


چرا این تفکیک در پروژه‌های بزرگ اهمیت دارد؟

فرض کنید یک فروشگاه اینترنتی با React یا Next.js ساخته‌ایم.

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

در ظاهر اتفاق ساده‌ای است، اما پشت این دکمه ممکن است چند قانون وجود داشته باشد:

  • آیا محصول موجود است؟

  • آیا کاربر اجازه خرید دارد؟

  • آیا تعداد انتخاب‌شده بیشتر از موجودی است؟

  • آیا محصول محدودیت خرید دارد؟

  • آیا تخفیف خاصی باید اعمال شود؟

  • آیا سبد خرید قبلی باید به‌روزرسانی شود؟

  • آیا اطلاعات باید به Server ارسال شود؟

اگر تمام این موارد را داخل onClick یک Button یا داخل کامپوننت Product قرار دهیم، UI به مرکز تمام تصمیم‌های برنامه تبدیل می‌شود.

مثلاً:

const handleAddToCart = async () => {
  if (product.stock === 0) {
    toast.error("محصول موجود نیست");
    return;
  }

  if (quantity > product.maxPurchase) {
    toast.error("تعداد انتخاب‌شده مجاز نیست");
    return;
  }

  await fetch("/api/cart", {
    method: "POST",
    body: JSON.stringify({
      productId: product.id,
      quantity,
    }),
  });

  setCartUpdated(true);
};

این کد ممکن است در ابتدا کاملاً قابل قبول باشد.

اما مشکل زمانی ایجاد می‌شود که همین قانون در چند جای دیگر نیز مورد نیاز باشد.

مثلاً:

  • صفحه محصول

  • صفحه سبد خرید

  • پیشنهادهای ویژه

  • پنل مدیریت

  • نسخه موبایل

  • Quick Add

  • خرید سریع

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

اینجاست که معماری اهمیت پیدا می‌کند.


سه لایه اصلی در معماری Frontend

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

┌──────────────────────────────┐
│             UI               │
│   Components / Pages / Forms │
└──────────────┬───────────────┘
               ↓
┌──────────────────────────────┐
│       Business Logic        │
│ Rules / Use Cases / Services │
└──────────────┬───────────────┘
               ↓
┌──────────────────────────────┐
│        Data Access          │
│ API / DB / Storage / SDK    │
└──────────────────────────────┘

البته در پروژه‌های واقعی ممکن است لایه‌های بیشتری مانند Domain، Application، Infrastructure یا Presentation وجود داشته باشد. هدف این تقسیم‌بندی، ایجاد مرز مشخص بین انواع مسئولیت‌هاست، نه اجبار به استفاده از یک ساختار پوشه‌ای خاص.

حالا هر بخش را بررسی کنیم.


۱. UI؛ مسئول نمایش و تعامل با کاربر

UI همان بخشی است که کاربر با آن ارتباط برقرار می‌کند.

در React معمولاً شامل مواردی مانند:

  • Components

  • Pages

  • Forms

  • Buttons

  • Modals

  • Tables

  • Inputs

  • Layouts

  • Visual States

است.

وظیفه اصلی UI این است که اطلاعات را نمایش دهد و تعامل کاربر را دریافت کند.

برای مثال:

function ProductCard({ product, onAddToCart }) {
  return (
    <article>
      <h2>{product.name}</h2>

      <span>{product.price} تومان</span>

      <button onClick={() => onAddToCart(product.id)}>
        افزودن به سبد
      </button>
    </article>
  );
}

این کامپوننت می‌داند:

چگونه محصول را نمایش دهد.

اما بهتر است نداند:

درخواست API چگونه ارسال می‌شود؟

یا:

قانون محدودیت خرید محصول چیست؟

یا:

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

این موارد متعلق به بخش‌های دیگری از معماری هستند.


چه چیزهایی باید در UI قرار بگیرند؟

مواردی مانند:

  • نمایش اطلاعات

  • مدیریت تعاملات کاربر

  • کنترل باز و بسته شدن Modal

  • انتخاب Tab

  • مدیریت Stateهای صرفاً نمایشی

  • نمایش Loading

  • نمایش Error

  • دریافت Props

  • ارسال Event

می‌توانند در این لایه قرار بگیرند.

اما یک نکته مهم وجود دارد.

هر Stateای UI State نیست.

برای مثال:

const [isModalOpen, setIsModalOpen] = useState(false);

یک State مربوط به UI است.

اما:

const [discount, setDiscount] = useState(...);

اگر مقدار Discount نتیجه یک قانون کسب‌وکار باشد، لزوماً نباید مسئولیت محاسبه آن در UI قرار بگیرد.


۲. Business Logic؛ مغز تصمیم‌گیری برنامه

Business Logic شامل قوانینی است که مشخص می‌کنند سیستم چگونه باید رفتار کند.

این بخش به UI وابسته نیست.

برای مثال در فروشگاه:

  • چه کسی اجازه خرید دارد؟

  • چه مقدار محصول قابل خرید است؟

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

  • چه زمانی سفارش قابل لغو است؟

  • چه زمانی سفارش باید پرداخت شود؟

  • چه محصولی مشمول ارسال رایگان می‌شود؟

این‌ها قوانین کسب‌وکار هستند.

مثلاً:

function canAddToCart(
  stock: number,
  quantity: number
) {
  return stock >= quantity;
}

این تابع هیچ ارتباطی با React ندارد.

نه useState دارد، نه JSX و نه onClick.

بنابراین می‌توان آن را مستقل از UI تست و استفاده کرد.


Business Logic نباید به React وابسته باشد

یکی از اصول مهم این است:

Business Logic
      ↓
نباید به
      ↓
React Component
      ↓
وابسته باشد

به عبارت دیگر، این نوع کد:

import { useState } from "react";

function calculateDiscount() {
  ...
}

نشانه خوبی نیست، اگر محاسبه Discount ذاتاً یک قانون کسب‌وکار باشد.

در حالت بهتر:

export function calculateDiscount(
  price: number,
  discountPercent: number
) {
  return price * (discountPercent / 100);
}

و سپس UI فقط از نتیجه استفاده کند.

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


۳. Data Access؛ مسئول ارتباط با منابع داده

Data Access جایی است که برنامه با منابع خارجی ارتباط برقرار می‌کند.

برای مثال:

  • REST API

  • GraphQL

  • Database

  • LocalStorage

  • IndexedDB

  • Cookies

  • Third-party SDK

  • External Services

فرض کنید API مربوط به محصولات این باشد:

export async function getProducts() {
  const response = await fetch("/api/products");

  if (!response.ok) {
    throw new Error("Failed to fetch products");
  }

  return response.json();
}

کامپوننت UI نباید مجبور باشد بداند:

  • URL چیست؟

  • HTTP Method چیست؟

  • Header چیست؟

  • Authorization چگونه انجام می‌شود؟

  • API چگونه خطا می‌دهد؟

این جزئیات باید در Data Access مدیریت شوند.


تفاوت Business Logic و Data Access چیست؟

این دو بخش معمولاً با یکدیگر اشتباه گرفته می‌شوند.

فرض کنید می‌خواهیم سفارش کاربر را ثبت کنیم.

Data Access

می‌گوید:

چگونه اطلاعات سفارش را به Server ارسال کنم؟

مثلاً:

await api.post("/orders", order);

Business Logic

می‌گوید:

آیا این سفارش اصلاً قابل ثبت است؟

مثلاً:

if (cart.items.length === 0) {
  throw new Error("Cart is empty");
}

پس:

Data Access = چگونه با داده ارتباط برقرار کنیم؟

Business Logic = چه تصمیمی باید گرفته شود؟

این تفاوت یکی از مهم‌ترین مرزهای معماری Frontend است.


یک مثال واقعی در React

فرض کنیم کاربر روی دکمه «خرید» کلیک می‌کند.

معماری نامناسب ممکن است چنین چیزی باشد:

function BuyButton({ product }) {
  const handleBuy = async () => {
    if (!product.stock) {
      alert("محصول موجود نیست");
      return;
    }

    if (product.price < 100000) {
      // business rule
    }

    const response = await fetch("/api/orders", {
      method: "POST",
      body: JSON.stringify({
        productId: product.id,
      }),
    });

    // handle response
  };

  return (
    <button onClick={handleBuy}>
      خرید
    </button>
  );
}

در اینجا سه مسئولیت داخل یک کامپوننت قرار گرفته‌اند:

UI
+
Business Logic
+
Data Access

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


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

می‌توانیم این مسئولیت‌ها را جدا کنیم.

UI

function BuyButton({ onBuy }) {
  return (
    <button onClick={onBuy}>
      خرید
    </button>
  );
}

Business Logic

export function validatePurchase(product) {
  if (product.stock <= 0) {
    throw new Error("محصول موجود نیست");
  }

  return true;
}

Data Access

export async function createOrder(productId: string) {
  return api.post("/orders", {
    productId,
  });
}

و در یک لایه Application یا Use Case:

export async function purchaseProduct(product) {
  validatePurchase(product);

  return createOrder(product.id);
}

در این ساختار هر بخش وظیفه مشخصی دارد.


جریان صحیح داده چگونه است؟

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

User
 ↓
UI
 ↓
Use Case / Business Logic
 ↓
Data Access
 ↓
API
 ↓
Server

و پاسخ:

Server
 ↓
Data Access
 ↓
Business / Application
 ↓
UI
 ↓
User

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


در Next.js وضعیت کمی متفاوت است

در Next.js مدرن، مخصوصاً با App Router، مرز Server و Client نیز اهمیت پیدا می‌کند.

در Next.js، صفحات و Layoutها به‌صورت پیش‌فرض Server Component هستند و Client Componentها زمانی استفاده می‌شوند که به State، Event Handler، Lifecycle یا Browser API نیاز داشته باشیم.

بنابراین معماری پروژه فقط به این سه سؤال محدود نمی‌شود:

UI کجاست؟
Business Logic کجاست؟
Data Access کجاست؟

بلکه باید سؤال دیگری هم اضافه کنیم:

این منطق باید روی Server اجرا شود یا Client؟

برای مثال:

Server

  • دریافت اطلاعات از Database

  • دسترسی به Secretها

  • دریافت داده از APIهای داخلی

  • پردازش‌هایی که نباید به Client منتقل شوند

Client

  • تعاملات کاربر

  • Stateهای تعاملی

  • Browser API

  • بعضی سناریوهای Client-side Data Fetching

مستندات فعلی Next.js نیز بر استفاده از Server Components برای بسیاری از سناریوهای دریافت داده و Client Components برای تعاملات و قابلیت‌های مرورگر تأکید می‌کند.


آیا API Call باید داخل Component باشد؟

در پروژه‌های کوچک ممکن است چنین چیزی قابل قبول باشد:

const response = await fetch("/api/products");

اما وقتی تعداد درخواست‌ها زیاد می‌شود، پراکنده شدن API Callها در Componentها می‌تواند نگهداری پروژه را دشوار کند.

برای مثال:

ProductCard.tsx
ProductPage.tsx
Cart.tsx
Dashboard.tsx
Profile.tsx

اگر هرکدام مستقیماً با API ارتباط داشته باشند، Data Access در سراسر UI پخش می‌شود.

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

features/
└── products/
    ├── ui/
    ├── services/
    ├── repositories/
    ├── hooks/
    ├── types/
    └── utils/

برای پروژه‌های بزرگ‌تر می‌توان این ساختار را بر اساس Feature یا Domain توسعه داد.

اصل مهم، نام پوشه نیست؛ مرز مسئولیت‌هاست.


Repository Pattern چه کمکی می‌کند؟

یکی از روش‌های متداول برای جدا کردن Data Access استفاده از Repository است.

مثلاً:

interface ProductRepository {
  getById(id: string): Promise<Product>;
  getAll(): Promise<Product[]>;
}

سپس یک پیاده‌سازی برای API:

class ApiProductRepository
  implements ProductRepository {

  async getById(id: string) {
    return api.get(`/products/${id}`);
  }

  async getAll() {
    return api.get("/products");
  }
}

حالا Business Logic به جای اینکه مستقیماً به fetch وابسته باشد، با یک قرارداد مشخص کار می‌کند.

این ایده باعث می‌شود جزئیات منبع داده از منطق برنامه جدا شود؛ Repository در معماری‌های لایه‌ای معمولاً به‌عنوان مرزی برای دسترسی به منابع خارجی استفاده می‌شود.


Custom Hook دقیقاً کجای معماری قرار می‌گیرد؟

یکی از اشتباهات رایج این است که تصور کنیم:

هر چیزی که داخل Hook قرار بگیرد، Business Logic است.

این‌طور نیست.

مثلاً:

function useModal() {
  const [open, setOpen] = useState(false);

  return {
    open,
    openModal: () => setOpen(true),
    closeModal: () => setOpen(false),
  };
}

این بیشتر UI/Application State Logic است.

اما:

function calculateOrderTotal(items) {
  ...
}

اگر یک قانون واقعی کسب‌وکار باشد، بهتر است مستقل از React باشد.

پس Hook همیشه محل Business Logic نیست.


چه زمانی تفکیک بیش از حد است؟

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

برای یک کامپوننت ساده:

function Button() {
  return <button>ثبت</button>;
}

ساختن پنج فایل برای:

ButtonService
ButtonRepository
ButtonUseCase
ButtonController
ButtonAdapter

معمولاً ارزش واقعی ایجاد نمی‌کند.

معماری خوب یعنی:

به اندازه پیچیدگی پروژه، مرز ایجاد کنیم.

نه اینکه برای هر خط کد یک Abstraction بسازیم.


نشانه‌های یک معماری نامناسب

اگر در پروژه خود این موارد را زیاد می‌بینید، احتمالاً مرز مسئولیت‌ها به‌خوبی تعریف نشده است:

۱. Componentهای بسیار بزرگ

کامپوننت‌هایی با صدها خط کد که هم UI، هم API و هم Business Logic را مدیریت می‌کنند.

۲. API Callهای پراکنده

هر صفحه روش متفاوتی برای دریافت یک نوع داده دارد.

۳. قوانین تکراری

یک Rule در چند Component تکرار شده است.

۴. Hookهای بسیار سنگین

یک Hook هم API می‌زند، هم Business Logic دارد، هم State مدیریت می‌کند و هم UI behavior را کنترل می‌کند.

۵. تغییر API باعث تغییر UI می‌شود

مثلاً تغییر ساختار Response API باعث می‌شود تعداد زیادی Component تغییر کنند.

۶. تست کردن Business Logic سخت است

برای تست یک قانون ساده مجبور هستیم Component را Render کنیم.


یک ساختار پیشنهادی برای پروژه React / Next.js

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

src/
│
├── features/
│   └── orders/
│       │
│       ├── ui/
│       │   ├── OrderCard.tsx
│       │   ├── OrderList.tsx
│       │   └── OrderForm.tsx
│       │
│       ├── application/
│       │   ├── createOrder.ts
│       │   └── cancelOrder.ts
│       │
│       ├── domain/
│       │   ├── order.ts
│       │   └── orderRules.ts
│       │
│       ├── data/
│       │   ├── orderApi.ts
│       │   └── orderRepository.ts
│       │
│       ├── hooks/
│       │   └── useOrder.ts
│       │
│       └── types/
│           └── order.types.ts
│
├── components/
│   └── ui/
│
├── lib/
│   ├── api/
│   └── utils/
│
└── app/

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


یک قانون ساده برای تشخیص محل کد

هنگام نوشتن یک قطعه کد از خودتان بپرسید:

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

اگر بله:

UI

آیا این کد درباره تصمیم‌ها و قوانین سیستم است؟

اگر بله:

Business Logic / Domain

آیا این کد درباره دریافت یا ذخیره اطلاعات است؟

اگر بله:

Data Access

آیا این کد جریان اجرای یک عملیات را هماهنگ می‌کند؟

اگر بله:

Application / Use Case

این چهار سؤال در بسیاری از پروژه‌ها می‌توانند جلوی مخلوط شدن مسئولیت‌ها را بگیرند.


مزایای تفکیک UI، Business Logic و Data Access

نگهداری ساده‌تر

وقتی هر مسئولیت جای مشخصی داشته باشد، پیدا کردن کد موردنظر ساده‌تر می‌شود.

تست‌پذیری بهتر

Business Logic مستقل از React را می‌توان بدون Render کردن Component تست کرد.

استفاده مجدد

یک Rule کسب‌وکار می‌تواند توسط چند UI مختلف استفاده شود.

تغییر آسان‌تر تکنولوژی

اگر Data Access از UI جدا باشد، تغییر API Client یا ساختار ارتباط با Server ساده‌تر خواهد بود.

کاهش وابستگی

Component نباید به جزئیات Backend وابسته باشد.

توسعه تیمی بهتر

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

این هدف با اصول معماری لایه‌ای و Separation of Concerns هم‌راستا است؛ جداسازی منطق کسب‌وکار از UI همچنین امکان استفاده و تست مستقل‌تر این منطق را فراهم می‌کند.


آیا این معماری فقط برای React است؟

خیر.

این مفهوم محدود به React نیست.

می‌توان همین تفکیک را در:

  • Vue

  • Angular

  • React Native

  • Flutter

  • اپلیکیشن‌های موبایل

  • Desktop Applications

  • Backend Applications

نیز استفاده کرد.

آنچه تغییر می‌کند، ابزار و نحوه پیاده‌سازی است؛ اما اصل Separation of Concerns همچنان قابل استفاده است.


جمع‌بندی

در پروژه‌های کوچک، مخلوط کردن UI، Business Logic و Data Access ممکن است مشکل بزرگی ایجاد نکند.

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

معماری مناسب الزاماً به معنی استفاده از ده‌ها لایه و فایل نیست.

اصل ساده‌تر است:

UI باید روی نمایش و تعامل تمرکز کند.

Business Logic باید قوانین و تصمیم‌های سیستم را مدیریت کند.

Data Access باید مسئول ارتباط با منابع داده باشد.

و در پروژه‌های پیچیده‌تر، یک لایه Application یا Use Case می‌تواند جریان اجرای عملیات را بین این بخش‌ها هماهنگ کند.

در Next.js نیز باید مرز Server و Client را در کنار این تفکیک در نظر گرفت؛ Server Components می‌توانند برای بسیاری از سناریوهای دریافت داده و رندر سمت سرور استفاده شوند، در حالی که Client Components برای تعامل، State و Browser APIها مناسب‌اند.

در نهایت، هدف معماری این نیست که کد بیشتری بنویسیم؛ هدف این است که هر بخش از سیستم دقیقاً بداند چه مسئولیتی دارد و چه مسئولیتی ندارد.

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