معماری برش عمودی (Vertical Slice Architecture) 🔪
معماریهای لایهای، پایه و اساس بسیاری از سیستمهای نرمافزاری هستند. با این حال، معماریهای لایهای سیستم را حول لایههای فنی سازماندهی میکنند. و انسجام (cohesion) بین لایهها پایین است.
چه میشد اگر به جای آن، میخواستید سیستم را حول ویژگیها (features) سازماندهی کنید؟ 🤔
کوپلینگ (coupling) بین ویژگیهای نامرتبط را به حداقل برسانید و کوپلینگ را در یک ویژگی واحد به حداکثر برسانید.
امروز میخواهم در مورد معماری برش عمودی صحبت کنم، که دقیقاً همین کار را انجام میدهد.
مشکل معماریهای لایهای 👎
معماریهای لایهای، سیستم نرمافزاری را به لایهها یا طبقات (tiers) سازماندهی میکنند. هر یک از لایهها معمولاً یک پروژه در سولوشن شماست. برخی از پیادهسازیهای محبوب، معماری N-tier یا معماری تمیز (Clean architecture) هستند.
معماریهای لایهای بر روی تفکیک دغدغههای (separating the concerns) کامپوننتهای مختلف تمرکز میکنند. این کار، درک و نگهداری سیستم نرمافزاری را آسانتر میکند. و مزایای زیادی برای طراحی نرمافزار ساختاریافته وجود دارد ✅، مانند قابلیت نگهداری، انعطافپذیری و اتصال سست (loose coupling).
با این حال، معماریهای لایهای محدودیتها یا قوانین سفت و سختی را نیز بر سیستم شما تحمیل میکنند. جهت وابستگیها بین لایهها از پیش تعیین شده است.
🔹 Domain
نباید هیچ وابستگی داشته باشد.
🔹 لایه Application میتواند به Domain ارجاع دهد.
🔹 Infrastructure
میتواند هم به Application و هم به Domain ارجاع دهد.
🔹 Presentation
میتواند هم به Application و هم به Domain ارجاع دهد.
شما در نهایت به coupling بالا در داخل یک لایه و coupling پایین بین لایهها میرسید. این به این معنی نیست که معماریهای لایهای بد هستند. اما به این معنی است که شما انتزاعهای (abstractions) زیادی بین لایههای مجزا خواهید داشت. و انتزاعهای بیشتر به معنای افزایش پیچیدگی است 🤯 زیرا کامپوننتهای بیشتری برای نگهداری وجود دارد.
من برای اولین بار در مورد معماری برش عمودی از جیمی بوگارد شنیدم. او همچنین خالق برخی کتابخانههای متنباز محبوب مانند MediatR و Automapper است.
معماری برش عمودی از درد کار با معماریهای لایهای متولد شد. آنها شما را مجبور میکنند برای پیادهسازی یک ویژگی، تغییراتی در لایههای بسیار متفاوتی ایجاد کنید.
بیایید تصور کنیم افزودن یک ویژگی جدید در یک معماری لایهای چگونه به نظر میرسد: 📝
🔹 بهروزرسانی مدل دامین
🔹 اصلاح منطق اعتبارسنجی
🔹 ایجاد یک مورد استفاده با MediatR
🔹 ارائه یک endpoint API از یک کنترلر
انسجام پایین است زیرا شما در حال ایجاد فایلهای زیادی در لایههای مختلف هستید.
برشهای عمودی رویکرد متفاوتی را در پیش میگیرند:
کوپلینگ بین برشها را به حداقل برسانید و کوپلینگ را در یک برش به حداکثر برسانید. 🎯
برای مثال، در معماری تمیز (Clean Architecture): ⛓️
🔹 Domain
نباید هیچ وابستگی داشته باشد.
🔹 لایه Application میتواند به Domain ارجاع دهد.
🔹 Infrastructure
میتواند هم به Application و هم به Domain ارجاع دهد.
🔹 Presentation
میتواند هم به Application و هم به Domain ارجاع دهد.
شما در نهایت به coupling بالا در داخل یک لایه و coupling پایین بین لایهها میرسید. این به این معنی نیست که معماریهای لایهای بد هستند. اما به این معنی است که شما انتزاعهای (abstractions) زیادی بین لایههای مجزا خواهید داشت. و انتزاعهای بیشتر به معنای افزایش پیچیدگی است 🤯 زیرا کامپوننتهای بیشتری برای نگهداری وجود دارد.
معماری برش عمودی (Vertical Slice Architecture) چیست؟ 🤔
من برای اولین بار در مورد معماری برش عمودی از جیمی بوگارد شنیدم. او همچنین خالق برخی کتابخانههای متنباز محبوب مانند MediatR و Automapper است.
معماری برش عمودی از درد کار با معماریهای لایهای متولد شد. آنها شما را مجبور میکنند برای پیادهسازی یک ویژگی، تغییراتی در لایههای بسیار متفاوتی ایجاد کنید.
بیایید تصور کنیم افزودن یک ویژگی جدید در یک معماری لایهای چگونه به نظر میرسد: 📝
🔹 بهروزرسانی مدل دامین
🔹 اصلاح منطق اعتبارسنجی
🔹 ایجاد یک مورد استفاده با MediatR
🔹 ارائه یک endpoint API از یک کنترلر
انسجام پایین است زیرا شما در حال ایجاد فایلهای زیادی در لایههای مختلف هستید.
برشهای عمودی رویکرد متفاوتی را در پیش میگیرند:
کوپلینگ بین برشها را به حداقل برسانید و کوپلینگ را در یک برش به حداکثر برسانید. 🎯
در این عکس نحوه تجسم برشهای عمودی آمده است:
تمام فایلها برای یک مورد استفاده واحد، در داخل یک پوشه گروهبندی میشوند. بنابراین، انسجام برای یک مورد استفاده واحد بسیار بالا است. 👍 این کار تجربه توسعه را ساده میکند. پیدا کردن تمام کامپوننتهای مرتبط برای هر ویژگی آسان است، زیرا آنها به هم نزدیک هستند.
اگر در حال ساخت یک API هستید، سیستم از قبل به کامندها (POST/PUT/DELETE) و کوئریها (GET) تقسیم میشود. با تقسیم کردن درخواستها به کامندها و کوئریها، شما از مزایای الگوی CQRS بهرهمند میشوید. 🚀
برشهای عمودی به طور محدود روی یک ویژگی واحد تمرکز میکنند. این به شما اجازه میدهد تا هر مورد استفاده را به صورت جداگانه در نظر گرفته و پیادهسازی را متناسب با نیازمندیهای خاص، سفارشی کنید. یک برش عمودی میتواند از EF Core برای پیادهسازی یک درخواست GET استفاده کند. برش عمودی دیگری میتواند از Dapper با کوئریهای SQL خام استفاده کند.
یکی دیگر از مزایای پیادهسازی برشهای عمودی به این شکل این است:
✅ ویژگیهای جدید فقط کد اضافه میکنند، شما کد اشتراکی را تغییر نمیدهید و نگران عوارض جانبی نیستید.
با این حال، برشهای عمودی مجموعه چالشهای خاص خود را دارند. ⚠️ از آنجایی که شما بخش زیادی از منطق بیزینس را داخل یک مورد استفاده واحد پیادهسازی میکنید، باید قادر به تشخیص code smells باشید. با رشد مورد استفاده، ممکن است در نهایت کار بیش از حدی انجام دهد. شما مجبور خواهید بود با انتقال منطق به دامین، کد را بازآرایی (refactor) کنید.
تمام فایلها برای یک مورد استفاده واحد، در داخل یک پوشه گروهبندی میشوند. بنابراین، انسجام برای یک مورد استفاده واحد بسیار بالا است. 👍 این کار تجربه توسعه را ساده میکند. پیدا کردن تمام کامپوننتهای مرتبط برای هر ویژگی آسان است، زیرا آنها به هم نزدیک هستند.
🎯پیادهسازی برشهای عمودی
اگر در حال ساخت یک API هستید، سیستم از قبل به کامندها (POST/PUT/DELETE) و کوئریها (GET) تقسیم میشود. با تقسیم کردن درخواستها به کامندها و کوئریها، شما از مزایای الگوی CQRS بهرهمند میشوید. 🚀
برشهای عمودی به طور محدود روی یک ویژگی واحد تمرکز میکنند. این به شما اجازه میدهد تا هر مورد استفاده را به صورت جداگانه در نظر گرفته و پیادهسازی را متناسب با نیازمندیهای خاص، سفارشی کنید. یک برش عمودی میتواند از EF Core برای پیادهسازی یک درخواست GET استفاده کند. برش عمودی دیگری میتواند از Dapper با کوئریهای SQL خام استفاده کند.
یکی دیگر از مزایای پیادهسازی برشهای عمودی به این شکل این است:
✅ ویژگیهای جدید فقط کد اضافه میکنند، شما کد اشتراکی را تغییر نمیدهید و نگران عوارض جانبی نیستید.
با این حال، برشهای عمودی مجموعه چالشهای خاص خود را دارند. ⚠️ از آنجایی که شما بخش زیادی از منطق بیزینس را داخل یک مورد استفاده واحد پیادهسازی میکنید، باید قادر به تشخیص code smells باشید. با رشد مورد استفاده، ممکن است در نهایت کار بیش از حدی انجام دهد. شما مجبور خواهید بود با انتقال منطق به دامین، کد را بازآرایی (refactor) کنید.
ساختار سولوشن با الگوی REPR 🧩
معماریهای لایهای، مانند معماری تمیز، سولوشن را در لایهها سازماندهی میکنند. این منجر به یک ساختار پوشه میشود که بر اساس دغدغههای فنی گروهبندی شده است.
معماری برش عمودی، از طرف دیگر، کد را حول ویژگیها یا موارد استفاده سازماندهی میکند.
یک رویکرد جالب برای ساختاربندی APIها حول ویژگیها، استفاده از الگوی REPR است. این مخفف Request-EndPoint-Response است. این کاملاً با ایده برشهای عمودی هماهنگ است. شما میتوانید برای مثال، این را با کتابخانه MediatR به دست آورید.
الگوی REPR تعریف میکند که endpointهای وب API باید سه کامپوننت داشته باشند:
1️⃣ Request (درخواست)
2️⃣ Endpoint (نقطه پایانی)
3️⃣ Response (پاسخ)
در اینجا یک مثال از ساختار سولوشن در NET. آمده است. شما پوشه Features را مشاهده خواهید کرد که حاوی برشهای عمودی است. هر برش عمودی یک درخواست API (یا مورد استفاده) را پیادهسازی میکند.
📂 RunTracker.API
| 📁 Database
| 📁 Entities
| #️⃣ Activity.cs
| #️⃣ Workout.cs
| #️⃣ ...
| 📁 Features
| 📁 Activities
| 📁 GetActivity
| #️⃣ ActivityResponse.cs
| #️⃣ GetActivityEndpoint.cs
| #️⃣ GetActivityQuery.cs
| #️⃣ GetActivityQueryHandler.cs
| 📁 CreateActivity
| #️⃣ CreateActivity.cs
| #️⃣ CreateActivity.Command.cs
| #️⃣ CreateActivity.Endpoint.cs
| #️⃣ CreateActivity.Handler.cs
| #️⃣ CreateActivity.Validator.cs
| 📁 Workouts
| 📁 ...
| 📁 Middleware
| 📄 appsettings.json
| 📄 appsettings.Development.json
| #️⃣ Program.cs
چند کتابخانه دیگر برای پیادهسازی الگوی REPR:
🔹 FastEndpoints
🔹 ApiEndpoints
قدمهای بعدی 🚀
ممکن است برخی از شما ایده گروهبندی تمام فایلهای مرتبط با یک ویژگی را در یک پوشه واحد دوست نداشته باشید.
با این حال، به طور کلی ارزش زیادی در گروهبندی بر اساس ویژگیها وجود دارد. شما مجبور نیستید برشهای عمودی را پیادهسازی کنید. اما میتوانید برای مثال، این مفهوم را با گروهبندی فایلها حول aggregates، به دامین خود اعمال کنید.
ممنون برای مطالعه، و عالی بمانید!🤍
📖 سری آموزشی کتاب C# 12 in a Nutshell
تو پست قبلی، با تایپهای جنریک مثل <Stack<T آشنا شدیم. اما قدرت جنریکها به کلاسها محدود نمیشه! ما میتونیم متدهایی بنویسیم که خودشون پارامترهای نوعی (<T>) داشته باشن.
به اینها متدهای جنریک (Generic Methods) میگن و به ما اجازه میدن الگوریتمهای بنیادی رو به صورت کاملاً قابل استفاده مجدد و Type-Safe بنویسیم.
فرض کنید میخوایم یه متد بنویسیم که محتوای دو تا متغیر رو با هم عوض کنه. بدون جنریکها، باید برای int، string و هر نوع دیگهای یه نسخه جدا مینوشتیم (تکرار کد!). اما با یه متد جنریک، این کار رو فقط یک بار و برای همه تایپها انجام میدیم:
بهترین بخش ماجرا اینه که در ۹۹٪ مواقع، شما نیازی ندارید که نوع T رو موقع صدا زدن متد مشخص کنید (<Swap<int). کامپایلر #C اونقدر هوشمنده که خودش از روی نوع آرگومانهایی که به متد پاس میدید، T رو تشخیص میده.
چه چیزهایی میتوانند جنریک باشند؟
در #C، پارامترهای نوعی (<T>) فقط در تعریف کلاسها، استراکتها، اینترفیسها، دلیگیتها و متدها قابل استفاده هستن. اعضای دیگه مثل پراپرتیها یا سازندهها نمیتونن پارامتر نوعی جدیدی تعریف کنن، ولی میتونن از پارامتر نوعی که کلاسشون تعریف کرده، استفاده کنن.
🔹️ چندین پارامتر جنریک:
یک تایپ یا متد جنریک میتونه چند تا پارامتر نوعی داشته باشه:
🔹️ اورلودینگ بر اساس تعداد پارامترها:
شما میتونید اسم یک تایپ یا متد رو بر اساس تعداد پارامترهای جنریکش اورلود کنید. (به تعداد پارامترهای جنریک، "Arity" گفته میشه).
🔹️ قرارداد نامگذاری:
طبق قرارداد، برای پارامترهای نوعی تک حرفی از T استفاده میشه. اگه پارامترهای بیشتری دارید، اونها رو با اسمهای توصیفیتر و با پیشوند T نامگذاری کنید (مثل TKey, TValue).
متدهای جنریک به شما اجازه میدن الگوریتمهای بنیادی رو یک بار بنویسید و همه جا به صورت type-safe ازشون استفاده کنید، که این اساس نوشتن کدهای تمیز و قابل استفاده مجدده.
🧬 متدهای جنریک در #C: نوشتن الگوریتمهای همهکاره
تو پست قبلی، با تایپهای جنریک مثل <Stack<T آشنا شدیم. اما قدرت جنریکها به کلاسها محدود نمیشه! ما میتونیم متدهایی بنویسیم که خودشون پارامترهای نوعی (<T>) داشته باشن.
به اینها متدهای جنریک (Generic Methods) میگن و به ما اجازه میدن الگوریتمهای بنیادی رو به صورت کاملاً قابل استفاده مجدد و Type-Safe بنویسیم.
1️⃣ مثال کلاسیک: متد <Swap<T
فرض کنید میخوایم یه متد بنویسیم که محتوای دو تا متغیر رو با هم عوض کنه. بدون جنریکها، باید برای int، string و هر نوع دیگهای یه نسخه جدا مینوشتیم (تکرار کد!). اما با یه متد جنریک، این کار رو فقط یک بار و برای همه تایپها انجام میدیم:
static void Swap<T>(ref T a, ref T b)
{
T temp = a;
a = b;
b = temp;
}
2️⃣ جادوی استنتاج نوع (Type Inference) ✨
بهترین بخش ماجرا اینه که در ۹۹٪ مواقع، شما نیازی ندارید که نوع T رو موقع صدا زدن متد مشخص کنید (<Swap<int). کامپایلر #C اونقدر هوشمنده که خودش از روی نوع آرگومانهایی که به متد پاس میدید، T رو تشخیص میده.
int x = 5;
int y = 10;
// نیازی به نوشتن Swap<int>(ref x, ref y) نیست!
Swap(ref x, ref y);
string s1 = "Hello";
string s2 = "World";
// کامپایلر خودش میفهمه T اینجا از نوع string هست
Swap(ref s1, ref s2);
3️⃣ قوانین و جزئیات مهم جنریکها ⚖️
چه چیزهایی میتوانند جنریک باشند؟
در #C، پارامترهای نوعی (<T>) فقط در تعریف کلاسها، استراکتها، اینترفیسها، دلیگیتها و متدها قابل استفاده هستن. اعضای دیگه مثل پراپرتیها یا سازندهها نمیتونن پارامتر نوعی جدیدی تعریف کنن، ولی میتونن از پارامتر نوعی که کلاسشون تعریف کرده، استفاده کنن.
🔹️ چندین پارامتر جنریک:
یک تایپ یا متد جنریک میتونه چند تا پارامتر نوعی داشته باشه:
// TKey و TValue دو پارامتر نوعی متفاوت هستن
class Dictionary<TKey, TValue> { /* ... */ }
🔹️ اورلودینگ بر اساس تعداد پارامترها:
شما میتونید اسم یک تایپ یا متد رو بر اساس تعداد پارامترهای جنریکش اورلود کنید. (به تعداد پارامترهای جنریک، "Arity" گفته میشه).
class A {} // Arity = 0
class A<T> {} // Arity = 1
class A<T1, T2> {} // Arity = 2🔹️ قرارداد نامگذاری:
طبق قرارداد، برای پارامترهای نوعی تک حرفی از T استفاده میشه. اگه پارامترهای بیشتری دارید، اونها رو با اسمهای توصیفیتر و با پیشوند T نامگذاری کنید (مثل TKey, TValue).
🤔 حرف حساب و تجربه شما
متدهای جنریک به شما اجازه میدن الگوریتمهای بنیادی رو یک بار بنویسید و همه جا به صورت type-safe ازشون استفاده کنید، که این اساس نوشتن کدهای تمیز و قابل استفاده مجدده.
🔖 هشتگها:
#CSharp #DotNet #OOP #Generics
📖 سری آموزشی کتاب C# 12 in a Nutshell
تو پست قبلی با جنریکها آشنا شدیم. اما پارامتر T به صورت پیشفرض یک "لوح سفید" هست و کامپایلر هیچچیزی در موردش نمیدونه. چطور میتونیم به کامپایلر بگیم که T باید چه قابلیتهایی داشته باشه؟
با استفاده از قیدهای جنریک (Generic Constraints) و کلمه کلیدی where.
به صورت پیشفرض، شما میتونید هر نوعی رو جای T قرار بدید. قیدها این امکان رو محدود میکنن، اما هدف اصلیشون فعال کردن قابلیتهای جدیده. وقتی شما یه قید میذارید، به کامپایلر میگید: "من قول میدم که T حتماً این قابلیتها رو داره" و کامپایلر هم در عوض به شما اجازه میده از اون قابلیتها استفاده کنید (مثلاً متدهای یک اینترفیس رو روی T صدا بزنید).
این لیست کامل قیدهاییه که میتونید با where استفاده کنید:
🔹️ where T : base-class
(قید کلاس پایه: T باید از این کلاس ارثبری کند)
🔹️ where T : interface
(قید اینترفیس: T باید این اینترفیس را پیادهسازی کند)
🔹️ where T : class
(قید نوع ارجاعی: T باید یک کلاس باشد)
🔹️ where T : struct
(قید نوع مقداری: T باید یک struct باشد)
🔹️ where T : new()
(قید سازنده: T باید یک سازنده بدون پارامتر داشته باشد)
🔹️ where U : T
(قید نوع عریان: پارامتر جنریک U باید از T ارثبری کند)
قید اینترفیس (: <IComparable<T):
فرض کنید میخوایم یه متد Max بنویسیم. برای این کار، باید مطمئن باشیم که T قابلیت مقایسه شدن رو داره.
این قید تضمین میکنه که T باید یک Value Type باشد. بهترین مثالش، خودِ System.Nullable<T> هست که فقط برای Value Typeها معنی داره.
این قید تضمین میکنه که T یک سازنده عمومی بدون پارامتر داره. این به ما اجازه میده که بتونیم با new T() ازش نمونه بسازیم.
قیدهای جنریک، ابزار اصلی شما برای ساختن APIهای جنریک قدرتمند، امن و کارآمد هستن.
⚙️ قدرت where: راهنمای کامل Generic Constraints در #C
تو پست قبلی با جنریکها آشنا شدیم. اما پارامتر T به صورت پیشفرض یک "لوح سفید" هست و کامپایلر هیچچیزی در موردش نمیدونه. چطور میتونیم به کامپایلر بگیم که T باید چه قابلیتهایی داشته باشه؟
با استفاده از قیدهای جنریک (Generic Constraints) و کلمه کلیدی where.
1️⃣ چرا به قیدها (Constraints) نیاز داریم؟
به صورت پیشفرض، شما میتونید هر نوعی رو جای T قرار بدید. قیدها این امکان رو محدود میکنن، اما هدف اصلیشون فعال کردن قابلیتهای جدیده. وقتی شما یه قید میذارید، به کامپایلر میگید: "من قول میدم که T حتماً این قابلیتها رو داره" و کامپایلر هم در عوض به شما اجازه میده از اون قابلیتها استفاده کنید (مثلاً متدهای یک اینترفیس رو روی T صدا بزنید).
2️⃣ انواع قیدهای جنریک
این لیست کامل قیدهاییه که میتونید با where استفاده کنید:
🔹️ where T : base-class
(قید کلاس پایه: T باید از این کلاس ارثبری کند)
🔹️ where T : interface
(قید اینترفیس: T باید این اینترفیس را پیادهسازی کند)
🔹️ where T : class
(قید نوع ارجاعی: T باید یک کلاس باشد)
🔹️ where T : struct
(قید نوع مقداری: T باید یک struct باشد)
🔹️ where T : new()
(قید سازنده: T باید یک سازنده بدون پارامتر داشته باشد)
🔹️ where U : T
(قید نوع عریان: پارامتر جنریک U باید از T ارثبری کند)
3️⃣ مثالهای کاربردی
قید اینترفیس (: <IComparable<T):
فرض کنید میخوایم یه متد Max بنویسیم. برای این کار، باید مطمئن باشیم که T قابلیت مقایسه شدن رو داره.
static T Max<T>(T a, T b) where T : IComparable<T>
{
// حالا که قید گذاشتیم، کامپایلر اجازه میده متد CompareTo رو صدا بزنیم
return a.CompareTo(b) > 0 ? a : b;
}
// استفاده:
int z = Max(5, 10); // 10
قید نوع مقداری (: struct):
این قید تضمین میکنه که T باید یک Value Type باشد. بهترین مثالش، خودِ System.Nullable<T> هست که فقط برای Value Typeها معنی داره.
struct Nullable<T> where T : struct { /* ... */ }قید سازنده (: ()new):
این قید تضمین میکنه که T یک سازنده عمومی بدون پارامتر داره. این به ما اجازه میده که بتونیم با new T() ازش نمونه بسازیم.
static void Initialize<T>(T[] array) where T : new()
{
for (int i = 0; i < array.Length; i++)
array[i] = new T();
}
🤔 حرف حساب و تجربه شما
قیدهای جنریک، ابزار اصلی شما برای ساختن APIهای جنریک قدرتمند، امن و کارآمد هستن.
🔖 هشتگها:
#CSharp #DotNet #OOP #Generics
📖 سری آموزشی کتاب C# 12 in a Nutshell
تو پست قبلی، با قیدهای جنریک آشنا شدیم. حالا میخوایم ببینیم چطور میتونیم این دو مفهوم قدرتمند، یعنی وراثت و جنریکها، رو با هم ترکیب کنیم تا سلسلهمراتبهای تایپی پیچیده و در عین حال قابل استفاده مجدد بسازیم.
یک کلاس جنریک میتونه مثل هر کلاس دیگهای به ارث برده بشه. کلاس فرزند (subclass) در این حالت چند تا انتخاب داره:
در این حالت، کلاس فرزند هم جنریک باقی میمونه و پارامتر نوعی T رو از پدر به ارث میبره.
بستن پارامتر نوعی:
شما میتونید با یه نوع مشخص، پارامتر جنریک کلاس پدر رو ببندید. در این حالت، کلاس فرزند شما دیگه جنریک نیست.
معرفی پارامترهای نوعی جدید: کلاس فرزند میتونه علاوه بر پارامترهای پدر، پارامترهای جنریک جدیدی هم برای خودش تعریف کنه.
یکی از الگوهای خیلی قدرتمند و رایج، اینه که یک تایپ، یک اینترفیس جنریک رو با خودش به عنوان پارامتر نوعی، پیادهسازی کنه. این کار برای تعریف قراردادهایی مثل قابلیت مقایسه شدن (<IEquatable<T) عالیه و به شما ایمنی نوع کامل در زمان کامپایل میده.
مثال کتاب (Balloon):
این الگو خیلی قدرتمنده و تو قیدهای جنریک هم استفاده میشه:
ترکیب وراثت و جنریکها به شما اجازه میده ساختارهای تایپی بسیار قدرتمند و انعطافپذیری طراحی کنید که اساس خیلی از کتابخانهها و فریمورکهای مدرن داتنت هستن.
🧬 وراثت در دنیای جنریکها: ساختن تایپهای پیچیده
تو پست قبلی، با قیدهای جنریک آشنا شدیم. حالا میخوایم ببینیم چطور میتونیم این دو مفهوم قدرتمند، یعنی وراثت و جنریکها، رو با هم ترکیب کنیم تا سلسلهمراتبهای تایپی پیچیده و در عین حال قابل استفاده مجدد بسازیم.
1️⃣ ارثبری از کلاسهای جنریک
یک کلاس جنریک میتونه مثل هر کلاس دیگهای به ارث برده بشه. کلاس فرزند (subclass) در این حالت چند تا انتخاب داره:
باز گذاشتن پارامتر نوعی: 📖
در این حالت، کلاس فرزند هم جنریک باقی میمونه و پارامتر نوعی T رو از پدر به ارث میبره.
class Stack<T> { /* ... */ }
class SpecialStack<T> : Stack<T> { /* ... */ }بستن پارامتر نوعی:
شما میتونید با یه نوع مشخص، پارامتر جنریک کلاس پدر رو ببندید. در این حالت، کلاس فرزند شما دیگه جنریک نیست.
class IntStack : Stack<int> { /* ... */ }معرفی پارامترهای نوعی جدید: کلاس فرزند میتونه علاوه بر پارامترهای پدر، پارامترهای جنریک جدیدی هم برای خودش تعریف کنه.
class List<T> { /* ... */ }
class KeyedList<T, TKey> : List<T> { /* ... */ }2️⃣ الگوی پیشرفته: Self-Referencing Generics 🤯
یکی از الگوهای خیلی قدرتمند و رایج، اینه که یک تایپ، یک اینترفیس جنریک رو با خودش به عنوان پارامتر نوعی، پیادهسازی کنه. این کار برای تعریف قراردادهایی مثل قابلیت مقایسه شدن (<IEquatable<T) عالیه و به شما ایمنی نوع کامل در زمان کامپایل میده.
مثال کتاب (Balloon):
public interface IEquatable<T>
{
bool Equals(T obj);
}
// کلاس Balloon، قرارداد مقایسه شدن با یک Balloon دیگر را پیادهسازی میکند
public class Balloon : IEquatable
{
public string Color { get; set; }
public int CC { get; set; }
public bool Equals(Balloon b)
{
if (b == null) return false;
return b.Color == Color && b.CC == CC;
}
}
این الگو خیلی قدرتمنده و تو قیدهای جنریک هم استفاده میشه:
// این کلاس جنریک، فقط تایپهایی رو به عنوان T قبول میکنه
// که خودشون قابل مقایسه با خودشون باشن!
class Foo<T> where T : IComparable<T> { /* ... */ }
🤔 حرف حساب و تجربه شما
ترکیب وراثت و جنریکها به شما اجازه میده ساختارهای تایپی بسیار قدرتمند و انعطافپذیری طراحی کنید که اساس خیلی از کتابخانهها و فریمورکهای مدرن داتنت هستن.
🔖 هشتگها:
#CSharp #DotNet #OOP #Generics
چگونه Middlewareهای سفارشی در ASP.NET Core بسازیم 🔧
معماری middleware در ASP.NET Core راهی قدرتمند برای ساخت و پیکربندی پایپلاین درخواست HTTP در اپلیکیشنهای شما ارائه میدهد. در این پست، شما بررسی خواهید کرد که middleware چیست و چگونه middlewareهای سفارشی در ASP.NET Core بسازید.
Middleware در ASP.NET Core چیست؟ 🤔
میدلور در ASP.NET Core یک کامپوننت نرمافزاری است که بخشی از پایپلاین اپلیکیشن است که درخواستها و پاسخها را مدیریت میکند. در ASP.NET Core چندین middleware وجود دارد که با یکدیگر در یک زنجیره ترکیب شدهاند. ⛓️
هر کامپوننت middleware در پایپلاین مسئول فراخوانی کامپوننت بعدی در توالی است. هر middleware میتواند در صورت لزوم با short-circuit کردن زنجیره، اجرای middlewareهای دیگر را متوقف کند. Middlewareها در ASP.NET Core یک پیادهسازی کلاسیک از الگوی طراحی chain of responsibility هستند.
ASP.NET Core
تعداد زیادی middleware داخلی دارد و بسیاری نیز توسط پکیجهای Nuget ارائه شدهاند. ترتیبی که middlewareها به پایپلاین اپلیکیشن اضافه میشوند، حیاتی است. ⚠️ این ترتیب تعریف میکند که چگونه درخواستهای HTTP ورودی از طریق پایپلاین عبور میکنند و پاسخها با چه توالیای بازگردانده میشوند.
میدلور ها به ترتیبی که به پایپلاین در آبجکت WebApplication اضافه شدهاند، اجرا میشوند.
چگونه یک Middleware سفارشی در ASP.NET Core بسازیم ✍️
شما میتوانید یک middleware سفارشی را به روشهای زیر ایجاد کنید:
1️⃣ ارائه یک delegate برای متد Use در کلاس WebApplication.
2️⃣ ایجاد یک کلاس Middleware بر اساس قرارداد (by convention).
3️⃣ ایجاد یک کلاس Middleware با ارثبری از اینترفیس IMiddleware.
1️⃣ با یک متد Use در کلاس WebApplication
شما میتوانید یک متد Use را روی کلاس WebApplication فراخوانی کنید تا یک middleware بسازید:
var builder = WebApplication.CreateBuilder(args);
builder.Logging.AddConsole();
var app = builder.Build();
app.Use(async (context, next) =>
{
Console.WriteLine("Request is starting...");
await next();
Console.WriteLine("Request has finished");
});
app.MapGet("/api/books", () =>
{
var books = SeedService.GetBooks(10);
return Results.Ok(books);
});
await app.RunAsync();
در این مثال هنگام فراخوانی یک endpoint به آدرس /api/books، ابتدا middleware تعریف شده در متد Use فراخوانی میشود. await next.Invoke() خودِ endpoint کتابها را فراخوانی میکند، اما قبل و بعد از آن ما یک پیام در کنسول لاگ کردهایم:
Request is starting...
Request has finished
میدلور ها به ترتیبی که به پایپلاین در آبجکت WebApplication اضافه شدهاند، اجرا میشوند. هر middleware میتواند عملیاتی را قبل و بعد از middleware بعدی انجام دهد:
🔹 قبل: اجرای عملیات قبل از فراخوانی middleware بعدی میتواند شامل تسکهایی مانند لاگینگ، احراز هویت، اعتبارسنجی و غیره باشد.
🔹 بعد: عملیات پس از فراخوانی middleware بعدی میتواند شامل تسکهایی مانند تغییر پاسخ یا مدیریت خطا باشد.
قدرت واقعی middlewareها این است که شما میتوانید آنها را آزادانه به هر ترتیبی که میخواهید، زنجیرهای کنید. برای متوقف کردن اجرای درخواست و short-cut کردن زنجیره middleware (متوقف کردن اجرای middlewareهای دیگر) - یک پاسخ را مستقیماً در HttpContext بنویسید به جای فراخوانی متد await next.Invoke():
await context.Response.WriteAsync("Some response here");2️⃣ با یک کلاس Middleware بر اساس قرارداد (By Convention)
شما میتوانید یک middleware را به یک کلاس جداگانه که از قرارداد خاصی پیروی میکند، استخراج کنید:
public class LoggingMiddleware
{
private readonly RequestDelegate _next;
public LoggingMiddleware(RequestDelegate next)
{
_next = next;
}
public async Task InvokeAsync(HttpContext context)
{
Console.WriteLine($"Request: {context.Request.Method} {context.Request.Path}");
await _next(context);
Console.WriteLine($"Response: {context.Response.StatusCode}");
}
}
برای افزودن این middleware به پایپلاین، متد UseMiddleware را روی کلاس WebApplication فراخوانی کنید:
app.Use(async (context, next) =>
{
// Middleware از مثال قبلی
});
app.UseMiddleware<LoggingMiddleware>();
در نتیجه اجرای این middleware، موارد زیر هنگام اجرای یک endpoint به آدرس /api/books در کنسول لاگ خواهد شد:
Request is starting...
Request: GET /api/books
Response: 200
Request has finished
این رویکرد "بر اساس قرارداد" نامیده میشود، زیرا کلاس middleware باید از این قوانین پیروی کند:
✅ کلاس middleware باید یک متد InvokeAsync با یک آرگومان الزامی HttpContext داشته باشد.
✅ کلاس middleware باید یک RequestDelegate بعدی را در سازنده تزریق کند.
✅ کلاس middleware، delegate RequestDelegate بعدی را فراخوانی کرده و آرگومان HttpContext را به آن پاس دهد.
3️⃣ با یک کلاس Middleware که اینترفیس IMiddleware را پیادهسازی میکند 🛡
رویکرد قبلی معایب خود را دارد: توسعهدهنده باید یک کلاس middleware بسازد که از تمام قوانین ذکر شده در بالا پیروی کند، در غیر این صورت middleware کار نخواهد کرد. اما یک راه امنتر برای ایجاد middleware وجود دارد: پیادهسازی اینترفیس IMiddleware:
public class ExecutionTimeMiddleware : IMiddleware
{
public async Task InvokeAsync(HttpContext context, RequestDelegate next)
{
var watch = Stopwatch.StartNew();
await next(context);
watch.Stop();
Console.WriteLine($"Request executed in {watch.ElapsedMilliseconds}ms");
}
}
این رویکرد بسیار امنتر است زیرا کامپایلر به شما میگوید که کلاس middleware چگونه باید باشد.
برای این رویکرد شما باید به صورت دستی ExecutionTimeMiddleware را در کانتینر DI ثبت کنید:
builder.Services.AddScoped<ExecutionTimeMiddleware>();
برای افزودن این middleware به پایپلاین، متد UseMiddleware را روی کلاس WebApplication فراخوانی کنید:
app.Use(async (context, next) =>
{
// Middleware از مثال قبلی
});
app.UseMiddleware<LoggingMiddleware>();
app.UseMiddleware<ExecutionTimeMiddleware>();
در نتیجه اجرای این middleware، موارد زیر هنگام اجرای یک endpoint به آدرس /api/books در کنسول لاگ خواهد شد:
Request is starting...
Request: GET /api/books
Request executed in 68ms
Response: 200
Request has finished
میدلرور ها و تزریق وابستگی (Dependency Injection) 💉
میدلور هایی که بر اساس قرارداد ساخته میشوند، به طور پیشفرض طول عمر Singleton دارند و تمام وابستگیهای تزریق شده در سازنده نیز باید singleton باشند. همانطور که میدانیم، middlewareها به ازای هر درخواست اجرا میشوند و شما میتوانید وابستگیهای scoped را در متد InvokeAsync بعد از HttpContext تزریق کنید. در اینجا ما یک ILoggingService را که به عنوان سرویس scoped در DI ثبت شده است، تزریق میکنیم:
builder.Services.AddScoped<ILoggingService, ConsoleLoggingService>();
در مرحله بعد:
public class LoggingMiddleware
{
private readonly RequestDelegate _next;
public LoggingMiddleware(RequestDelegate next)
{
_next = next;
}
public async Task InvokeAsync(HttpContext context, ILoggingService loggingService)
{
loggingService.LogRequest(context.Request.Method, context.Request.Path);
await _next(context);
loggingService.LogResponse(context.Response.StatusCode);
}
}
این رویکرد فقط برای کلاسهای middleware که بر اساس قرارداد ایجاد شدهاند، مناسب است. برای تزریق سرویسهای scoped به کلاسهای middleware که اینترفیس IMiddleware را پیادهسازی میکنند، به سادگی از سازنده استفاده کنید:
📌 نکته: هنگام ایجاد یک کلاس middleware که اینترفیس IMiddleware را پیادهسازی میکند - شما مسئول انتخاب یک طول عمر DI مناسب برای آن هستید. شما میتوانید middleware را به صورت Singleton, Scoped یا Transient ایجاد کنید، آنچه را که در هر مورد استفاده بهترین است، انتخاب کنید.
شما میتوانید یک middleware سفارشی را به روشهای زیر ایجاد کنید:
🔹 ارائه یک delegate برای متد Use در کلاس WebApplication.
🔹 ایجاد یک کلاس Middleware بر اساس قرارداد.
🔹 ایجاد یک کلاس Middleware با ارثبری از اینترفیس IMiddleware.
انتخاب ترجیحی من، ایجاد یک middleware با ارثبری از اینترفیس IMiddleware است. این رویکرد یک راه امنتر و راحتتر برای ایجاد middlewareها و یک استراتژی تزریق وابستگی سرراست از طریق سازنده ارائه میدهد. و همچنین کنترل کاملی بر روی طول عمر middleware به شما میدهد.
امیدوارم این مقاله برایتان مفید باشد.👋🏻
public class ExecutionTimeMiddleware : IMiddleware
{
private readonly ILoggingService _loggingService;
public ExecutionTimeMiddleware(ILoggingService loggingService)
{
_loggingService = loggingService;
}
public async Task InvokeAsync(HttpContext context, RequestDelegate next)
{
// ...
}
}
📌 نکته: هنگام ایجاد یک کلاس middleware که اینترفیس IMiddleware را پیادهسازی میکند - شما مسئول انتخاب یک طول عمر DI مناسب برای آن هستید. شما میتوانید middleware را به صورت Singleton, Scoped یا Transient ایجاد کنید، آنچه را که در هر مورد استفاده بهترین است، انتخاب کنید.
خلاصه 📝
شما میتوانید یک middleware سفارشی را به روشهای زیر ایجاد کنید:
🔹 ارائه یک delegate برای متد Use در کلاس WebApplication.
🔹 ایجاد یک کلاس Middleware بر اساس قرارداد.
🔹 ایجاد یک کلاس Middleware با ارثبری از اینترفیس IMiddleware.
انتخاب ترجیحی من، ایجاد یک middleware با ارثبری از اینترفیس IMiddleware است. این رویکرد یک راه امنتر و راحتتر برای ایجاد middlewareها و یک استراتژی تزریق وابستگی سرراست از طریق سازنده ارائه میدهد. و همچنین کنترل کاملی بر روی طول عمر middleware به شما میدهد.
امیدوارم این مقاله برایتان مفید باشد.👋🏻
📖 سری آموزشی کتاب C# 12 in a Nutshell
در دو پست قبلی، با قیدها و وراثت در دنیای جنریکها آشنا شدیم. امروز در قسمت آخر این سری، به دو تا از نکات خیلی ظریف و فنی شیرجه میزنیم که رفتار جنریکها رو در سطح حافظه و مقادیر پیشفرض نشون میده.
این نکات، درک شما رو از جنریکها کامل میکنه.
چطور میتونیم مقدار پیشفرض یک پارامتر جنریک T رو بدست بیاریم؟ چون T میتونه هر چیزی باشه (value type یا reference type)، ما نمیتونیم مستقیماً null یا 0 بهش بدیم.
راه حل #C، کلمه کلیدی default هست.
default(T)
به ما مقدار پیشفرض T رو میده:
🔹 اگه T یک Reference Type باشه، مقدارش null میشه.
🔹 اگه T یک Value Type باشه، مقدارش صفر میشه (نتیجه صفر کردن بیتهای حافظه).
این یکی از اون نکات خیلی مهمه که خیلیها رو غافلگیر میکنه.
قانون اینه: دادههای استاتیک (Static Fields/Properties) برای هر نوع بستهی جنریک (Closed Type)، منحصر به فرد و جداگانه هستن.
یعنی static فیلدِ <Bob<int هیچ ربطی به static فیلدِ <Bob<string نداره و هر کدوم در حافظه، جای خودشون رو دارن.
مثال کتاب برای درک بهتر این موضوع:
این جزئیات فنی، نشون میده که جنریکها در #C فقط یه جایگزینی ساده متن نیستن، بلکه یه مکانیزم قدرتمند در سطح CLR هستن که تایپهای کاملاً جدیدی رو در زمان اجرا تولید میکنن.
🔬 نکات عمیق جنریکها: Static Data و مقدار default
در دو پست قبلی، با قیدها و وراثت در دنیای جنریکها آشنا شدیم. امروز در قسمت آخر این سری، به دو تا از نکات خیلی ظریف و فنی شیرجه میزنیم که رفتار جنریکها رو در سطح حافظه و مقادیر پیشفرض نشون میده.
این نکات، درک شما رو از جنریکها کامل میکنه.
1️⃣ مقدار پیشفرض T (default(T))
چطور میتونیم مقدار پیشفرض یک پارامتر جنریک T رو بدست بیاریم؟ چون T میتونه هر چیزی باشه (value type یا reference type)، ما نمیتونیم مستقیماً null یا 0 بهش بدیم.
راه حل #C، کلمه کلیدی default هست.
default(T)
به ما مقدار پیشفرض T رو میده:
🔹 اگه T یک Reference Type باشه، مقدارش null میشه.
🔹 اگه T یک Value Type باشه، مقدارش صفر میشه (نتیجه صفر کردن بیتهای حافظه).
static void Zap<T>(T[] array)
{
for (int i = 0; i < array.Length; i++)
{
// مقدار پیشفرض T را در آرایه قرار میدهد
array[i] = default(T);
}
}
// از #C 7.1 به بعد، میتونیم خلاصهتر بنویسیم:
// array[i] = default;
2️⃣ تلهی دادههای استاتیک در جنریکها ⚠️
این یکی از اون نکات خیلی مهمه که خیلیها رو غافلگیر میکنه.
قانون اینه: دادههای استاتیک (Static Fields/Properties) برای هر نوع بستهی جنریک (Closed Type)، منحصر به فرد و جداگانه هستن.
یعنی static فیلدِ <Bob<int هیچ ربطی به static فیلدِ <Bob<string نداره و هر کدوم در حافظه، جای خودشون رو دارن.
مثال کتاب برای درک بهتر این موضوع:
class Bob<T>
{
public static int Count;
}
// --- نتایج ---
Console.WriteLine(++Bob.Count); // خروجی: 1 (شمارنده مخصوص int)
Console.WriteLine(++Bob.Count); // خروجی: 2 (شمارنده مخصوص int)
Console.WriteLine(++Bob.Count); // خروجی: 1 (شمارنده مخصوص string، کاملاً جداست!)
Console.WriteLine(++Bob.Count); // خروجی: 1 (شمارنده مخصوص object، این هم جداست!)
🤔 حرف حساب و تجربه شما
این جزئیات فنی، نشون میده که جنریکها در #C فقط یه جایگزینی ساده متن نیستن، بلکه یه مکانیزم قدرتمند در سطح CLR هستن که تایپهای کاملاً جدیدی رو در زمان اجرا تولید میکنن.
🔖 هشتگها:
#CSharp #DotNet #OOP #Generics #AdvancedCSharp
به نظرم یه پست دیگه در مورد Vertical Slice باید بریم هنوز جا داره✌🏻
پست امشب:
Vertical Slice Architecture Is Easier Than You Think🔥
پست امشب:
Vertical Slice Architecture Is Easier Than You Think🔥
معماری برش عمودی آسانتر از آن چیزی است که فکر میکنید 🔪
فرض کنید شما باید یک ویژگی "خروجی گرفتن از دادههای کاربر" 📤 را به اپلیکیشن NET. خود اضافه کنید. کاربران روی یک دکمه کلیک میکنند، سیستم شما خروجی دادههایشان را تولید میکند، آن را در فضای ذخیرهسازی ابری آپلود میکند و یک لینک دانلود امن به آنها ایمیل میکند.
در معماری لایهای فعلی شما با یک ساختار پوشه فنی، احتمالاً شش پوشه مختلف را لمس خواهید کرد: Controllers, Services, Models, DTOs, Repositories, و Validators. شما در solution explorer خود بالا و پایین اسکرول خواهید کرد، رشته افکار خود را از دست خواهید داد، و از خود خواهید پرسید که چرا افزودن یک ویژگی نیازمند ویرایش فایلهایی است که در سراسر پایگاه کد شما پراکنده شدهاند. 🤯
اگر این برایتان آشنا به نظر میرسد، شما تنها نیستید. اکثر توسعهدهندگان NET. با معماری لایهای "استاندارد" شروع میکنند، و کد را بر اساس دغدغههای فنی به جای ویژگیهای بیزینسی سازماندهی میکنند.
اما یک راه بهتر وجود دارد: معماری برش عمودی. ✨
معماری برش عمودی چیست؟🤔
به جای سازماندهی کد خود بر اساس لایههای فنی (Controllers, Services, Repositories)، معماری برش عمودی آن را بر اساس ویژگیهای بیزینسی سازماندهی میکند. هر ویژگی به یک "برش" خودکفا تبدیل میشود که شامل همه چیز مورد نیاز برای آن عملکرد خاص است.
اینطور به آن فکر کنید: معماری لایهای سنتی مانند سازماندهی یک کتابخانه بر اساس اندازه یا رنگ کتاب است 📚، در حالی که برشهای عمودی مانند سازماندهی بر اساس موضوع است. وقتی میخواهید در مورد تاریخ یاد بگیرید، نمیخواهید در کل کتابخانه جستجو کنید، شما تمام کتابهای تاریخ را در یک مکان میخواهید.
رویکرد سنتی در برابر برشهای عمودی
بیایید به مثال خروجی داده ما نگاه کنیم. در اینجا نحوه ساختاردهی این ویژگی در یک پروژه معمولی NET. آمده است:
ساختار لایهای سنتی: 👎
📁 Controllers/
└── UsersController.cs (export endpoint)
📁 Services/
├── IDataExportService.cs
├── DataExportService.cs
├── ICloudStorageService.cs
├── CloudStorageService.cs
├── IEmailService.cs
└── EmailService.cs
📁 Models/
├── ExportDataRequest.cs
└── ExportDataResponse.cs
📁 Repositories/
├── IUserRepository.cs
└── UserRepository.cs
حالا همان عملکرد که به صورت برشهای عمودی سازماندهی شده است:
ساختار برش عمودی: 👍
📁 Features/
└──📁 Users/
└──📁 ExportData/
├── ExportUserData.cs
└── ExportUserDataEndpoint.cs
📁 Create/
└── CreateUser.cs
📁 GetById/
└── GetUserById.cs
پوشه ExportData شامل همه چیز مربوط به خروجی گرفتن از دادههای کاربر است: درخواست، پاسخ، منطق بیزینس و endpoint API.
توجه داشته باشید که من هنوز هم ICloudStorageClient و IEmailSender را تزریق میکنم به جای اینکه آن منطق را مستقیماً در handler قرار دهم. اینها دغدغههای مشترک (cross-cutting concerns) واقعی هستند که چندین ویژگی از آنها استفاده خواهند کرد. نکته کلیدی 🔑، تمایز بین "اشتراکی چون باید باشد" در مقابل "اشتراکی چون این الگو به من گفت" است.
کد را به من نشان بده 👨💻
من ابتدا بر اساس دامنه (Users) و سپس بر اساس ویژگی (ExportData) سازماندهی میکنم. 📂 برخی تیمها مستقیماً Features/ExportUserData را ترجیح میدهند، اما من متوجه شدهام که گروهبندی دامنه زمانی که ویژگیهای زیادی دارید، کمک میکند. ویژگیهای مرتبط به صورت بصری گروهبندی شده باقی میمانند.
در اینجا ظاهر برش ویژگی (feature slice) خروجی داده ما با استفاده از یک request، handler و minimal APIها آمده است: 👇
Features/Users/ExportData/ExportUserData.cs
public static class ExportUserData
{
public record Request(Guid UserId) : IRequest<Response>;
public record Response(string DownloadUrl, DateTime ExpiresAt);
public class Handler(
AppDbContext dbContext,
ICloudStorageClient storageClient,
IEmailSender emailSender)
: IRequestHandler<Request, Response>
{
public async Task<Response> Handle(Request request, CancellationToken ct = default)
{
// Get user data
var user = await dbContext.Users
.Include(u => u.Orders)
.Include(u => u.Preferences)
.FirstOrDefaultAsync(u => u.Id == request.UserId, ct);
if (user == null)
{
throw new NotFoundException($"User {request.UserId} not found");
}
// Generate export data
var exportData = new
{
user.Email,
user.Name,
user.CreatedAt,
Orders = user.Orders.Select(o => new { o.Id, o.Total, o.Date }),
Preferences = user.Preferences
};
// Upload to cloud storage
var fileName = $"user-data-{user.Id}-{DateTime.UtcNow:yyyyMMdd}.json";
var expiresAtUtc = DateTime.UtcNow.AddDays(7);
var downloadUrl = await storageClient.UploadAsJsonAsync(
fileName,
exportData,
expiresAtUtc,
ct);
// Send email notification
await emailSender.SendDataExportEmailAsync(user.Email, downloadUrl, ct);
return new Response(downloadUrl, expiresAtUtc);
}
}
// Simple validation using FluentValidation
public sealed class Validator : AbstractValidator<Request>
{
public Validator()
{
RuleFor(r => r.UserId).NotEmpty();
}
}
}
همه چیز مربوط به خروجی گرفتن از دادههای کاربر در یک مکان قرار دارد: کوئری دیتابیس، اعتبارسنجی، منطق بیزینس، یکپارچهسازی با فضای ذخیرهسازی ابری، و نوتیفیکیشن ایمیل.
endpoint Minimal API سرراست است: 👇
public static class ExportUserDataEndpoint
{
public static void Map(IEndpointRouteBuilder app)
{
app.MapPost("/users/{userId}/export", async (
Guid userId,
IRequestHandler<ExportUserData.Request, ExportUserData.Response> handler) =>
{
var response = await handler.Handle(new ExportUserData.Request(userId));
return Results.Ok(response);
});
}
}
ما حتی میتوانستیم endpoint را داخل فایل ExportUserData.cs تعریف کنیم اگر میخواستیم همه چیز را کنار هم نگه داریم. این بیشتر یک موضوع ترجیح و قراردادهای تیمی است. هر دو رویکرد به خوبی کار میکنند. 👍
یک فایل در برابر چند فایل: انتخاب شما 📂 vs 🗂
شاید متوجه چیزی شده باشید: من همه چیز را در یک فایل واحد قرار دادم. این یک انتخاب طراحی با مزایا و معایب است.
رویکرد یک فایل (ExportUserData.cs):
public static class ExportUserData
{
public record Request(Guid UserId) : IRequest<Response>;
public record Response(string DownloadUrl, DateTime ExpiresAt);
public class Handler : IRequestHandler<Request, Response> { /* ... */ }
public class Validator : AbstractValidator<Request> { /* ... */ }
}
رویکرد چند فایل:
📁 ExportData/
├── ExportUserDataCommand.cs
├── ExportUserDataResponse.cs
├── ExportUserDataHandler.cs
├── ExportUserDataValidator.cs
└── ExportUserDataEndpoint.cs
یک فایل عالی است وقتی: ویژگی سرراست است، شما حداکثر نزدیکی و انسجام مکانی (locality) را میخواهید، و فایل از چند صد خط کد فراتر نمیرود.
تعداد خطوط کد یک قانون سختگیرانه نیست، اما اگر یک فایل فراتر از ۳۰۰-۴۰۰ خط رشد کند، برای خوانایی بهتر، تقسیم آن را در نظر بگیرید. باز هم، این یک موضوع ترجیح تیمی است و یک قانون سخت که من از آن پیروی کنم، نیست. مهم است که به غرایز خود و آنچه برای تیم شما درست به نظر میرسد، اعتماد کنید.
چند فایل بهتر کار میکند وقتی: شما منطق اعتبارسنجی پیچیده، چندین نوع پاسخ دارید، یا وقتی handler به اندازهای بزرگ میشود که میخواهید هر بار روی یک دغدغه تمرکز کنید.
شما حتی میتوانید هر دو رویکرد را در یک پروژه ترکیب کنید.
هر دو رویکرد کدهای مرتبط را کنار هم نگه میدارند. و این همان چیزی است که در معماری برش عمودی بیشترین اهمیت را دارد.
چرا این واقعاً کار میکند (و چگونه شروع کنیم) 🧠
مزایای برشهای عمودی به محض اینکه آن را امتحان کنید، آشکار میشود. مغز شما نیازی ندارد به خاطر بسپارد که کدام فایلها به کدام ویژگیها مرتبط هستند. همه چیز با هم زندگی میکند.
نیاز به اصلاح ویژگی خروجی داده دارید؟ همه چیز در پوشه ExportData است. نیازی به جستجو در لایههای Controllers, Services, و Repositories نیست. هر برش میتواند به طور مستقل تکامل یابد، بنابراین عملیات ساده CRUD ساده باقی میمانند در حالی که ویژگیهای پیچیده مانند خروجی داده میتوانند از رویکردهای پیشرفته استفاده کنند.
شما نیازی ندارید کل اپلیکیشن خود را یک شبه بازنویسی کنید. 🚀 با ویژگیهای جدید با استفاده از برشهای عمودی شروع کنید. همانطور که به کدهای موجود دست میزنید، به تدریج قطعات مرتبط را به پوشههای ویژگی منتقل کنید.
معماری خوب یعنی آسانتر کردن درک و اصلاح پایگاه کد شما. وقتی تمام کدها برای یک ویژگی با هم زندگی میکنند، شما انرژی ذهنی کمتری را صرف ناوبری در سولوشن خود میکنید و زمان بیشتری را صرف حل مشکلات واقعی میکنید.
تمام این مفاهیم به هم گره خوردهاند تا به شما در ساخت اپلیکیشنهای NET. قابل نگهداری و مقیاسپذیر کمک کنند.