ویرگول
ورودثبت نام
Farhad Adeli
Farhad Adeliمهندس نرم‌افزار ارشد با ۱۶+ سال تجربه در فرانت‌اند، بک‌اند و سیستم‌های بلادرنگ؛ علاقه‌مند به معماری نرم‌افزار، WebRTC و یادگیری سریع با کمک AI.
Farhad Adeli
Farhad Adeli
خواندن ۹ دقیقه·۴ ماه پیش

چطور دو اپلیکیشن بسیار مشابه را یکی کنیم، بدون اینکه یک کابوس نگهداری بسازیم؟

بسیاری از تیم‌ها دیر یا زود به این وضعیت می‌رسند:

یک پروژه داریم که به‌خوبی کار می‌کند. برای راه‌اندازی محصول دوم، همان پروژه را کپی می‌کنیم، چند بخش را تغییر می‌دهیم و آن را به‌عنوان یک محصول مستقل منتشر می‌کنیم.

در ابتدا این تصمیم سریع و منطقی به نظر می‌رسد.

اما با گذشت زمان، مشکلات شروع می‌شوند:

  • هر قابلیت جدید باید دو بار پیاده‌سازی شود.

  • هر باگ باید در دو پروژه برطرف شود.

  • تغییرات دیتابیس باید در هر دو پروژه هماهنگ شوند.

  • کدهای دو پروژه به‌تدریج از هم فاصله می‌گیرند.

  • هزینه تست، توسعه و نگهداری بیشتر می‌شود.

ما نیز با شرایط مشابهی روبه‌رو بودیم:

  • دو محصول بسیار مشابه

  • فرانت‌اند با 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 استفاده می‌کنند.

برنامه مهاجرت مرحله‌به‌مرحله

این مهاجرت باید تدریجی انجام شود.

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

مرحله ۱: ایجاد Monorepo

یک 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/

مرحله ۵: استخراج Contractهای مشترک

DTOها، Schemaها، Enumها و API Typeها را به مسیر زیر منتقل کنید:

packages/contracts/

مرحله ۶: استخراج کامپوننت‌های پایه UI

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

  • Button

  • Input

  • Table

  • Dialog

  • Form

  • Layout

مرحله ۷: استخراج Featureهای کامل فرانت‌اند

Featureهایی مانند موارد زیر را یکی‌یکی منتقل کنید:

  • Authentication

  • Products

  • Users

  • Markets

  • Permissions

مرحله ۸: استخراج منطق مشترک کسب‌وکار

قوانین، محاسبات و تغییر وضعیت‌های مشترک را به مسیر زیر منتقل کنید:

packages/domain/

مرحله ۹: استخراج ماژول‌های مشترک NestJS

از ماژول‌هایی شروع کنید که در هر دو پروژه تقریباً یکسان هستند.

مرحله ۱۰: اضافه‌کردن Product Config

تفاوت‌های کوچک را با Configuration و Feature Flag مدیریت کنید.

مرحله ۱۱: اضافه‌کردن Strategy و Policy

برای تفاوت‌های مهم رفتاری، Implementationهای مجزا ایجاد کنید.

مرحله ۱۲: حذف کدهای تکراری

پس از استخراج هر Package مشترک، کد قدیمی را از هر دو پروژه حذف کنید.

مرحله ۱۳: اضافه‌کردن تست‌های مشترک

برای Packageهای Shared، Unit Test و Integration Test بنویسید.

مرحله ۱۴: حفظ تست‌های End-to-End مستقل

هر محصول باید همچنان تست‌های E2E مخصوص فرایندهای حیاتی خودش را داشته باشد.

مرحله ۱۵: به‌روزرسانی CI/CD

فقط پروژه‌هایی را Build و Test کنید که تحت تأثیر تغییرات هستند:

nx affected -t lint test build

مرحله ۱۶: مهاجرت Feature به Feature

کل سیستم را در یک Release مهاجرت ندهید.

هر بار یک Feature را منتقل، تست و پایدار کنید.

مرحله ۱۷: بازبینی معماری نهایی

پس از حذف بخش عمده Duplication، بررسی کنید که آیا هنوز به موارد زیر نیاز دارید:

  • دو فرانت‌اند جدا

  • دو بک‌اند جدا

  • دو Pipeline استقرار

  • دو دیتابیس مستقل

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

چه کارهایی نباید انجام دهیم؟

ساختن یک پوشه بزرگ Shared

یک پوشه عمومی مانند shared معمولاً به‌مرور تبدیل به محل انباشته‌شدن انواع کدهای نامرتبط می‌شود.

بهتر است Packageها مرز و مسئولیت مشخص داشته باشند:

packages/frontend/products packages/backend/auth packages/domain/orders

استفاده بیش از حد از شرط‌های مربوط به محصول

وجود چنین شرط‌هایی در نقاط متعدد یک علامت هشدار است:

if (product === 'a') { // ... }

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

رفتن زودهنگام به سمت Microservice

وجود دو محصول مشابه به‌معنای نیاز به Microservice نیست.

در بسیاری از موارد، Modular Monolith انتخاب مناسب‌تری است.

Microservice پیچیدگی‌های زیادی ایجاد می‌کند:

  • پیچیدگی Deployment

  • Distributed Transaction

  • خطاهای شبکه

  • پیچیدگی Observability

  • مشکلات Data Consistency

فقط زمانی یک ماژول را به سرویس مستقل تبدیل کنید که دلیل عملیاتی یا مقیاس‌پذیری مشخصی داشته باشید.

Shared کردن همه چیز

هر فایل مشابهی الزاماً نباید به 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 نیست.

هدف اصلی این است که هر قابلیت جدید فقط یک‌بار پیاده‌سازی شود.

software engineeringweb developmentsoftware developmentsoftware architecture
۱
۰
Farhad Adeli
Farhad Adeli
مهندس نرم‌افزار ارشد با ۱۶+ سال تجربه در فرانت‌اند، بک‌اند و سیستم‌های بلادرنگ؛ علاقه‌مند به معماری نرم‌افزار، WebRTC و یادگیری سریع با کمک AI.
شاید از این پست‌ها خوشتان بیاید