تفکیک 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، بسیار قابلکنترلتر خواهد شد.
