بسیاری از تیمها دیر یا زود به این وضعیت میرسند:
یک پروژه داریم که بهخوبی کار میکند. برای راهاندازی محصول دوم، همان پروژه را کپی میکنیم، چند بخش را تغییر میدهیم و آن را بهعنوان یک محصول مستقل منتشر میکنیم.
در ابتدا این تصمیم سریع و منطقی به نظر میرسد.
اما با گذشت زمان، مشکلات شروع میشوند:
هر قابلیت جدید باید دو بار پیادهسازی شود.
هر باگ باید در دو پروژه برطرف شود.
تغییرات دیتابیس باید در هر دو پروژه هماهنگ شوند.
کدهای دو پروژه بهتدریج از هم فاصله میگیرند.
هزینه تست، توسعه و نگهداری بیشتر میشود.
ما نیز با شرایط مشابهی روبهرو بودیم:
دو محصول بسیار مشابه
فرانتاند با Next.js، React و TypeScript
بکاند با NestJS و TypeScript
قابلیتهای مشترک مانند احراز هویت، محصولات، کاربران و بازارها
تفاوتهایی در برخی فرایندهای کسبوکار
پیادهسازی دوباره هر قابلیت جدید در هر دو پروژه
هدف مشخص بود:
تفاوتهای واقعی محصولات را حفظ کنیم، کدهای تکراری را حذف کنیم و توسعه قابلیتهای بعدی را سادهتر کنیم.
اولین ایده معمولاً این است:
دو پروژه را مستقیماً با هم ادغام کنیم و به یک اپلیکیشن واحد برسیم.
این راه در ظاهر ساده است، اما ریسک زیادی دارد.
دو پروژهای که از یک کد پایه شروع شدهاند، ممکن است بهمرور زمان تغییرات متفاوتی پیدا کرده باشند:
باگفیکسهای متفاوت
تغییرات متفاوت دیتابیس
قوانین اعتبارسنجی متفاوت
رفتارهای متفاوت در رابط کاربری
نیازهای متفاوت در استقرار
Integrationهای اختصاصی
قوانین کسبوکاری که در ظاهر مشخص نیستند
اگر یکی از پروژهها را مبنا قرار دهیم و پروژه دیگر را روی آن ادغام کنیم، احتمال زیادی وجود دارد که بخشی از رفتارهای معتبر یا قابلیتهای مهم از بین بروند.
بنابراین بهتر است کار را با یکیکردن اپلیکیشنها شروع نکنیم.
بهتر است ابتدا زیرساخت و بخشهای مشترک را یکی کنیم.
اولین قدم مناسب، انتقال هر دو پروژه به یک Monorepo است، بدون اینکه استقلال آنها در Build و Deploy از بین برود.
ساختار پیشنهادی میتواند شبیه زیر باشد:
platform/ ├── apps/ │ ├── project-a-web/ │ ├── project-b-web/ │ ├── project-a-api/ │ └── project-b-api/ │ └── packages/ ├── domain/ ├── frontend/ ├── backend/ ├── contracts/ └── config/
پوشه apps شامل اپلیکیشنهای اجرایی و قابل استقرار است.
پوشه packages شامل کدهایی است که میان پروژهها به اشتراک گذاشته میشوند.
با این ساختار، میتوانیم بیشتر کد را مشترک کنیم، بدون اینکه هر دو محصول را مجبور کنیم در یک Runtime یا یک Pipeline استقرار قرار بگیرند.
برای پیادهسازی این معماری میتوان از ابزارهای زیر استفاده کرد:
pnpm workspace
Nx
TypeScript Project References
تنظیمات مشترک Lint، Test و Build
Nx در این معماری انتخاب مناسبی است، چون وابستگی میان پروژهها را درک میکند و میتواند فقط بخشهایی را Build یا Test کند که تحت تأثیر یک تغییر قرار گرفتهاند.
همه چیز نباید Shared باشد.
قبل از استخراج کدها، بهتر است هر بخش را در یکی از چهار گروه زیر قرار دهیم.
نمونهها:
ابزارهای احراز هویت
DTOهای مشترک
Typeهای API
Schemaهای اعتبارسنجی
کامپوننتهای پایه UI
Logging
زیرساختهای مشترک
این بخشها معمولاً بدون تغییر خاصی قابل انتقال به Packageهای مشترک هستند.
گاهی رفتار اصلی یکسان است، اما تنظیمات محصولات فرق میکند.
برای مثال:
عنوان یک صفحه متفاوت است.
صدور فاکتور در یکی از محصولات فعال است.
یک ماژول فقط در یکی از پروژهها نمایش داده میشود.
منوی هر محصول متفاوت است.
برندینگ یا متنها با هم فرق دارند.
این نوع تفاوتها بهتر است با Configuration مدیریت شوند.
export interface ProductConfig { name: string; features: { invoices: boolean; preOrders: boolean; payments: boolean; }; }
هر پروژه میتواند تنظیمات خودش را ارائه دهد:
export const projectAConfig: ProductConfig = { name: 'Project A', features: { invoices: true, preOrders: false, payments: true, }, };
export const projectBConfig: ProductConfig = { name: 'Project B', features: { invoices: false, preOrders: true, payments: false, }, };
گاهی مفهوم در هر دو محصول وجود دارد، اما رفتار آن متفاوت است.
برای مثال، هر دو محصول ممکن است فرایندی به نام سفارش داشته باشند، اما:
در محصول اول سفارش واقعی ثبت میشود.
موجودی رزرو میشود.
فاکتور تولید میشود.
در محصول دوم فقط پیشسفارش ثبت میشود.
موجودی رزرو نمیشود.
فاکتور صادر نمیشود.
در اینجا Configuration بهتنهایی کافی نیست.
برای چنین تفاوتهایی بهتر است از Strategy یا Policy استفاده کنیم.
export interface OrderWorkflow { submit(order: Order): Promise<Order>; approve(order: Order): Promise<Order>; cancel(order: Order): Promise<Order>; }
هر محصول Implementation مخصوص خودش را دارد:
export class ProjectAOrderWorkflow implements OrderWorkflow { async submit(order: Order) { // Reserve inventory and create invoice return order; } }
export class ProjectBOrderWorkflow implements OrderWorkflow { async submit(order: Order) { // Save as preorder without reserving inventory return order; } }
این ساختار بسیار بهتر از پراکندهکردن شرطهای متعدد در کل کد است:
if (projectType === 'a') { // ... } else { // ... }
استفاده محدود از شرط مشکلی ندارد، اما وقتی منطق کسبوکار در دهها نقطه به نوع پروژه وابسته باشد، معماری بهمرور غیرقابل نگهداری میشود.
برخی قابلیتها واقعاً فقط متعلق به یکی از محصولات هستند.
این بخشها باید داخل همان App باقی بمانند.
هدف این نیست که تمام کدها را به زور Shared کنیم.
هدف این است که فقط بخشهایی را مشترک کنیم که واقعاً رفتار و مسئولیت یکسانی دارند.
یکی از اشتباهات رایج این است که فقط کامپوننتهای ساده مانند Button، Input و Modal را مشترک کنیم.
این کار مفید است، اما مشکل اصلی را حل نمیکند.
اگر هر Feature همچنان در هر دو پروژه جداگانه نوشته شود، بخش عمده Duplication باقی میماند.
بهتر است Featureهای کامل را Shared کنیم.
برای مثال:
packages/frontend/products/ ├── components/ ├── hooks/ ├── api/ ├── schemas/ ├── state/ └── index.ts
این Package میتواند شامل موارد زیر باشد:
ProductList
ProductForm
ProductFilters
useProducts
APIهای محصول
Schemaهای اعتبارسنجی
Permissionهای مربوط به محصول
سپس هر دو اپلیکیشن Next.js از همین Feature استفاده میکنند:
export default function ProductsPage() { return <ProductList config={productConfig} />; }
در این معماری، هر App بیشتر مسئول موارد زیر خواهد بود:
Routing
Branding
Configuration
صفحههای اختصاصی
ترکیب Featureهای مشترک
در بکاند نیز میتوان ماژولهای مشترک NestJS را در Packageهای مستقل قرار داد.
packages/backend/ ├── auth/ ├── users/ ├── products/ ├── markets/ ├── database/ └── observability/
سپس هر پروژه فقط ماژولهای موردنیاز خود را Compose میکند.
@Module({ imports: [ AuthModule, ProductsModule, MarketsModule, OrdersModule.register({ workflow: ProjectAOrderWorkflow, }), ], }) export class ProjectAAppModule {}
پروژه دوم نیز Workflow مخصوص خودش را ارائه میدهد:
@Module({ imports: [ AuthModule, ProductsModule, MarketsModule, OrdersModule.register({ workflow: ProjectBOrderWorkflow, }), ], }) export class ProjectBAppModule {}
در نتیجه، پیادهسازی مشترک Backend فقط یکبار نوشته میشود، اما تفاوتهای رفتاری هر محصول همچنان مستقل باقی میمانند.
یکی دیگر از منابع Duplication، تعریف جداگانه Typeها در فرانتاند و بکاند است.
برای مثال، فرانتاند یک نسخه از CreateOrderRequest دارد و بکاند نسخه دیگری از آن را تعریف میکند.
با گذشت زمان، این دو Type ممکن است با هم ناسازگار شوند.
برای جلوگیری از این مشکل، میتوان یک Package مشترک برای Contractها ایجاد کرد:
packages/contracts/ ├── orders/ ├── products/ ├── users/ └── common/
این Package میتواند شامل موارد زیر باشد:
DTOها
Schemaهای API
Enumها
Response Typeها
Event Payloadها
مثال:
import { z } from 'zod'; export const CreateOrderSchema = z.object({ customerId: z.string(), items: z.array( z.object({ productId: z.string(), quantity: z.number().positive(), }), ), }); export type CreateOrderRequest = z.infer< typeof CreateOrderSchema >;
اکنون فرانتاند و بکاند هر دو از یک Contract استفاده میکنند.
این مهاجرت باید تدریجی انجام شود.
تلاش برای انتقال همه چیز در یک مرحله، ریسک پروژه را بسیار زیاد میکند.
یک Workspace جدید با pnpm و Nx ایجاد کنید.
apps/ ├── project-a-web/ ├── project-b-web/ ├── project-a-api/ └── project-b-api/
در این مرحله فقط محل پروژهها تغییر میکند.
هر چهار App باید همچنان مستقل Build، Test و Deploy شوند.
هر Feature را در یکی از این گروهها قرار دهید:
کاملاً مشترک
مشترک با تفاوت تنظیمات
مشترک با تفاوت رفتاری
کاملاً اختصاصی
تنظیمات TypeScript، ESLint، Prettier و Build را به مسیر زیر منتقل کنید:
packages/config/
DTOها، Schemaها، Enumها و API Typeها را به مسیر زیر منتقل کنید:
packages/contracts/
ابتدا از بخشهای کمریسک شروع کنید:
Button
Input
Table
Dialog
Form
Layout
Featureهایی مانند موارد زیر را یکییکی منتقل کنید:
Authentication
Products
Users
Markets
Permissions
قوانین، محاسبات و تغییر وضعیتهای مشترک را به مسیر زیر منتقل کنید:
packages/domain/
از ماژولهایی شروع کنید که در هر دو پروژه تقریباً یکسان هستند.
تفاوتهای کوچک را با Configuration و Feature Flag مدیریت کنید.
برای تفاوتهای مهم رفتاری، Implementationهای مجزا ایجاد کنید.
پس از استخراج هر Package مشترک، کد قدیمی را از هر دو پروژه حذف کنید.
برای Packageهای Shared، Unit Test و Integration Test بنویسید.
هر محصول باید همچنان تستهای E2E مخصوص فرایندهای حیاتی خودش را داشته باشد.
فقط پروژههایی را Build و Test کنید که تحت تأثیر تغییرات هستند:
nx affected -t lint test build
کل سیستم را در یک Release مهاجرت ندهید.
هر بار یک Feature را منتقل، تست و پایدار کنید.
پس از حذف بخش عمده Duplication، بررسی کنید که آیا هنوز به موارد زیر نیاز دارید:
دو فرانتاند جدا
دو بکاند جدا
دو Pipeline استقرار
دو دیتابیس مستقل
در این مرحله، تصمیمگیری بسیار سادهتر خواهد بود، چون تفاوتهای واقعی دو محصول بهصورت شفاف مشخص شدهاند.
یک پوشه عمومی مانند shared معمولاً بهمرور تبدیل به محل انباشتهشدن انواع کدهای نامرتبط میشود.
بهتر است Packageها مرز و مسئولیت مشخص داشته باشند:
packages/frontend/products packages/backend/auth packages/domain/orders
وجود چنین شرطهایی در نقاط متعدد یک علامت هشدار است:
if (product === 'a') { // ... }
اگر این شرطها در همهجای پروژه دیده شوند، یعنی تفاوت محصولات بهدرستی در معماری ایزوله نشده است.
وجود دو محصول مشابه بهمعنای نیاز به Microservice نیست.
در بسیاری از موارد، Modular Monolith انتخاب مناسبتری است.
Microservice پیچیدگیهای زیادی ایجاد میکند:
پیچیدگی Deployment
Distributed Transaction
خطاهای شبکه
پیچیدگی Observability
مشکلات Data Consistency
فقط زمانی یک ماژول را به سرویس مستقل تبدیل کنید که دلیل عملیاتی یا مقیاسپذیری مشخصی داشته باشید.
هر فایل مشابهی الزاماً نباید به Package مشترک منتقل شود.
یک بخش زمانی باید Shared شود که:
رفتار آن واقعاً یکسان باشد.
مسئولیت آن مشخص باشد.
مرز پایداری داشته باشد.
اشتراکگذاری باعث کاهش هزینه نگهداری شود.
Coupling غیرضروری ایجاد نکند.
شاید.
داشتن یک Frontend و یک Backend زمانی منطقی است که:
هر دو محصول همزمان منتشر شوند.
زیرساخت آنها یکسان باشد.
بیشتر Workflowها مشابه باشند.
تفاوتها با Configuration قابل مدیریت باشند.
مالکیت هر دو محصول با یک تیم باشد.
اما حفظ Appهای جدا زمانی بهتر است که:
Deployها باید مستقل باشند.
نیازهای امنیتی متفاوت باشند.
مشتریان به محیطهای جدا نیاز داشته باشند.
مسیر توسعه محصولات در حال جداشدن باشد.
خرابی یک محصول نباید محصول دیگر را تحت تأثیر قرار دهد.
Monorepo بهمعنای یک Deployment نیست.
میتوان بخش عمده کد را مشترک کرد و همچنان Docker Image و Pipeline جداگانه داشت.
از همان ابتدا تلاش نکنید دو محصول را به یک اپلیکیشن بزرگ تبدیل کنید.
ابتدا یک Monorepo بسازید که شامل این بخشها باشد:
دو اپلیکیشن فرانتاند
دو اپلیکیشن بکاند
Featureهای مشترک Frontend
ماژولهای مشترک Backend
Contractهای مشترک
منطق مشترک Domain
تنظیمات مشترک
تفاوتهای کوچک را با Configuration مدیریت کنید.
تفاوتهای رفتاری را با Strategy و Policy جدا کنید.
مهاجرت را Feature به Feature انجام دهید.
پس از پایدارشدن Shared Core، بررسی کنید که آیا ادغام نهایی Runtime و Deployment واقعاً ارزش انجامدادن دارد یا نه.
هدف اصلی فقط داشتن یک Repository نیست.
هدف اصلی این است که هر قابلیت جدید فقط یکبار پیادهسازی شود.