اوایل پروژه تقریباً هر صفحه CRUD داستان خودش را داشت. table و modal و hookها خیلی با هم فرق نمیکردند، ولی wiring همهشان دوباره داخل همان page نوشته میشد: گرفتن داده، باز و بسته کردن فرم، تشخیص create و edit، ساختن actionهای هر ردیف، loading، delete و بقیه چیزهایی که تقریباً در همه صفحهها تکرار میشدند.
اگر صفحه فایل، گزارش یا resource فرزند هم داشت، چند تکه دیگر به همین مجموعه اضافه میشد.
تا یک جایی این وضعیت آزاردهنده بود، ولی قابل تحمل. مشکل وقتی جدی شد که تغییر دادن یک رفتار مشترک دیگر به معنی تغییر دادن یک component نبود. باید چند page مختلف را میگشتیم تا ببینیم هرکدام آن رفتار را چطور به table، form و hookهایشان وصل کردهاند.
component مشترک داشتیم. چیزی که مشترک نبود، خودِ ترکیب صفحه بود.
مشکل داخل componentها نبود
DataTable آن زمان خودش امکانات کمی نداشت. search، filter، sort، pagination، حالت mobile، action گروهی و بعدتر server-side table را داشتیم.
سمت form هم input، select، modal و validation تا حد خوبی reusable شده بودند.
پس مسئله این نبود که یک Table جدید یا Form جدید بسازم. اگر PageShell دوباره این رفتارها را داخل خودش پیاده میکرد، فقط یک component بزرگتر ساخته بودم که نسخه دوم همان زیرساخت قبلی بود.
تکرار اصلی اینجا بود:
Page
├── data hooks
├── mutation hooks
├── table
├── form modal
├── CRUD actions
├── permissions
├── loading states
└── feature-specific actionsو تقریباً هر page دوباره همین قطعات را با دست به هم وصل میکرد.
اولین راهحلی که رفتم سمتش یک CRUD shell عمومی بود. flow معمول صفحههای server-table را جمع میکرد و از نظر تئوری نشان میداد که بخش زیادی از این wiring قابل حذف است.
ولی خیلی زود یک مشکل مشخص شد: آن abstraction بیشتر شبیه یک component با تعداد زیادی prop بود تا یک قرارداد واقعی برای ساخت صفحه.
حتی بعضی صفحههایی که همان موقع در کنار آن ساخته شده بودند عملاً از shell استفاده نکردند.
همانجا یک نکته برایم روشن شد: اینکه چیزی را داخل shared/ بگذاریم، لزوماً به این معنی نیست که abstraction خوبی ساختهایم.
صفحههای واقعی همیشه یک چیزی اضافه داشتند
CRUD ساده مشکلی نداشت.
مشکل وقتی شروع میشد که صفحه واقعی وارد بازی میشد.
یک صفحه table سمت server داشت، یکی داده را سمت client نگه میداشت.
یک صفحه create داشت ولی delete نباید روی آن فعال میشد.
یک صفحه فقط برای نمایش اطلاعات بود و اصلاً form معنی نداشت.
resourceهای nested هم داستان خودشان را داشتند. باید id والد از route خوانده میشد، query براساس آن scope میشد، breadcrumb ساخته میشد و موقع create همان parent بهعنوان مقدار ثابت داخل form قرار میگرفت.
بعد هم قابلیتهایی مثل اینها اضافه میشدند:
- upload فایل
- report
- signature
- navigation به resource فرزند
- custom action
- انتخاب چند ردیف از داخل یک modal
هیچکدام هم روی تمام صفحهها وجود نداشتند.
میشد برای همهشان داخل shell چیزی مثل hasUpload،hasReport، hasSignature و دهها flag دیگر اضافه کرد. ولی انتهای آن مسیر تقریباً مشخص بود: یک component عظیم که درباره همه قسمتهای ERP اطلاعات دارد و نصف کدش شرط است.
پس هدف را عوض کردم.
قرار نبود «یک CRUD page که همهجا کار کند» بسازم.
قرار بود بخش ثابت ساخت صفحه را مشترک کنم و بخش متغیر را تا جای ممکن بیرون نگه دارم.
PageDefinition + PageShell
مدلی که در نهایت بیشتر جواب داد، دو بخش اصلی داشت:
PageDefinition و PageShell.
هر feature تعریف خودش را داشت: hookها، columnها، عنوانها، form config، نوع table و policy مربوط به actionها.
PageShell فقط مسئول این بود که این قطعات را به زیرساخت مشترک وصل کند.
بهشکل ساده:
PageDefinition
↓
PageShell
├── Data strategy
├── Form config
├── Table mode
├── Policies
├── Nested context
└── Featuresیک helper تایپشده هم روی تعریف page داشتیم تا config تبدیل به یک object آزاد با anyهای فراوان نشود.
type رکورد دامنه، submit model، fieldها و columnها هنوز متعلق به همان feature بودند.
PageShell قرار نبود بفهمد یک status در بخش فروش چه معنی دارد یا فلان relation در Planning چرا وجود دارد. فقط میدانست این قطعات باید چطور کنار هم قرار بگیرند.
این مرز بعداً خیلی مهم شد.
وقتی یک صفحه موجود به PageShell migrate میشد، business logic آن صفحه ناگهان تبدیل به بخشی از PageShell نمیشد. فقط orchestration آن صفحه عوض شده بود.
قابلیتهای اختیاری را feature کردم
برای چیزهایی که روی همه صفحات نبودند، بهجای اضافه کردن flagهای بیشتر به هسته، feature adapter شکل گرفت.
هر feature میتوانست چیزهایی مثل اینها را برگرداند:
- row action
- table action
- modal
- رفتار اضافه مربوط به همان قابلیت
در نتیجه upload، report، navigation، selection modal و بقیه قابلیتها میتوانستند کنار PageShell قرار بگیرند، بدون اینکه implementation آنها وارد خود shell شود.
تصویر ذهنی تقریباً این بود:
┌─ Upload
├─ Report
PageShell ───┼─ Signature
├─ Navigation
├─ Selection
└─ Custom Actionیکی از محدودیتهای جالب این مدل به React برمیگشت.
feature factory ممکن بود خودش hook اجرا کند. در نتیجه نمیشد وسط render براساس یک شرط، feature را از آرایه حذف یا به آن اضافه کرد. ساختار featureها باید ثابت میماند تا ترتیب hookها هم ثابت بماند.
یعنی enable/disable شدن قابلیت باید داخل config همان feature مدیریت میشد.
API کمی سختگیرتر شد، ولی حداقل رفتار آن قابل پیشبینی بود.
readOnly همان editable: false نبود
یکی از چیزهایی که مدل اولیه درست تفکیک نکرده بود همین بود.
صفحهای ممکن است edit نداشته باشد، ولی هنوز یک action دیگر روی rowهایش داشته باشد.
این با صفحهای که واقعاً read-only است فرق دارد.
در حالت دوم نه فقط edit، بلکه form، mutation و actionهای جانبی هم نباید در دسترس باشند.
بعداً این دو مفهوم از هم جدا شدند:
editable: false- read-only کامل
همین قسمت یک bug هم ایجاد کرد.
در بعضی حالتها با اینکه صفحه read-only بود، row actionها هنوز به یکی از مسیرهای render table میرسیدند.
در ظاهر شاید میشد فقط دکمهها را در component پایینتر مخفی کرد، ولی ترجیح دادم policy در خود مرز PageShell اعمال شود. یعنی اگر page read-only است، shell اصلاً action به table ندهد.
این نوع bugها برای من مفید بودند، چون دقیقاً نشان میدادند abstraction کجا هنوز فقط «ظاهر مشترک» دارد و کجا واقعاً مسئولیت خودش را کنترل میکند.
nested resourceها قرارداد را جدیتر کردند
برای یک صفحه nested، isNested: true عملاً اطلاعات کافی نبود.
PageShell باید میدانست:
- کدام route param شناسه parent است
- کدام query لیست scoped را برمیگرداند
- اطلاعات parent از کجا میآید
- breadcrumb چطور ساخته شود
- مقدار parent باید داخل کدام field فرم قرار بگیرد
نسخه اول این قسمت یک bug داشت.
shell سعی میکرد از اسم route param، اسم field فرم را حدس بزند. در یکی از consumerها نتیجه تبدیل شد به یک field اشتباه، در حالی که form config از قبل مشخص کرده بود مقدار باید داخل کدام property قرار بگیرد.
بهجای اینکه convention را پیچیدهتر کنم، قرارداد را explicit کردم:
route param → declared form fieldیعنی config هم میگفت چه paramی خوانده شود و هم مشخص میکرد مقدار داخل چه fieldی نوشته شود.
از آن تصمیمهایی بود که بعداً خیلی ساده به نظر میرسند، ولی معمولاً اول باید یک بار از حدسزدن اسمها ضربه بخوری تا به آن برسی.
اولین selection modal هم بینقص نبود
اولین consumer واقعی selection modal در نسخه اولیه با یک id ثابت وصل شده بود.
بعد اصلاح شد تا context ردیفی که کاربر واقعاً انتخاب کرده وارد feature شود.
بعد از آن، همان adapter توانست default selection، table داخلی و group action را مدیریت کند، بدون اینکه PageShell بداند این انتخاب در business چه معنایی دارد.
برای من این بخش مهم بود، چون اگر PageShell برای پیاده کردن selection مجبور میشد domain object را بشناسد، عملاً مرزی که ساخته بودیم از بین میرفت.
form و table هم همزمان جلو میرفتند
PageShell قرار نبود زیرساخت اطراف خودش را freeze کند.
بعداً cascade field به dynamic form اضافه شد. یک field میتوانست parent مورد نیاز lookup بعدی را تعیین کند و metadata فرم این dependency را منتقل میکرد.
PageShell لازم نبود برای یک صفحه مشخص branch جدید داشته باشد.
همین اتفاق برای table افتاد.
server table امکان quick filter گرفت، در حالی که client table تنظیمات خودش برای pagination، sorting و filtering را داشت.
shell فقط config مناسب را عبور میداد.
این separation باعث شد توسعه form و table الزاماً به تغییر contract صفحه منجر نشود.
history پروژه فاصله دو دوره را خوب نشان میدهد. صفحه مدیریت Menu اولین بار در ۱۸ فوریه ۲۰۲۵ دیده میشود؛ create آن ۴ مارس کامل شد و edit/delete در دو مرحله در ۱۹ آوریل به flow اضافه شدند. این به معنی دو ماه کار پیوسته روی یک صفحه نیست؛ feature طی چند چرخه جلو رفت، همزمان با اینکه table، form و select مشترک هم در حال ساخته شدن بودند. نکته مهم برای من این بود که هنوز بخش زیادی از plumbing در خود page حل میشد.
معماری با migrate کردن pageهای واقعی تست شد
PageShell را روی یک demo ساختم و تمام نکردم.
مهاجرت بهمرور انجام شد.
در rollout کامل PageShell در ۱۵ فوریه ۲۰۲۶، پنج صفحه domain ــ Project، Product، Pump Model، Technology و Technology Step ــ در یک چرخه توسعه به قرارداد مشترک منتقل شدند. دو روز بعد، Machine و Workshop بههمراه flow انتخاب Tool برای Technology Step در یک session اضافه شدند و follow-up همان flow حدود دو ساعت بعد کامل شد. این اعداد درباره migration و integration فرانتاندند، نه مالکیت behavior دامنههای پشت این صفحهها.
اول صفحههای سادهتر، بعد صفحههایی که report یا upload داشتند، resourceهای nested، صفحههای کاملاً read-only، server table با quick filter و client tableهایی که policy متفاوتی برای delete داشتند.
بعدتر formهای دارای dependency و selection هم وارد شدند.
طبیعتاً همهچیز هم تمیز پیش نرفت.
یک helper برای data loading استخراج شده بود ولی خود PageShell هنوز از آن استفاده نمیکرد.
نسخه اولیه documentation چند مثال نیمهکاره داشت.
سه test قدیمی form system هم بعد از migration حذف شدند، چون دیگر معماریای را تست میکردند که pageهای اصلی از آن استفاده نمیکردند و نتیجهشان برای ساختار جدید قابل اعتماد نبود.
اگر امروز دوباره این migration را انجام بدهم، احتمالاً قبل از زیاد شدن consumerها چند contract test مشخص برای این قسمتها مینویسم:
- action policy
- read-only behavior
- nested param mapping
- feature composition
نه برای اینکه coverage درصد بالاتری نشان بدهد، بیشتر برای اینکه مرزهای abstraction زودتر قفل شوند.
با مقایسه همین history قبل و بعد از factoryها و PageShell، برای صفحههای CRUD مشابه و تکراری، کاهش بیش از ۷۰٪ در زمان توسعه برآورد محافظهکارانهای است. این عدد benchmark رسمی برای تمام frontend یا کل ERP نیست؛ فقط تفاوت چرخه پیادهسازی admin pageهایی را توصیف میکند که بعد از حذف wiring تکراری، بیشتر به config و integration تبدیل شدند.
چیزی که از PageShell نگه میدارم
اگر بخواهم امروز دوباره چنین ساختاری بسازم، هنوز همان سه مرز را نگه میدارم:
Typed Definition
+
Stable Orchestrator
+
Domain Behaviorfeature adapterها هم باید کوچک و explicit بمانند.
اگر روزی یک registry بزرگ بسازیم که صد نوع capability ERP را بشناسد، فقط از یک Page.tsx بزرگ رسیدهایم به یک PageShell.tsx بزرگتر.
ارزش PageShell برای من هیچوقت صرفاً کم شدن تعداد خطهای کد نبود.
تغییر مهمتر این بود که wiring تکراری pageها بالاخره یک جای مشخص پیدا کرد، بدون اینکه behavior واقعی featureها مجبور شود خودش را شبیه یک CRUD عمومی نشان بدهد.
هنوز هم یک معیار ساده برایش دارم:
اگر برای توضیح یک business rule مجبور شوم بروم داخل کد PageShell، احتمالاً آن rule جای اشتباهی قرار گرفته.