
طی سال های متمادی و از ماحصل تلاش های مهندس های نرم افزار، الگو هایی برای طراحی سیستم های نرم افزاری مقیاس پذیر (scalable) و قابل پشتیبانی/نگهداری/توسعه (maintainable) پدید اومد که قطعا از بعضی هاشون که خیلی معروف و رایج هستن شنیده اید، مثل MVC و MVVM و DDD و ...
به طور کلی، چیزی که همه این معماری های مختلف با هم در اشتراک دارن، تقسیم پروژه به قسمت های کوچک تر هست. برخی به شکل لایه ای (layered) و برخی به شکل ماژولار (modular)
در تقسیم بندی ماژول ها، هدف اولویت بیشتری نسبت به وسیله دارد. یعنی در یک ساختار ماژولار، فایل های پروژه برحسب نوع کلاس ها/فایل ها چیده نمی شن، بلکه بر اساس هدف های مشترک، فایل های مربوط کنار هم قرار می گیرن و تشکیل ماژول می دهند.
مثلا در یک پروژه ری اکت به جای اینکه ساختار پروژه رو این طوری بچینیم:
src/ components/ hooks/ contexts/ services/ ...
عوضش به این شکل می چینیم:
src/ auth/ state/ user-login/ route-protection/ customer-survey/ store/ products/ shopping-card/ ... user-profile/ ...
درسته که مثال هایی که می زنم همه از ری اکت هست، ولی محدود به ری اکت نیست و می تونه توی همه پروژه های فرانت-اند استفاده بشه. راجع به پروژه های بک-اند هم میشه از این نکات استفاده کرد اما یک سری نکات خاص وجود داره که در انتها بهش اشاره می کنم.
هر ماژول باید تمام چیز های مورد نیاز رو برای به اتمام رسوندن وظیفه اش داشته باشه. مثلا در ساختار بالا، ماژول user-login باید همه component های رابط کاربری، api call ها، hook ها و ... لازم برای ورود کاربر رو داشته باشه.
به طور کلی، خیلی وقت ها پیش میاد که ماژول ها نیاز دارند یه سری چیز ها رو از هم دیگه import کنند. این اشکالی نداره به شرط اینکه ارتباط import ها یک طرفه باشه.
بعضی وقت ها پیش میاد که ماژول A به ماژول B نیاز داره و همینطور B به A که بهش circular-dependency هم گفته میشه. این موقعیت ها کاملا گند میزنه به ساختار پروژه و خاصیت اصلی یک ساختار ماژولار که جدا کردن بخش های مستقل اپلیکیشن هست رو از بین می بره.
معمولا این قضیه زمانی پیش میاد که یکی از این دو شرایط برقرار باشه:

منظورم از «سیستم بسته»، اینه که ماژول باید تا حد امکان تعداد کمتری از اعضاشو export کنه. و اعضایی هم که به بیرون ماژول export میشن باید دقیقا معلوم و مشخص باشند. دقیقا مثل یک ماشین که از هزار تا قطعه کوچک تر ساخته شده ولی در نهایت یک سری interface مشخص مثل فرمان و دنده و پدال و ... برای راننده ها گذاشته که بدون دونستن جزئیات داخلی ماشین باهاش تعامل برقرار کنند.
برای مثال، در پیاده سازی ماژول user-login، به جای اینکه یک API این شکلی ایجاد کنید:

بهتره کلا سیستم login رو بسته نگه دارید:

خوبی این روش اینه که راه اندازی ماژول خیلی ساده تر از قبل هست و کاربر نیازی به دونستن تمام جزئیات داخلی فرم لاگین نداره. ضمن این که انجام این کار ماژول شما رو خیلی خودکفا تر می کنه چون الان کنترل تمام پروسه لاگین دست UserLoginForm هست.
البته، بدی این روش هم اینه که کاربر قسمت های کمتری از logic و UI رو می تونه دستکاری و شخصیسازی کنه. برای رفع این مورد، می تونید پارامتر های مشخصی رو تعیین کنید. برای مثال می تونید برای تعیین صفحه ای که کاربر بعد لاگین باید به اونجا هدایت بشه، یک property به نام redirectUrl روی UserLoginForm تعریف کنید.
حواستون باشه که پارامتر های ماژول برحسب نیاز های واقعی پروژه باشن نه نیاز هایی که احتمال میدید در آینده پیش بیاد! مثلا اضافه کردن یک property مثل formUI (که قابلیت تغییر در ظاهر فرم رو میده) چون صرفا احتمال میدید در آینده ممکنه مورد نیاز بشه غلطه.
معمولا وقتی ماژول رو دارید برای پروژه خودتون می نویسید، خیلی از چیز هایی که اوایل فکر می کنید نیاز میشن، نهایتا یا نیاز نمیشن یا اینکه با چیزی که واقعا نیاز پروژه است تفاوت دارند.
وقتی کدتون رو به صورت reactive بنویسید، می تونید در قبال تغییر وضعیت سایر ماژول ها واکنش نشون بدید بدون اینکه اون ها خبر داشته باشند. اینطوری می تونید وابستگی رو حتی کمتر کنید.
سناریو:
فرض کنید می خواهید وقتی کاربر از اکانتش خارج شد، اگر در یک صفحه محافظت شده مثل صفحه admin بود، از اون صفحه هم خارج بشه و به فرم login بره.
یه راهش اینه:

که کار هم می کنه ولی راه بهترش اینه که به جای اینکه ماژول auth برای بقیه تصمیم بگیره، فقط سرش به کار خودش باشه و واکنش مناسب رو به دست ماژول های دیگه بسپره. این شکلی:

اول یک hook میسازیم که فقط و فقط اطلاعات احراز هویت رو پاک میکنه

بعد هم یک hook می سازیم که فقط منطق محافظت از route و redirect کردن رو پیاده سازی می کنه و ضمن اینکه خودش یک عملکرد default داره (جهت راحتی استفاده)، به شما اجازه تغییر آدرسی که کاربر قراره به اونجا redirect بشه رو هم میده. (از طریق پارامتر redirectUrl)
تا اینجاش، وظیفه ماژول auth بود. از اینجا به بعدش رو بقیه ماژول ها تصمیم میگیرند که از این سیستم امنیتی استفاده بکنند یا نه. برای مثال، الان می تونیم در صفحه ادمین این کار رو انجام بدیم:

یا اینکه از high order component ها استفاده کنیم (HOC)

در هر حال، الان ما یک سیستم reactive در صفحه ادمین مون داریم که به وضعیت احراز هویت کاربر می تونه واکنش امنیتی مناسب نشون بده. همه اینها در حالی که است که خود ماژول auth از صفحه های تحت محافظت و ... بی خبره ? - خیلی تمیز بود مگه نه؟
پیاده سازی ساختار ماژولار در پروژه های back-end نیازمند رعایت یه سری نکات اضافی هست. طبق تجربه خودم در پروژه های back-end اگر به ساختار لایه های data و API توجه نشه، به مرور زمان می تونن باعث ایجاد روابط دو طرفه بین ماژول ها بشن.
در واقع این طور اتفاقات اون قدر در پروژه های بک-اند می افته که خیلی از فریمورک ها برای خودشون API های مخصوصی ارائه کرده اند جهت مدیریت این طور موقعیت ها. مثلا در ORM جنگو، شما می تونید این شکلی و بدون import خود مدل یک reference بهش ایجاد کنید:

حتی یک reference برعکس هم می تونید روی همین فیلد ایجاد کنید که هم از طریق کاربر به پست هاش و هم از طریق پست به کاربر نویسنده اش دسترسی داشته باشید. الان میشه پست های یک کاربر رو اینطوری درآورد:
User.objects.get(username="ashkan").posts
و نویسنده یک پست رو اینطوری:
Post.objects.get(id=34234).author
بهتره که شما هم از این ویژگی ها استفاده کنید و به جای import کردن مدل ها از string path شون در پروژه استفاده کنید. البته این وابستگی ها رو رفع نمی کنه اما حداقل دیگه ارور های مربوط به circular-dependency رو دریافت نمی کنید.
اگر بخواهید خیلی جدی کل data layer هر ماژول رو جدا کنید، اون وقت باید خودتون transaction ها و app-level join و cache و data normalization و synchronization و ... رو بین ماژول ها انجام بدید که البته لازم به گفتن نیست که چقدر اذیت کننده، وقت گیر و سخته.
البته بعضی شرکت های بزرگ که منابع انسانی خیلی زیادی دارند، پا رو از این فراتر میگذارند و حتی پروسه (process) های ماژول ها رو هم از هم جدا می کنند که بتونن هر ماژول رو با زبان برنامه نویسی و تکنولوژی خاص خودش پیاده سازی کنن و برای هرکدوم فرایند های CI/CD، deployment و replication جداگانه ای تعریف کنند. به این نوع معماری، microservice گفته میشه که در این مطلب نمی گنجه اما می تونید خودتون راجع بهش مطالعه کنید.
فرض کنید مثلا دارید برای مدرسه تون یک اپلیکیشن می نویسد و دو ماژول auth و school رو در back-end دارید. حالا اگر بخواهید role هر کاربر رو در مدرسه نشون بدید چیکار می کنید؟ من خودم قبلا سریع در مدل User که در ماژول auth قرار داره فیلد جدیدی اضافه می کردم به نام role. احتمالا هم یه enum جهت محدود کردن مقدارش به STUDENT, TEACHER و MANAGER می ساختم.
اما مسئله اینجاست که چرا باید در ماژول auth که صرفا مربوط به احراز هویت هست، فیلدی تحت عنوان role وجود داشته باشه؟ ضمنا توجه کنید که در ماژول auth ما اصلا از فیلد role استفاده ای نمی کنیم، بلکه ایجادش کردیم صرفا چون در ماژول school بهش نیاز داریم.
پس چیکار کنیم این ها رو؟ به نظر به من با استفاده از join table ها میشه این رو حلش کرد:

اصلا هدف از ایجاد API های GraphQL متصل کردن data source ها به هم برای ایجاد پلتفرم های یکپارچه بوده و با اینکه راه حل هایی برای ساختن API های GraphQL ماژولار وجود داره مثل GraphQL Federation و ... اما اگر هدفتون اینه که data point ها رو از هم جدا کنید، باید برید سراغ REST API ها.
باز هم طبق تجربه خودم، نهایتا می تونید resolver های GraphQL رو ماژولار بنویسید اما اگر دارید مثل من با روش code-first پیش میرید یکی از بزرگ ترین مشکلات typedef هایی هست که به شکل کلاس در کد تعریف میشن و هر جایی از گراف ممکنه import و استفاده بشن.
مثلا در طی مسیر School -> members -> user -> username روی گراف، با اینکه ابتدای مسیر با School شروع میشه (که مربوط به ماژول school هست) ولی انتهای مسیر به username اعضای مدرسه ختم میشه و شما باید بتونید از طریق ماژول auth نام کاربری هر عضو رو به دست بیارید و اینطور موقعیت ها باعث ایجاد circular-dependency میشه. چون همزمان که از مدرسه میشه به کاربر رسید، از طی مسیر User -> membership -> school هم میشه از کاربر به مدرسه اش رسید.
پس کلا اگه بخواهید از GraphQL استفاده کنید یا باید تحمل circular-dependency های متعدد رو داشته باشید، یا اینکه از راه حل نهایی که در قسمت بعد میگم استفاده کنید.
البته توجه داشته باشید که GraphQL تکنولوژی جوانی هست و هنوز لیست کل فن و تکنیک ها و best practice هاش در نیومده. بعید نیست که راه حل های دیگه ای هم وجود داشته باشه که من ندونم چون این پست رو از تجربه خودم نوشته ام.
بعضی وقت ها وقتی فلان ماژول فلان کار رو انجام میده، بلافاصله باید بقیه ماژول ها مطلع بشن تا بتونن کار خودشون رو انجام بدن. مثلا وقتی کاربر اکانتش رو پاک می کنه شاید نیاز باشه که بقیه ماژول ها هم اطلاع پیدا کنند و clean up های مخصوص خودشون رو اجرا کنند.
یا اینکه مثلا وقتی کاربر پیام رو دریافت می کنه، یک notification برای کاربر ارسال بشه.
همون طور که می بینید تقریبا شبیه به همون سناریویی بود که با هم در یک پروژه فرانت اند برای واکنش به logout کاربر برسی کردیم. اینجا هم باید از همون concept استفاده کنیم. با این تفاوت که این بار با استفاده از event ها.
مثلا به جای اینکه اینطوری کد بزنیم (کد غیر واقعی و مثالی است یکم وقت بزارید تا منطقش رو بفهمید):

به جاش اول میایم و یک eventBus میسازیم که در کل اپلیکیشن مشترکه. می تونید از طریق یک singleton و dependency injection این کار رو در فریمورک هایی مثل nest js انجام بدید. راجع به بقیه شون خودتون باید بفهمید که پیاده سازی اش چطوری هست. من اینجا فقط به نمایش طرح کلی concept بسنده می کنم:
اول کد مربوط به دریافت پیام چت رو به این صورت بازنویسی می کنیم. به طوری که دیگه هیچ خبری از سیستم notification و logging نباشه:

حالا به جای اینکه مستقیم notification بفرستیم و ... فقط اعلام می کنیم که ایونت message-received اتفاق افتاده.
در ادامه در ماژول notification یک handler برای message-received می نویسیم:

حالا هر وقت که message-received اتفاق بیوفته، سیستم notification خود به خود برای کاربر notif رو ارسال می کنه.
نکته: اگر تعداد notification ها زیاد باشه و ... خیلی پیچیدگی ها بیشتری به کار اضافه می کنه. مثلا شاید لازم باشه که به جای فرستادن notification به ازای هر پیام، فقط notification آخرین پیام های هر شخص/گروه و ... رو ارسال کنیم. به هر حال این فقط یک مثال ساده بود و جمع و جور کردن این موقعیت ها به عهده خودتون.
و نهایتا یک logger برای همه event های اپلیکیشن می نویسیم:

حالا هر اتفاقی توی اپلیکیشن بیوفته توی log ها نوشته میشه و لازم نیست مدام همه جا از سرویس logging استفاده کنیم. همچنین میتونیم این ماژول رو بسیار گسترشش بدیم. مثلا سیستمی رو در اون ایجاد کنیم که ارور های جدی رو برای مسئولین فنی ایمیل کنه یا اینکه warning ها رو به عنوان ticket برای bug tracker اپلیکیشن ارسال کنه جهت رسیدگی و ...
و همه این ها اتفاق می افته در حالی که بقیه اپلیکیشن هیچ خبری نداره که اصلا ماژولی به نام logging وجود داره. هروقت هم بخواهیم می تونیم کل ماژول logging رو تغییر بدیم و هیچ ایرادی پیش نمیاد.
همین قضیه برای سرویس notification هم برقراره.
از این جور design pattern ها کلا زیاده، یکی از پرکاربرد ترینشون CQRS هست که منم در پروژه هام ازش استفاده می کنم. می تونید با گشتن در اینترنت راجع بهش بیشتر تحقیق کنید.
دوست دارم بهشون بگم super-module ولی درواقع این روش ترکیب معماری لایه ای و ماژولار هست. مثلا راجع به GraphQL می تونیم با جمع کردن کل چیزای مربوط به API در یک ماژول خیلی بزرگ مشکل مربوط به circular-dependency رو حل کنیم.
یعنی به جای اینکه اینطوری ماژول بسازیم:

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

از لحاظ ساختار directory هم، اگر قبلش این باشه:
src/ module1/ db/ services/ graphql/ module2/ db/ services/ graphql/
حالا شده این:
src/ module1/ db/ services/ module2/ db/ services/ graphql/ interfaces/ inputobjs/ typedefs/ resolvers/ ....
حالا هر فعل و انفعالاتی که مربوط به graphql هست توی ماژول خودش ایزوله شده. شاید کمی پیچیده بشه ولی در این مورد خاص، استفاده از یک layer بهتر از circular-dependency و راه حل های عجیب غریبشه.
خوب، پس کلا اگر بخواهیم تمام مقاله رو خلاصه کنیم، ماژول ها:
نکاتی هم که در بک اند باید رعایت بشه:
اگر هم ساختار ماژولار جواب کار نبود و circular-dependency امان نمی داد، میزنیم به سیم آخر و یک super-module یا همون layer ایجاد می کنیم.
البته این ها حاصل مطالعات پراکنده من از منابع اینترنتی و تجربه های خودم در پیاده سازی پروژه هام بوده و قبول دارم که کامل نیست. اما چون این روش ها تا الان توی پروژه های با وسعت متوسط خیلی بهم کمک کرده اند و عملکرد قابل قبولی در نظم دهی به پروژه ها داشته اند تصمیم به اشتراک شون گرفتم.
انشاالله وقتی علمم در این زمینه بیشتر شد مقالات کامل تری رو بنویسم.
دمتون گرم که تا انتها خوندید، خدا یار و نگهدارتون.