آنچه در این مقاله میخوانید
معرفی 7 الگوی کدنویسی که شما را سنیور می کند
۲۳ تیر ۱۴۰۵
بهبود مهارت توسعهدهندگان از مسیر یادگیری نحو بیشتر یا ترفندهای پیچیدهتر نمیگذرد. بلوغ فنی وقتی شکل میگیرد که توسعهدهنده از پیچیده کردن بیدلیل کد پرهیز کند و چیزی بسازد که برای دیگران قابل فهم، تغییر و نگهداری باشد.
تفاوت اصلی میان یک جونیور و یک مهندس ارشد، دانستن تکنیکهای بیشتر نیست. تفاوت در این است که مهندس ارشد بدهی فنی را کاهش میدهد. کدی که او مینویسد، برای نفر بعدی مسئله کمتری ایجاد میکند.
کد تمیز را زیاد با کدی زیبا یا پر از انتزاع اشتباه میگیرند. اما تجربه رخدادهای پروداکشن، بازگشتهای اضطراری و کدبیسهای فرسوده چیز دیگری نشان میدهد: کد پایدار معمولاً ساده، قابل پیشبینی و حتی خستهکننده است.
ارزش واقعی یک کد وقتی معلوم میشود که نیازمندیها عوض شوند و سیستم همچنان قابل اعتماد بماند. این ارزش در ظاهر هوشمندانه بودن کد نیست؛ در تابآوری آن در برابر تغییر و خطاست.
هفت الگویی که در ادامه میآید، مخصوص React , Node , Java یا Cloud نیستند. اینها الگوهای تصمیمگیریاند؛ کمک میکنند باگها زودتر پیدا شوند، تغییرها سادهتر اعمال شوند و بدفهمی در تیم کمتر شود.
۱. بازگشت زودهنگام به جای هزارتوی شرط ها
اولین الگو بسیار ساده است: بازگشت زودهنگام. بعضی توسعهدهندگان تابع را مثل تونل میسازند؛ هر شرط، منطق اصلی را یک لایه عمیقتر میبرد. کاربر، بعد ورودی، بعد مجوز، بعد فلگ، بعد نتیجه دیتابیس.

وقتی منطق اصلی بالاخره ظاهر میشود، شش سطح تورفتگی پایینتر است و ذهن خواننده خسته شده. مهندس ارشد این کار را نمیکند. او اول مسیرهای نامعتبر را کنار میزند:
async function updateUserProfile(userId: string, input: ProfileInput) {
const user = await getUser(userId);
if (!user) {
throw new NotFoundError("User not found");
}
if (!input.email) {
throw new ValidationError("Email is required");
}
if (!user.canEditProfile) {
throw new ForbiddenError("User cannot edit profile");
}
return saveProfile(user.id, input);
}
این سبک نمایشی نیست و کسی هم بابت گفتن “من از شرطهای نگهبان (guard clauses) استفاده کردم” ارتقا نمیگیرد. با این حال، همین کد روش دیباگ کردن یک تیم را عوض میکند؛ چون مسیرهای شکست دیده میشوند و مسیر موفق زیر لایهها دفن نمیشود.
نسخه بد در ابتدا بیضرر به نظر میرسد:
async function updateUserProfile(userId: string, input: ProfileInput) {
const user = await getUser(userId);
if (user) {
if (input.email) {
if (user.canEditProfile) {
return saveProfile(user.id, input);
}
}
}
throw new Error("Unable to update profile");
}
این کد در ابتدا ساده به نظر میرسد، اما با اضافه شدن بررسی وضعیت حساب، مجوزهای سازمانی، تأیید ایمیل و ثبت لاگ، به یک منطق پیچیده و سختخوان تبدیل میشود.
کدهای تو در تو معنا را پنهان میکنند و باعث میشوند خواننده مجبور باشد مسیرهای زیادی را همزمان در ذهن نگه دارد.
مهندسان سنیور میدانند که خواندن کد معمولا سختتر از نوشتن آن است؛ به همین دلیل، کد را طوری طراحی میکنند که در شرایط فشار هم سریع قابل درک باشد.
البته همیشه نباید از تو در تو بودن فرار کرد. گاهی در پردازش درختها یا جریانهای پیچیده، این ساختار منطقی است. هدف این نیست که هیچوقت از nested code استفاده نکنیم؛ هدف این است که خواننده برای رسیدن به منطق اصلی، مجبور نباشد از چندین لایه شرط عبور کند.
برداشت اصلی: مسیرهای خطا را زودتر حذف کنید تا منطق اصلی کد فضای بیشتری برای دیده شدن داشته باشد.
نکات مربوط به کدنویسی تمیز را در مقاله زیر بخوانید.
بهترین نکات مربوط به کدنویسی تمیز
۲. نام گذاری بر اساس معنای کسب و کار
نام بد فقط یک ایراد ظاهری نیست؛ خودش به یک منبع باگ تبدیل میشود. بسیاری از توسعهدهندگان چیزها را بر اساس شکل فنیشان نام میگذارند: data , result , item , payload , temp ، response , obj , list , value. کد کامپایل میشود، قابلیت کار میکند، و همه هم از کنارش رد میشوند.

اما سیستم رشد میکند. حالا data یک کاربر است. result یک مجوز پرداخت (payment authorization) است. payload در واقع یک درخواست بازنشانی رمز عبور (password reset request) است. items فاکتورها هستند، اما فقط فاکتورهای پرداختنشده؛ مگر وقتی فاکتورهای لغوشده را هم شامل میشوند، چون فصل گذشته یک فیلتر تغییر کرده است.
مهندس ارشد بر اساس معنای کسبوکار (business meaning) نامگذاری میکنند، چون آن معنا بعد از تغییر پیادهسازی هم زنده میماند. این دو نمونه قابل مقایسهاند:
const result = await getData(id);
if (result.status === "active") {
await process(result);
}
در برابر نسخه زیر:
const subscription = await getSubscription(subscriptionId);
if (subscription.isBillable) {
await chargeSubscription(subscription);
}
نسخه دوم فقط تمیزتر نیست؛ به توسعهدهنده بعدی میگوید چه چیزی مهم است. این یک رکورد (record) نیست، یک اشتراک (subscription) است. شرط هم بررسی مبهم وضعیت نیست؛ سؤال این است که آیا میشود برای این اشتراک صورتحساب صادر کرد.
در باگهای پروداکشن (production bugs) دیده شده که کد از نظر فنی درست بوده، اما از نظر معنایی شفاف نبوده است. متغیری با نام activeUsers شامل کاربرانی میشده که تعلیق شده بودند، اما حذف نشده بودند. تابعی با نام syncCustomer فاکتور هم ایجاد میکرده است. یک مقدار بولی (boolean) با نام isValid به معنی “عبور از اعتبارسنجی فرانتاند (frontend validation)” بوده، نه “امن بودن برای ذخیرهسازی (persist)”.
نام دقیقتر جلوی سواستفاده از کد را میگیرد:
const usersEligibleForReactivation = await findUsersEligibleForReactivation();
این نام طولانیتر است، اما همین طولانی بودن مطلوب است، چون قانون کسبوکار پشتش دقیق تعریف شده. نام کوتاه، پیچیدگی را ساده نمیکند؛ فقط آن را به حافظه شخص دیگری منتقل میکند.
نکته ظریف این است که نامگذاری میتواند نمایشی شود. لازم نیست برای هر متغیر یک پاراگراف بنویسید. اندیس حلقه (loop index) میتواند i باشد. یک کالبک محلی (local callback) میتواند ساده باشد. هدف، نامگذاری نمایشی نیست. هدف این است که مفهومهایی نامگذاری شوند که ریسک کسبوکار را با خود حمل میکنند.
برداشت اصلی: مهندس ارشد کد را طوری نامگذاری میکند که نفر بعدی مجبور نباشد نیت را از روی پیادهسازی حدس بزند.
۳. مرزگذاری دور آشفتگی بیرونی
مهندس ارشد فرض نمیکند سیستمهای بیرونی همیشه رفتار خوبی دارند. API تغییر میکند، وبهوک دیر میرسد، فیلد یک سرویس شخص ثالث ناپدید میشود، توکن دقیقاً در بدترین لحظه منقضی میشود.

کد جونیور معمولاً اجازه میدهد این آشفتگی همهجا پخش شود:
const userName = response.data.user_name;
const isActive = response.data.status === "ACTIVE";
const plan = response.data.subscription.plan_name;
این الگو از یک فایل شروع میشود و بعد پخش میشود. طولی نمیکشد که نیمی از کدبیس شکل دقیق پاسخ یک سرویس بیرونی را حفظ میکند؛ یعنی آن سرویس عملاً بخشی از معماری داخلی سیستم شده است.
مهندس ارشد یک مرز میسازد:
function mapBillingCustomer(response: BillingCustomerResponse): Customer {
return {
id: response.id,
name: response.user_name,
isBillable: response.status === "ACTIVE",
planName: response.subscription?.plan_name ?? "Free"
};
}
این کار فقط نگاشت (mapping) نیست، مهار (containment) است. بقیه سیستم نباید بداند تأمینکننده فیلدش را user_name گذاشته یا چیز دیگر. بقیه سیستم باید شکلی را دریافت کند که تیم داخلی خودش تعریف کرده است.
همین قانون همهجا صدق میکند: ردیف خام دیتابیس نباید وارد منطق رابط کاربری شود. شکل پاسخ HTTP نباید وارد منطق دامنه شود. Stripe , Slack یا GitHub نباید به زبان کل کدبیس تبدیل شوند.
این درس معمولاً وقتی آموخته میشود که یک API بیرونی در یک بهروزرسانی جزئی نام یک فیلد را تغییر میدهد، و آن فیلد پیشتر مستقیم در کنترلر، جاب پسزمینه، آنالیتیکس و داشبورد استفاده شده. پیدا کردن همه جاها کار دشواری میشود.
با مرز مشخص، فقط یک تست اداپتر شکست میخورد. تفاوت اصلی همینجاست. مرز البته هزینه دارد؛ برای یک اپلیکیشن کوچک، لایه نگاشت کامل شاید زیادهروی باشد. اما وقتی داده بیرونی چند قابلیت را طی میکند، باید جایی باشد که آشفتگی بیرون به زبان داخلی ترجمه شود.
برداشت اصلی: هرگز نباید اجازه داد سیستمی که کنترلش دست تیم نیست، شکل سیستمی را تعیین کند که کنترلش دست تیم است.
۴. کدی که ساختن وضعیت نامعتبر را سخت می کند
یک مهندس ارشد در جلسه بازبینی کد گفته بود: “این کد اجازه میدهد حالتی که نباید وجود داشته باشد، وجود داشته باشد.” جمله در نگاه اول آزاردهنده بود چون کد کار میکرد. دو هفته بعد همان بخش شکست خورد.

مشکل از یک ابجکت کاربر بود که تمام فیلدهایش اختیاری بودند:
type User = {
id?: string;
email?: string;
role?: string;
status?: string;
};
اختیاری بودن همه فیلدها فقط باعث میشد TypeScript دیگر خطا نگیرد؛ این ایمنی نوع نیست، تسلیم شدن است. حالا اپلیکیشن مجبور بود در همهجا سؤالهای نگرانکنندهای بپرسد: این کاربر شناسه دارد؟ ایمیل دارد؟ نقش تعریف شده؟ آیا میشود ذخیرهاش کرد؟
مهندس ارشد بهجای اینکه هر فراخواننده را مجبور کند در برابر داده بیمعنا از خودش دفاع کند، وضعیتها را صادقانهتر مدل میکند:
type DraftUser = {
email: string;
role: "admin" | "member";
};
type SavedUser = {
id: string;
email: string;
role: "admin" | "member";
status: "active" | "disabled";
};
همین اصل را میتوان برای پرداخت هم به کار برد:
type Payment =
| { state: "pending"; id: string }
| { state: "authorized"; id: string; authorizationId: string }
| { state: "captured"; id: string; receiptId: string }
| { state: "failed"; id: string; reason: string };
حالا تابعی که رسید ارسال میکند، میتواند صراحتاً یک پرداخت نهاییشده را الزامی کند:
function sendReceipt(payment: Extract<Payment, { state: "captured" }>) {
return emailReceipt(payment.receiptId);
}
موضوع اصلی، پرستش تایپها نیست. همین ایده در زبانهای پویا هم با اعتبارسنجی، سازنده یا اسکیما قابل پیادهسازی است. آنچه اهمیت دارد ابزار دقیق نیست؛ نظم و پایبندی به این رویکرد است.
برداشت اصلی: مهندس ارشد صرفاً وضعیت نامعتبر را مدیریت نمیکند؛ کد را طوری طراحی میکند که آن وضعیت اصلاً جای پنهان شدن نداشته باشد.
۵. جدا کردن تصمیم از اقدامارتباط با ما

یکی از پرکاربردترین الگوها همین است: تصمیم باید از اقدام جدا شود. بخش زیادی از کدها این دو مفهوم را با هم ترکیب میکنند، مانند نمونه زیر:
async function refundInvoice(invoiceId: string) {
const invoice = await getInvoice(invoiceId);
if (invoice.status !== "paid") {
throw new Error("Invoice cannot be refunded");
}
if (invoice.refundedAt) {
throw new Error("Invoice already refunded");
}
if (invoice.amount <= 0) {
throw new Error("Invalid refund amount");
}
await paymentProvider.refund(invoice.paymentId);
await markInvoiceRefunded(invoice.id);
await sendRefundEmail(invoice.customerId);
}
این کد بد نیست و بهاندازه کافی خواندنی است. اما تصمیم و اقدام به هم چسبیدهاند. برای تست کردن شرایط بازپرداخت، باید ارائهدهنده پرداخت، دیتابیس و سرویس ایمیل ماک شوند. تستها آزاردهنده میشوند و کمکم کسی آنها را نمینویسد.
مهندس ارشد تصمیم را به یک تابع خالص منتقل میکند:
function getRefundEligibility(invoice: Invoice): RefundEligibility {
if (invoice.status !== "paid") {
return { allowed: false, reason: "Invoice is not paid" };
}
if (invoice.refundedAt) {
return { allowed: false, reason: "Invoice is already refunded" };
}
if (invoice.amount <= 0) {
return { allowed: false, reason: "Invalid refund amount" };
}
return { allowed: true };
}
بعد از این تفکیک، بخش اقدام هم کوتاه و ساده میشود:
async function refundInvoice(invoiceId: string) {
const invoice = await getInvoice(invoiceId);
const eligibility = getRefundEligibility(invoice);
if (!eligibility.allowed) {
throw new ValidationError(eligibility.reason);
}
await paymentProvider.refund(invoice.paymentId);
await markInvoiceRefunded(invoice.id);
await sendRefundEmail(invoice.customerId);
}
حالا میتوان قوانین کسبوکار را بدون ماک کردن کل دنیا تست کرد. این همان برد واقعی است. همین الگو در بررسی مجوزها، قیمتگذاری، منطق تلاش دوباره و گردش کار هم کاربرد دارد. در فریمورکهای سمت سرور مانند NestJS، این تفکیک را میتوان در سرویسها، کنترلرها و ماژولهای جدا پیاده کرد.
وقتی تصمیم و اقدام قاطیاند، اعتماد کردن به قوانین سخت است. وقتی جدا میشوند، هر قانون قابل بررسی مستقل است. لازم نیست هر تابع کوچک تفکیک شود؛ فقط تصمیمهایی که ریسک واقعی دارند لایق این کارند.
برداشت اصلی: تصمیم باید بدون فعال کردن اثرهای جانبیای که کنترل میکند، بهراحتی قابل تست باشد.
۶. خطاهایی که برای نفر بعدی مفیدند
مدیریت ضعیف خطا، غرور توسعهدهنده در لباس مبدل است. کدی که در پاسخ به شکست فقط میگوید “مشکلی پیش آمد”، به کار هیچکس نمیآید؛ نه کاربر، نه پشتیبانی، نه توسعهدهندهای که نیمهشب لاگها را میخواند.

مهندس ارشد خطا را نوعی ارتباط میداند، نه یک متن اضافی. خطای خوب داده حساس افشا نمیکند و رد پشته را در UI نمیریزد، اما به فرد درست، اطلاعات کافی برای اقدام بعدی میدهد. این دو نمونه را میتوان مقایسه کرد:
{
"message": "Something went wrong"
}
در برابر نسخه قویتر:
{
"code": "USER_EMAIL_ALREADY_EXISTS",
"message": "A user with this email already exists.",
"details": {
"field": "email"
},
"requestId": "req_8f91a2"
}
نام دقیق فیلدها اهمیت چندانی ندارد؛ بعضی تیمها errorCode میگذارند، بعضی type، بعضی قالب problem-details. اشتباه واقعی زمانی رخ میدهد که یک متن انسانی مبهم برگردانده شود و انتظار برود هر کلاینت و لاگ و داشبورد آن را درست تفسیر کند.
نحوه مشاهده و تنظیم لاگهای لینوکس در سرور مجازی اوبونتو را در مقاله زیر مطالعه کنید.
تنظیم لاگ های لینوکس
مشکل جدیتر میشود وقتی فرانتاند پیام خطا را اینطور پردازش کند:
if (error.message.includes("already exists")) {
showEmailTakenError();
}
بکاند متن را تغییر میدهد و فرانتاند از کار میافتد. یک مترجم متن رابط کاربری را تغییر میکند و منطق برنامه دچار مشکل میشود. یک endpoint دیگر مقدار مشابهی برمیگرداند و UI وضعیت اشتباهی را نمایش میدهد.
متن برای انسانهاست؛ کدها برای سیستمها هستند.
مهندسان سنیور فقط پیام خطا را در لاگها ثبت نمیکنند؛ آنها context لازم را هم اضافه میکنند تا در زمان بررسی مشکل، اطلاعات کافی برای پیدا کردن علت وجود داشته باشد:
logger.warn("Refund rejected", {
invoiceId,
customerId,
reason: eligibility.reason,
requestId
});
لاگ کردن همهچیز کار درستی نیست. ثبت اطلاعات حساس در لاگها یک اشتباه جدی است. رمز عبورها، توکنها، اطلاعات شخصی، جزئیات پرداخت و کلیدهای محرمانه نباید وارد لاگ شوند.
اما شناسههای کاربردی، context امن و دلیلهای ساختاریافته میتوانند یک رخداد را از یک فرایند حدس و بررسی، به مسیری قابلردگیری تبدیل کنند.
جمعبندی: خطاها باید به نفر بعدی کمک کنند بفهمد چه چیزی، کجا و چرا شکست خورده است و چه شواهدی این مشکل را به علت اصلی آن مرتبط میکند.
۷. بهینه سازی برای diff، نه برای demo
توسعهدهندگان جونیور معمولاً تمرکزشان روی این است که یک قابلیت روی سیستم خودشان کار کند. مهندس ارشد تمرکزش روی این است که تغییر برای بازبینی قابل فهم باشد. این تفاوت، تفاوت بنیادینی است.

یک قابلیت میتواند در demo بینقص کار کند و همچنان برای ادغام خطرناک باشد. شاید بخشهای زیادی از سیستم را لمس کرده باشد، شاید رفکتور را با تغییر رفتار قاطی کرده باشد، شاید تست فقط مسیر موفق را پوشش داده باشد. demo درست کار میکند، اما سیستم در کل پرریسکتر میشود.
مهندس ارشد با نگاه به diff فکر میکند، چون diff همان چیزی است که تیم از طریق آن تغییر را جذب و ارزیابی میکند. یک diff تمیز، داستان تغییر را روشن میکند: چه چیزی عوض شده، چرا عوض شده، و اکنون چه رفتاری متفاوت شده است. یک diff آشفته، بازبینی کد را به نوعی باستانشناسی تبدیل میکند.
نمونهای از این وضعیت را میتوان در پیام کامیت زیر مشاهده کرد:
feat: update billing flow
- refactor invoice service
- rename payment fields
- update refund logic
- change dashboard UI
- add new webhook handler
- modify retry behavior
- fix customer status bug
- update tests
چنین تغییری در واقع یک پول ریکوئست نیست، بلکه بیشتر شبیه یک یادداشت گروگانگیری است. بازبینها نمیتوانند تشخیص دهند کدام تغییرها ضروری بودهاند و کدامها فرصتطلبانه اضافه شدهاند. باگها درون شلوغی گم میشوند، و بازگشت به نسخه قبل هم پاکسازیهای نامرتبط را با خودش برمیگرداند.
الگوی پشت الگوها
این هفت مورد در ظاهر جدا از هم به نظر میرسند: شرط نگهبان، نام بهتر، مرز، وضعیت امن، تصمیم جداشده، خطای مفید، diff قابل بازبینی. اما در لایه زیرین همه آنها یک ایده مشترک را دنبال میکنند: کاهش غافلگیری.
مهندس ارشد از پیچیدگی فرار نمیکند؛ فقط میداند پیچیدگی باید جایی در سیستم بنشیند. اگر آن پیچیدگی در نامهای شفاف، مرزهای مشخص و خطاهای مفید جا نگیرد، بهناچار در ذهن آدمها پخش میشود، و نرمافزار دقیقاً از همانجا خسته و فرسوده میشود.
یک کدبیس گیجکننده تنها روند تحویل را کند نمیکند؛ اعتماد تیم را هم میخورد. توسعهدهندگان پیش از هر تغییر مکث میکنند، بیش از حد لازم تست دستی انجام میدهند، همان سوالها را دوباره در Slack مطرح میکنند، و از نزدیک شدن به ماژولهای قدیمی پرهیز میکنند.
این رفتارها نشانه ضعف توسعهدهندگان نیست. این پیامد مستقیم ساختاری است که هر تغییر را ذاتاً پرریسک جلوه میدهد.
مهندسان ارشد خوب دنبال باهوش به نظر رسیدن نیستند؛ آنها فقط تلاش میکنند تغییر بعدی، نسبت به تغییرات پیشین، تصمیمی کمی کماشتباهتر باشد. همین تفاوت است که مرز میان مهندسی سطحی و مهندسی سنیور را تعیین میکند.
کد باکیفیت، کدی نیست که میزان دانش نویسنده را نشان دهد؛ کدی است که برای توسعهدهنده بعدی، فضای کمتری برای حدس زدن باقی میگذارد.
جمع بندی
مهارت یک توسعهدهنده فقط با یادگیری ابزارها، زبانها یا ترفندهای بیشتر سنجیده نمیشود. تفاوت اصلی زمانی دیده میشود که کد نوشتهشده برای دیگر اعضای تیم قابل فهم باشد، تغییر آن ریسک کمتری داشته باشد و خطاها سریعتر پیدا شوند.
بازگشت زودهنگام، نامگذاری دقیق، مشخص کردن مرز سیستمهای بیرونی، جلوگیری از وضعیتهای نامعتبر، جدا کردن تصمیم از اقدام، نوشتن خطاهای قابل پیگیری و ایجاد تغییرهای کوچک و قابل بازبینی، همگی یک هدف مشترک دارند: کم کردن ابهام در کد.
کد خوب قرار نیست پیچیده یا هوشمندانه به نظر برسد. کد خوب به توسعهدهنده بعدی کمک میکند بدون حدس زدن، رفتار سیستم را بفهمد و با اطمینان بیشتری آن را تغییر دهد. همین ویژگی است که کدنویسی روزمره را به مهندسی نرمافزار حرفهای نزدیک میکند.

