Craft & Process · مهارت و فرایند متوسطIntermediate ~100 دقیقه مطالعه~90 min read
کد تمیز: KISS، DRY، YAGNI و بازآراییClean Code: KISS, DRY, YAGNI & Refactoring
از اینکه چرا خوانایی یک استدلال اقتصادی است تا نامگذاری، توابع کوچک، KISS/DRY/YAGNI، کاتالوگ بوی بد کد و بازآرایی، کار با کد legacy، مدیریت بدهی فنی، code review مؤثر و ابزارهایی مثل Spotless، Checkstyle، PMD، SpotBugs، Error Prone، SonarQube و ArchUnit — یعنی همان صنعتگری روزمرهای که مرز بین برنامهنویس میانی و مهندس سنیور است.From why readability is an economic argument rather than an aesthetic one, through naming, small functions, KISS/DRY/YAGNI, the code-smell and refactoring catalogues, working safely with legacy code, running technical debt as a ledger, code review that improves people as well as code, and the Java toolchain — Spotless, Checkstyle, PMD, SpotBugs, Error Prone, SonarQube and ArchUnit — the daily craft that separates a mid-level developer from a senior engineer.
پیشنیاز:Prerequisites: SOLID و الگوهای طراحی GoF در جاواSOLID & GoF Design Patterns in Java
اگر آگهیهای شغلی بکاند را کنار هم بگذاری، یک الگوی عجیب میبینی: کنار «Spring Boot»، «Kafka» و «PostgreSQL»، تقریباً همیشه یک ردیف هست که هیچ نسخه و هیچ API ندارد — «Clean Code»، «SOLID»، «آشنایی با Refactoring». این ردیف شوخی نیست و تزئینی هم نیست. تیم استخدامکننده با آن دارد یک چیز مشخص را میسنجد: وقتی شش ماه بعد کسی باید کدی که تو نوشتهای را عوض کند، چقدر طول میکشد و چقدر احتمال دارد چیزی بشکند؟
این فصل دربارهٔ زیبایی کد نیست. دربارهٔ هزینه است. یک برنامهنویس میانی کدی مینویسد که کار میکند؛ یک مهندس سنیور کدی مینویسد که میشود با خیال راحت عوضش کرد — و این مهارت، برخلاف تصور رایج، مجموعهای از قواعد سلیقهای نیست؛ یک بدنهٔ فنی دقیق است با اصطلاحات مشخص، کاتالوگهای نامدار، تکنیکهای مکانیکی و ابزار قابل اجرا در CI.
سایت جاهای دیگر SOLID و الگوهای طراحی (فصل solid-patterns)، DDD، معماری hexagonal، تست با JUnit و Mockito و استثناها را جدا آموزش داده است. این فصل آنها را دوباره درس نمیدهد؛ چیزی را میسازد که زیر همهٔ آنهاست: صنعتگری روزمرهٔ نوشتن، خواندن، بوکشیدن و بازآرایی کد.
۱. چرا خوانایی یک استدلال اقتصادی است: نسبت خواندن به نوشتن، هزینهٔ تغییر و منحنی بدهی.
۲. نامگذاری بهعنوان پربازدهترین مهارت — و حالت خاص تیم غیرانگلیسیزبان.
۳. کامنت: کدام کامنتها بو میدهند و کدامها واقعاً جای خود را دارند.
۴. توابع: کوچک، یک سطح انتزاع، قاعدهٔ stepdown، تعداد آرگومان، آرگومان boolean و CQS.
۵. مدیریت خطا بهعنوان طراحی: استثنا در برابر کد خطا، null، Optional درست، و استخراج بدنهٔ try/catch.
۶. اصول با بازآرایی واقعی: KISS، DRY (تکرار دانش، نه متن)، YAGNI، Boy Scout، POLA، Tell Don't Ask، Law of Demeter و SLA.
۷. کاتالوگ بوی بد کد و بازآرایی متناظر هرکدام، با مثال فشردهٔ Java.
۸. کاتالوگ بازآرایی و قاعدهٔ آهنین: بازآرایی بدون تست وجود ندارد.
۹. کد legacy: characterization test، seam، sprout و wrap.
۱۰. بدهی فنی بهعنوان دفتر حساب: چهار ربع، کِی بدهی درست است، چطور دیده شود.
۱۱. Code review که هم کد را بهتر میکند هم آدمها را.
۱۲. ابزار دنیای Java: Spotless، Checkstyle، PMD، SpotBugs، Error Prone، SonarQube، JaCoCo و ArchUnit با کانفیگ واقعی.
۱۳. قضاوت سنیور: کِی این قواعد باید شکسته شوند و مصاحبهگر دقیقاً چه چیزی را میسنجد.
۱. خوانایی یک استدلال اقتصادی است، نه زیباییشناختی
دو داروخانه را تصور کن. هر دو دقیقاً همان داروها را دارند. در اولی، قفسهها بر اساس دستهبندی درمانی چیده شدهاند، هر جعبه برچسب خوانا دارد و داروی نزدیک به انقضا جلوتر است. در دومی همهچیز «کار میکند» — هر دارویی که بخواهی آنجا هست — ولی چیدمان بر اساس ترتیب رسیدن محمولههاست.
روز اول هر دو یکساناند. ماه ششم، داروخانهٔ دوم برای هر نسخه سه برابر وقت میگذارد، و مهمتر: گاهی جعبهٔ اشتباه را برمیدارد. تفاوت این دو «زیبایی» نیست؛ نرخ خطا و زمان هر عملیات است.
کد هم دقیقاً همین است. دو واقعیت اندازهگیریشده پشت کل این فصل ایستادهاند:
یک: کد بهمراتب بیشتر خوانده میشود تا نوشته. تو یک متد را یک بار مینویسی و بعد دهها بار — موقع دیباگ، موقع review، موقع افزودن قابلیت، موقع پاسخ به یک incident — میخوانیاش. هر ثانیهای که در نوشتن صرفهجویی میکنی و به خواندن تحمیل میکنی، با ضریب بزرگتر از یک برمیگردد.
دو: هزینهٔ عمر یک سیستم را نگهداری تعیین میکند، نه ساخت اولیه. یک سیستم موفق سالها زنده میماند و در تمام آن سالها تغییر میکند. اگر ساخت اولیه ۲۰٪ هزینهٔ کل باشد، هر تصمیمی که فهم و تغییر کد را سخت کند، روی آن ۸۰٪ ضرب میشود.
از این دو، یک نتیجهٔ مهم بیرون میآید که آن را در مصاحبه باید بتوانی بگویی: «کد تمیز» یعنی بهینهسازی برای خواننده و برای تغییردهنده، نه برای نویسنده. هر بحث سلیقهای دربارهٔ سبک، وقتی به این معیار برگردد، تصمیمپذیر میشود.
نمودار: حلقهٔ بازخورد هزینهٔ تغییر — Diagram: the cost-of-change feedback loop
flowchart LR
A[Unclear code] --> B[Longer to understand]
B --> C[Change made<br/>with low confidence]
C --> D[Defects and rework]
D --> E[Pressure to ship fast]
E --> F[Copy-paste and patches]
F --> A
C --> G[Tests + refactor]
G --> H[Clearer code]
H --> B
حلقهٔ بالا دو مسیر دارد و هر دو خودتقویتکنندهاند. مسیر پایین (تست + بازآرایی) هزینهٔ فهم را کم میکند؛ مسیر بالا آن را زیاد میکند. کاری که یک سنیور میکند این نیست که همیشه کد بینقص بنویسد؛ این است که تشخیص بدهد تیم روی کدام حلقه است و آن را برگرداند.
«بعداً» یک زمان روی تقویم نیست. کدی که با فشار ددلاین کثیف نوشته میشود، دقیقاً همان کدی است که فردا کسی میترسد دستش بزند — پس نه بازآرایی میشود و نه تست میگیرد. تنها شکل قابلدفاع «بعداً» این است: یک تیکت با شمارهٔ مشخص، یک مالک، و یک تاریخ در همان اسپرینت یا اسپرینت بعد. اگر این سه چیز نیست، تو داری بدهی میگیری بدون اینکه آن را ثبت کنی — و در بخش ۱۰ میبینی که چرا این بدترین حالت است.
جواب ضعیف: «چون کد خواناتر و قشنگتر میشود و بقیه راحتتر میفهمند.»
جواب سنیور: «چون هزینهٔ مالکیت نرمافزار عمدتاً هزینهٔ تغییر آن است، نه هزینهٔ نوشتن اولیهاش. کد بیشتر خوانده میشود تا نوشته، پس بهینهسازی باید برای خواننده باشد. اثر عملیاش هم قابل اندازهگیری است: زمان lead time برای یک تغییر کوچک، نرخ change failure، و زمان onboarding یک نفر جدید. من کد تمیز را یک اهرم روی این سه عدد میبینم، نه یک ترجیح سلیقهای. جایی هم که این سه عدد بهبود نمییابند — مثلاً یک اسکریپت یکبارمصرف — سرمایهگذاری روی تمیزی را کم میکنم.»
جملهٔ آخر همان چیزی است که جواب را از «حفظکرده» به «قضاوتدار» تبدیل میکند.
۲. نامگذاری: پربازدهترین مهارت روزانه
وقتی اسبابکشی میکنی، روی جعبهها چه مینویسی؟ «جعبه ۳» یا «ظروف آشپزخانه — شکستنی»؟ هر دو یک اسماند و هر دو «کار میکنند». تفاوت وقتی معلوم میشود که ساعت یازده شب دنبال قابلمه میگردی. نام خوب، جستوجو را حذف میکند.
نامگذاری بالاترین نسبت «اثر به تلاش» را در کل مهندسی نرمافزار دارد: تغییر یک نام هزینهٔ اجرایی صفر دارد (IDE انجامش میدهد)، ولی میتواند کل نیاز به یک کامنت یا حتی به یک دیاگرام را حذف کند.
۲.۱ نام باید نیت را فاش کند
نام خوب به سه سؤال جواب میدهد: این چیست، چهکار میکند، چطور استفاده میشود؟ اگر برای جواب دادن به این سه، مجبوری کامنت بگذاری، یعنی نام شکست خورده است.
// قبل: نامها هیچ نیتی را فاش نمیکنند
public List<int[]> getThem() {
List<int[]> list1 = new ArrayList<>();
for (int[] x : theList) {
if (x[0] == 4) { // 4 یعنی چه؟
list1.add(x);
}
}
return list1;
}
// بعد: بدون هیچ کامنتی، همهچیز روشن است
public List<Cell> flaggedCells() {
List<Cell> flaggedCells = new ArrayList<>();
for (Cell cell : gameBoard) {
if (cell.isFlagged()) {
flaggedCells.add(cell);
}
}
return flaggedCells;
}
هیچچیز در ساختار کد عوض نشد — نه یک if کم شد نه یک حلقه. فقط نامها و یک نوع داده عوض شدند و کد از «رمزنگاریشده» به «خودتوضیح» رسید. این دقیقاً معنی «نامگذاری اهرم است».
۲.۲ چهار خطای کلاسیک نامگذاری
| خطا | مثال بد | چرا بد است | اصلاح |
|---|---|---|---|
| اطلاعات غلط (disinformation) | accountList که در واقع Set است |
خواننده رفتار List (ترتیب، تکرار) را فرض میکند |
accounts |
| کلمهٔ بیاثر (noise word) | CustomerData، CustomerInfo، CustomerObject |
Data/Info/Object هیچ تمایزی نمیسازند |
یکی را انتخاب کن: Customer |
| تمایز بیمعنا | getActiveAccount() و getActiveAccounts() و getActiveAccountInfo() |
خواننده نمیداند کدام را صدا بزند | نامها را بر اساس تفاوت واقعی رفتار بگذار |
| نام غیرقابل جستوجو | d، e، عدد 86400 |
grep کردنشان غیرممکن است |
elapsedTimeInDays، SECONDS_PER_DAY |
قاعدهٔ عملی طول نام: طول نام باید متناسب با دامنهٔ دیدش باشد. i در یک حلقهٔ سهخطی کاملاً درست است؛ یک فیلد static در یک کلاس عمومی باید نامی داشته باشد که در کل مخزن یکتا و قابل جستوجو باشد.
۲.۳ نامهای دامنهٔ مسئله در برابر دامنهٔ راهحل
اگر مفهوم متعلق به کسبوکار است، از زبان کسبوکار استفاده کن (SettlementBatch, ChargebackWindow). اگر متعلق به فنی است، از زبان فنی (RetryingHttpClient, OrderRepository). قاطی کردنشان — مثل OrderManagerHelperImpl — نشانهٔ این است که خودت هم نمیدانی آن کلاس چه مسئولیتی دارد. این همان «زبان فراگیر» (ubiquitous language) است که در فصل DDD عمیقتر میبینی؛ نامگذاری، سطح روزمره و کوچکمقیاس همان ایده است.
اگر در یک پروژه هم fetchUser، هم getUser، هم retrieveUser و هم loadUser داری، خواننده وقت میگذارد تا بفهمد اینها فرق دارند یا نه — و معمولاً ندارند. یک فعل را برای یک عمل انتخاب کن و در کل مخزن به آن پایبند بمان. برعکسش هم درست است: اگر add را برای «افزودن به مجموعه» استفاده میکنی، همان add را برای «جمع دو عدد» به کار نبر.
یک قرارداد ساده که در تیمها خوب جواب میدهد: get* برای دسترسی ارزان و بدون I/O، fetch*/load* وقتی از شبکه یا دیتابیس میآید، find* وقتی ممکن است چیزی پیدا نشود (و Optional برمیگرداند)، require*/getOrThrow وقتی نبودن یعنی خطا.
۲.۴ حالت خاص: نامگذاری در تیم غیرانگلیسیزبان
این بخش تقریباً در هیچ کتاب انگلیسیزبانی نیست ولی برای تیمهای فارسیزبان روزمره است.
قاعدهٔ اول و غیرقابل مذاکره: شناسهها انگلیسیاند. نه به این دلیل که انگلیسی بهتر است، بلکه به این دلایل مهندسی: کل اکوسیستم (فریمورکها، کتابخانهها، لاگها، پیامهای خطا) انگلیسی است و قاطیکردن دو زبان در یک خط، خواندن را کند میکند؛ ابزارها و جستوجوها با یک الفبا بهتر کار میکنند؛ و عضو بعدی تیم ممکن است فارسی نداند.
قاعدهٔ دوم: آوانویسی ممنوع. getMoshtari(), tarikhSodur, mablaghKol بدترین حالت ممکناند — نه برای فارسیزبان خوانا هستند نه برای انگلیسیزبان، و در هیچ فرهنگ لغتی نیستند.
قاعدهٔ سوم — و اینجاست که ظرافت هست: برای واژههای تخصصی دامنه که ترجمهٔ دقیق ندارند، یک واژهنامهٔ تیمی بساز و در مخزن نگه دار. مثلاً «سند حسابداری» را چه مینامی؟ Voucher؟ JournalEntry؟ AccountingDocument؟ هر سه در جایی درستاند. تصمیم بگیر، بنویس، و همه از همان استفاده کنند. یک فایل GLOSSARY.md با دو ستون (اصطلاح دامنه به فارسی / شناسهٔ انگلیسی مصوب) بیشتر از هر ابزاری در انسجام نامگذاری تیم اثر دارد.
قاعدهٔ چهارم: کامنت و مستندات میتوانند فارسی باشند اگر تیم فارسیزبان است و این تصمیم آگاهانه گرفته شده — ولی تصمیم باید یکدست باشد. بدترین حالت، مخزنی است که نیمی از کامنتهایش فارسی و نیمی انگلیسی است. یک استثنای مهم: پیامهای commit و متن Javadoc کتابخانههای عمومی بهتر است انگلیسی بمانند.
یک تلهٔ رایج در پروژههای فارسی: تیم برای اینکه «کاربر فارسی ببیند»، متن فارسی را مستقیم داخل کد میگذارد — در enum، در پیام استثنا، در پاسخ API. نتیجه: تغییر یک واژهٔ فارسی نیاز به دیپلوی دارد، تستها به رشتهٔ فارسی وابسته میشوند و i18n بعداً غیرممکن میشود.
مرز درست: شناسه و کد پایدارِ ماشینخوان انگلیسی است، متن نمایشی از منبع جدا میآید.
public enum OrderStatus {
PENDING_PAYMENT,
PAID,
SHIPPED,
CANCELLED
}
و متن فارسی در messages_fa.properties با کلید order.status.PENDING_PAYMENT مینشیند. جزئیات مکانیزم را فصل مربوط به Spring پوشش میدهد؛ نکتهٔ این فصل فقط این است: نام، بخشی از قرارداد است؛ نمایش، نیست.
«اول نامها را درست کنم.» و دلیلش: تغییر نام تنها بازآراییای است که ریسک عملی نزدیک به صفر دارد (IDE بهصورت type-safe انجامش میدهد، رفتار عوض نمیشود)، ولی بیشترین اطلاعات را به خواننده اضافه میکند. بعد از آن، کد را دوباره میخوانم و میبینم بعضی مشکلات ساختاری که قبلاً پنهان بودند حالا خودشان را نشان میدهند — مثلاً دو متغیر که اسم درستشان یکی میشود، یعنی تکرار دانش دارم؛ یا کلاسی که برای نامگذاری درستش باید سه اسم بگذارم، یعنی سه مسئولیت دارد.
۳. کامنت: کدامها بو میدهند و کدامها جای خود را دارند
جملهٔ کلیدی این است: هر کامنت یک شکست کوچک در بیانگری کد است — که گاهی شکست اجتنابناپذیر و کاملاً درستی است.
منطق پشتش ساده است: کامنت کامپایل نمیشود، تست نمیشود و هیچ ابزاری نمیتواند مطمئن شود با کد همگام مانده. پس کامنت بهمرور دروغ میشود، و کامنت دروغ از نبودن کامنت بدتر است.
۳.۱ کامنتهایی که بو میدهند
// بد: بازگویی کد
// اگر کاربر فعال است و اشتراک دارد
if (user.isActive() && user.hasSubscription()) { ... }
// بد: کامنت بهجای نام خوب
int d; // elapsed time in days
// بد: کامنت تاریخچهای — کارِ git است
/*
* 2024-08-03 - A: method added
* 2024-08-25 - B: rounding bug fixed
*/
// بد: کد کامنتشده — حذفش کن، git یادش هست
// processLegacyFormat(payload);
// بد: Javadoc تشریفاتی که هیچ نمیگوید
/**
* Gets the name.
* @return the name
*/
public String getName() { return name; }
راهحلِ سه مورد اول در همان بخش نامگذاری بود: if (user.canAccessPremiumContent())، int elapsedTimeInDays;.
۳.۲ کامنتهایی که واقعاً جای خود را دارند
کامنت خوب به «چرا» جواب میدهد، نه به «چه». کد همیشه «چه» را میگوید؛ آنچه کد نمیتواند بگوید، نیت، محدودیت بیرونی و تاریخچهٔ تصمیم است.
// ۱) توضیح یک تصمیم غیرشهودی — اطلاعاتی که در کد نیست
// Provider's API rejects more than 50 IDs per call (documented limit),
// so we chunk here rather than in the caller.
private static final int PROVIDER_BATCH_SIZE = 50;
// ۲) هشدار دربارهٔ عواقب
// NOT thread-safe: SimpleDateFormat keeps mutable state.
// Each call creates its own instance on purpose.
// ۳) توضیح یک regex یا الگوریتم فشرده
// Matches IBAN: 2 letters (country) + 2 check digits + up to 30 alphanumerics
private static final Pattern IBAN = Pattern.compile("^[A-Z]{2}\\d{2}[A-Z0-9]{1,30}$");
// ۴) TODO با تیکت و مالک — نه TODO یتیم
// TODO(PLAT-4821): remove after the legacy pricing service is decommissioned (Q3).
// ۵) Javadoc قرارداد برای API عمومی: پیششرط، پسشرط، استثنا
/**
* Reserves stock for an order.
*
* @param orderId non-null identifier of an existing order
* @return the reservation, never {@code null}
* @throws InsufficientStockException if any line cannot be fully reserved;
* no partial reservation is created in that case
*/
مورد پنجم مهم است: برای کدی که دیگران (تیم دیگر، مصرفکنندهٔ کتابخانه) صدا میزنند، Javadoc بخشی از قرارداد است، نه توضیح اضافه. آنچه باید بنویسی، چیزی است که از امضای متد قابل استنتاج نیست: آیا null مجاز است؟ آیا در صورت خطا اثر جزئی میماند؟ آیا thread-safe است؟
«شاید بعداً لازم شود» یک توهم است. آن کد در git هست و با git log -S پیدا میشود. کد کامنتشده سه ضرر مشخص دارد: خواننده وقت میگذارد تا بفهمد چرا آنجاست، ابزارهای جستوجو آن را بهعنوان استفادهٔ واقعی میشمارند، و بهمرور هیچکس جرئت نمیکند حذفش کند چون نمیداند چرا مانده. اگر واقعاً لازم است، یک شاخه بساز یا در تیکت لینکش کن.
نه — این یک شعار بد است. جواب دقیق: کامنتی که «چه» را میگوید نشانهٔ آن است که کد بهاندازهٔ کافی گویا نیست، و درمانش بازآرایی است نه کامنت. ولی کامنتی که «چرا» را میگوید اطلاعاتی دارد که اصلاً در کد قابل بیان نیست: یک محدودیت مستندشدهٔ سرویس بیرونی، یک کارِ آگاهانهٔ عجیب برای دور زدن یک باگ، یا دلیل رد کردن راهحل بدیهیتر. در پروژههای واقعی من دنبال حذف کامنت نیستم؛ دنبال جابهجا کردن آنها از دستهٔ «چه» به دستهٔ «چرا» هستم — و برای تصمیمهای بزرگتر، ADR مینویسم بهجای کامنت.
۴. توابع: کوچک، یکسطحی، و قابل خواندن مثل نثر
۴.۱ کوچک، و بعد کوچکتر
قاعدهای که تجربی است و بیرحم: تابع باید فقط یک کار بکند؛ آن یک کار را خوب بکند؛ و فقط همان را بکند. تست عملیاش این است: اگر میتوانی از تابع، تابع دیگری استخراج کنی که نامش صرفاً بازگویی پیادهسازی نباشد، پس تابع بیش از یک کار میکرد.
۴.۲ یک سطح انتزاع و قاعدهٔ stepdown
بزرگترین دلیل ناخوانا بودن یک تابع، مخلوط شدن سطوح انتزاع است: یک خط دربارهٔ مفهوم کسبوکار حرف میزند و خط بعدی دربارهٔ ایندکس یک آرایه.
قاعدهٔ stepdown: کد باید مثل یک متن از بالا به پایین خوانده شود؛ هر تابع، توابعی را صدا بزند که دقیقاً یک سطح پایینتر از خودش هستند.
// قبل: چهار سطح انتزاع در یک متد
public void processOrder(Order order) {
if (order == null || order.getLines().isEmpty()) {
throw new IllegalArgumentException("bad order");
}
BigDecimal total = BigDecimal.ZERO;
for (OrderLine line : order.getLines()) {
BigDecimal lineTotal = line.getUnitPrice()
.multiply(BigDecimal.valueOf(line.getQuantity()));
if (line.getDiscountPercent() > 0) {
lineTotal = lineTotal.multiply(
BigDecimal.ONE.subtract(
BigDecimal.valueOf(line.getDiscountPercent())
.divide(BigDecimal.valueOf(100), 4, RoundingMode.HALF_UP)));
}
total = total.add(lineTotal);
}
if (order.getCustomer().getTier() == Tier.GOLD) {
total = total.multiply(new BigDecimal("0.95"));
}
order.setTotal(total.setScale(2, RoundingMode.HALF_UP));
jdbcTemplate.update("UPDATE orders SET total = ? WHERE id = ?",
order.getTotal(), order.getId());
mailSender.send(order.getCustomer().getEmail(), "Order confirmed", "...");
}
// بعد: هر متد یک سطح، خوانده میشود مثل فهرست مطالب
public void processOrder(Order order) {
validate(order);
Money total = pricing.priceOf(order);
order.applyTotal(total);
orderRepository.save(order);
notifications.orderConfirmed(order);
}
و یک سطح پایینتر:
final class OrderPricing {
private static final BigDecimal GOLD_TIER_FACTOR = new BigDecimal("0.95");
Money priceOf(Order order) {
Money subtotal = order.lines().stream()
.map(this::priceOfLine)
.reduce(Money.ZERO, Money::plus);
return applyTierDiscount(subtotal, order.customerTier());
}
private Money priceOfLine(OrderLine line) {
return line.unitPrice()
.times(line.quantity())
.minusPercent(line.discountPercent());
}
private Money applyTierDiscount(Money amount, Tier tier) {
return tier == Tier.GOLD ? amount.times(GOLD_TIER_FACTOR) : amount;
}
}
چند چیز همزمان درست شد: سطوح انتزاع جدا شدند، BigDecimal خام پشت یک نوع دامنه (Money) پنهان شد (پادزهر «وسواس نوع اولیه» که در بخش ۷ میبینی)، عدد جادویی 0.95 نام گرفت، و بدنهٔ processOrder حالا قابل بازگویی به زبان کسبوکار است.
۴.۳ تعداد آرگومان
ترتیب مطلوب: صفر > یک > دو > سه. از سه به بالا نیاز به توجیه دارد.
- صفر آرگومان (niladic): بهترین. معمولاً یعنی تابع روی وضعیت خود شیء کار میکند.
- یک آرگومان (monadic): یا سؤالی میپرسد (
boolean exists(File f)) یا چیزی را تبدیل میکند (InputStream open(File f)). - دو آرگومان (dyadic): وقتی دو مقدار جزء طبیعی یک مفهوماند (
new Point(x, y)) درست است.assertEquals(expected, actual)بد است چون ترتیبش حفظکردنی است، نه استنتاجشدنی. - سه و بیشتر: تقریباً همیشه یعنی چند آرگومان با هم یک مفهوماند و باید در یک شیء بسته شوند → بازآرایی Introduce Parameter Object.
// بد: خواننده باید ترتیب را حفظ کند و کامپایلر هم کمکی نمیکند
transfer(fromAccountId, toAccountId, amount, currency, reference, true, false);
// بهتر: مقصود از هر مقدار در نوعش پیداست
transfer(new TransferCommand(
AccountId.of(fromAccountId),
AccountId.of(toAccountId),
Money.of(amount, currency),
Reference.of(reference)));
sendReport(true) در محل فراخوانی هیچ معنایی ندارد. خواننده مجبور است به امضای متد برود. بدتر: یک boolean در امضای متد یعنی متد دو کار متفاوت میکند و شما آنها را در یک بدنه فشردهاید — یعنی «یک تابع، یک کار» نقض شده.
// بد
public void save(Order order, boolean validate) {
if (validate) { validator.check(order); }
repository.persist(order);
}
// خوب: بازآرایی Remove Flag Argument
public void save(Order order) {
repository.persist(order);
}
public void validateAndSave(Order order) {
validator.check(order);
save(order);
}
استثنای واقعی: وقتی boolean یک دادهٔ دامنه است، نه یک سوئیچ رفتاری — مثل new User(name, emailVerified). حتی آنجا هم اگر بیش از یک boolean داری، یک enum یا شیء بساز تا در محل فراخوانی معنا داشته باشد.
۴.۴ جداسازی پرسش از فرمان (CQS)
Command-Query Separation: هر تابع یا کاری میکند (وضعیت را عوض میکند و چیزی برنمیگرداند) یا به سؤالی جواب میدهد (چیزی برمیگرداند و هیچ چیزی را عوض نمیکند) — نه هر دو.
// بد: هم میپرسد هم عوض میکند. خواننده در محل فراخوانی گمراه میشود:
// if (set("username", "admin")) ... یعنی چه؟ «اگر تنظیم شد» یا «اگر وجود داشت»؟
public boolean set(String attribute, String value) { ... }
// خوب
if (attributeExists("username")) {
setAttribute("username", "admin");
}
چند مورد شناختهشده عمداً آن را میشکنند و درست هم هستند: Stack.pop()، Iterator.next()، Queue.poll()، Map.put() که مقدار قبلی را برمیگرداند، و هر عملیات atomic مثل AtomicInteger.getAndIncrement(). در همهٔ اینها نقض CQS دلیل دارد: بدون آن، عملیات دیگر atomic نیست یا قابل استفاده نیست.
قاعدهٔ سنیور: CQS را پیشفرض بگیر؛ وقتی میشکنی، در نامِ متد صراحتاً اعلامش کن (getAndIncrement، putIfAbsent، removeAndReturn). چیزی که واقعاً کد را خراب میکند نقض CQS نیست، نقض پنهانِ آن است.
۴.۵ آرگومان خروجی و اثر جانبی پنهان
آرگومان خروجی (void appendFooter(StringBuilder report)) خواننده را مجبور میکند امضا را بخواند تا بفهمد چه چیزی عوض میشود. در Java مدرن معمولاً بهتر است مقدار برگردانی و ورودی را تغییرناپذیر نگه داری. اثر جانبی پنهان بدتر است: متدی به نام checkPassword که در حین کار، session را هم reset میکند. آن اثر باید یا در نام باشد یا اصلاً نباشد.
این سؤال یک تله است. جواب سنیور با عدد شروع نمیشود: «معیار من تعداد خط نیست، تعداد سطوح انتزاع و تعداد دلایل تغییر است. متدی که ۳۰ خط اما همه در یک سطحاند و یک کار میکنند — مثلاً یک switch روی یک enum — کاملاً قابل قبول است؛ متد ۸ خطی که هم منطق کسبوکار دارد هم SQL هم فرمتبندی رشته، بد است. عملاً وقتی مجبور میشوم برای فهمیدن متد اسکرول کنم، یا وقتی نمیتوانم در یک جمله بگویم چهکار میکند بدون گفتن «و»، وقت استخراج است. در تیم برای Checkstyle یک سقف مثل ۶۰ خط میگذارم، ولی بهعنوان زنگ خطر نه بهعنوان قانون.»
۵. مدیریت خطا بهعنوان طراحی
مکانیک استثناها (checked/unchecked، try-with-resources، زنجیرهٔ cause) در فصل exceptions آمده است. اینجا فقط بُعد طراحی را میبینیم: خطا بخشی از API توست، نه یک اتفاق حاشیهای.
۵.۱ استثنا بهجای کد خطا
کد خطا، منطق اصلی را با منطق خطا درهم میبافد و مجبورت میکند بلافاصله بعد از هر فراخوانی بررسی کنی. استثنا این دو را جدا میکند.
// بد: مسیر اصلی زیر بررسی خطاها گم شده
int code = device.shutDown();
if (code == DeviceCode.OK) {
int c2 = registry.unregister(device);
if (c2 == DeviceCode.OK) { log.info("done"); }
else { log.error("unregister failed"); }
} else {
log.error("shutdown failed");
}
// خوب: مسیر اصلی خوانا، خطا در یک جا
try {
device.shutDown();
registry.unregister(device);
} catch (DeviceOperationException e) {
log.error("Could not decommission device {}", device.id(), e);
}
۵.۲ بدنهٔ try/catch را استخراج کن
try باید تقریباً اولین کلمهٔ متد باشد و بعد از catch چیزی نماند. دلیلش همان «یک تابع یک کار» است: مدیریت خطا خودش یک کار است.
public void delete(Page page) {
try {
deletePageAndAllReferences(page);
} catch (DataAccessException e) {
throw new PageDeletionFailedException(page.id(), e);
}
}
private void deletePageAndAllReferences(Page page) {
pageRepository.delete(page);
referenceRepository.deleteByPage(page.id());
searchIndex.remove(page.id());
}
۵.۳ استثنایی تعریف کن که فراخواننده لازم دارد
بزرگترین اشتباه در طراحی استثنا این است که سلسلهمراتب استثنا را بر اساس منبع خطا بسازی، در حالی که فراخواننده بر اساس کاری که باید بکند تصمیم میگیرد.
// بد: فراخواننده مجبور است سه چیز بیربط را جدا مدیریت کند، چون همه یک واکنش دارند
try {
port.call();
} catch (SocketTimeoutException | ConnectException | SSLHandshakeException e) {
// همهشان یعنی: الان نمیشود، بعداً دوباره تلاش کن
}
// خوب: یک لایهٔ نازک که خطای فنی را به مفهوم دامنه ترجمه میکند
class PaymentGatewayClient {
PaymentResult charge(ChargeRequest request) {
try {
return httpPort.post(request);
} catch (IOException | SSLException e) {
throw new PaymentGatewayUnavailableException(request.reference(), e);
}
}
}
حالا فراخواننده یک تصمیم دارد، نه سهتا. این همان الگوی wrapper است که همزمان وابستگی به کتابخانهٔ HTTP را هم از منطق کسبوکار جدا میکند.
۵.۴ نه null برگردان، نه null پاس بده
null یک مقدار نیست؛ یک بمب ساعتی است که در محل انفجارش هیچ اطلاعاتی دربارهٔ منشأ ندارد.
// بد
List<Employee> employees = getEmployees();
if (employees != null) { // این if در هر محل فراخوانی تکرار میشود
for (Employee e : employees) { ... }
}
// خوب: مجموعهٔ خالی، نه null
public List<Employee> employees() {
return employees == null ? List.of() : List.copyOf(employees);
}
برای پارامترها، در مرز عمومی صریح باش:
public Reservation reserve(OrderId orderId, Quantity quantity) {
Objects.requireNonNull(orderId, "orderId");
Objects.requireNonNull(quantity, "quantity");
...
}
Optional در JDK با یک هدف مشخص طراحی شد: نوع بازگشتی متدهایی که ممکن است نتیجهای نداشته باشند. سه سوءاستفادهٔ رایج:
۱. فیلد Optional — Optional سریالایزبل نیست، در Jackson/JPA رفتار عجیب میسازد و یک لایهٔ اضافه به هر شیء اضافه میکند.
۲. پارامتر Optional — فراخواننده مجبور است Optional.of(x) بنویسد؛ بهجایش overload بساز.
۳. optional.get() بدون بررسی — دقیقاً همان NullPointerException است با نامی دیگر (در JDK جدید NoSuchElementException).
الگوی درست:
public Optional<Customer> findByEmail(Email email) { ... }
// در فراخواننده — بدون get()
Customer customer = customers.findByEmail(email)
.orElseThrow(() -> new CustomerNotFoundException(email));
String label = customers.findByEmail(email)
.map(Customer::displayName)
.orElse("unknown");
تفاوت در جایی که خطا کشف میشود و در آنچه امضا اعلام میکند است. null در امضا نامرئی است: Customer findByEmail(Email e) هیچ نمیگوید که ممکن است چیزی نباشد، پس فراخواننده فراموش میکند و NullPointerException جایی دورتر و در زمان اجرا منفجر میشود. Optional<Customer> همان اطلاعات را وارد نوع میکند: کامپایلر فراخواننده را مجبور میکند تصمیم بگیرد. یعنی Optional مسئله را از زمان اجرا به زمان کامپایل و از مستندات به امضا منتقل میکند. البته اگر کسی get() بیبررسی بزند، همان مسئله برمیگردد — پس ابزار بهتنهایی کافی نیست، انضباط استفاده لازم است.
۶. اصول، با بازآرایی واقعی — نه شعار
۶.۱ KISS و پیچیدگی عارضی
KISS یعنی «ساده نگهش دار». ولی «ساده» یعنی چه؟ تعریف دقیق و قابل استفاده این است: تفکیک پیچیدگی ذاتی (essential) از پیچیدگی عارضی (accidental).
- پیچیدگی ذاتی از خود مسئله میآید. محاسبهٔ مالیات ارزش افزوده با نرخهای متفاوت واقعاً پیچیده است؛ هیچ طراحیای آن را حذف نمیکند.
- پیچیدگی عارضی را ما اضافه میکنیم: لایهای که چیزی اضافه نمیکند، انتزاعی برای یک پیادهسازی، کانفیگی که هیچوقت عوض نمیشود، فریمورکی که فقط ۵٪ آن استفاده میشود.
// پیچیدگی عارضی: چهار نوع برای کاری که یک متد انجام میدهد
public interface DiscountStrategy { BigDecimal apply(BigDecimal amount); }
public class DiscountStrategyFactory { ... }
public class DefaultDiscountStrategy implements DiscountStrategy { ... }
public class DiscountStrategyConfig { ... }
// و در کل پروژه فقط یک پیادهسازی وجود دارد
// KISS: تا وقتی نیاز واقعی به تعویض پیادهسازی نیست
BigDecimal discounted = amount.multiply(GOLD_TIER_FACTOR);
۶.۲ DRY: تکرارِ دانش، نه تکرار متن
این پرغلطفهمترین اصل کل مهندسی نرمافزار است. تعریف دقیق: هر تکه دانش باید یک نمایش یکتا، بدون ابهام و معتبر در سیستم داشته باشد.
کلمهٔ کلیدی «دانش» است، نه «متن». دو قطعه کد که شبیهاند ولی به دو دلیل مختلف تغییر میکنند، تکرار نیستند — «تکرار تصادفی» (coincidental duplication) هستند، و یکیکردنشان یک باگ آینده است.
// این دو *به نظر* یکساناند
BigDecimal invoiceTotal = quantity.multiply(unitPrice); // مبلغ فاکتور
BigDecimal shippingCost = weightKg.multiply(ratePerKg); // هزینهٔ حمل
اگر اینها را در multiplyTwoNumbers(a, b) یکی کنی، هیچ دانشی را متمرکز نکردهای — فقط دو مفهوم مستقل را به هم چسباندهای. روزی که فرمول حمل بشود «حداقل ۵ واحد + وزن × نرخ»، مجبوری یا آن تابع مشترک را با یک boolean آلوده کنی (سلام آرگومان flag) یا دوباره جدایشان کنی.
در مقابل، این واقعاً تکرار دانش است:
// در سه فایل مختلف، همین قاعده تکرار شده: «سفارش زیر ۱۰۰ هزار، هزینهٔ ارسال دارد»
if (order.total().compareTo(new BigDecimal("100000")) < 0) { ... } // OrderService
if (o.getTotal().doubleValue() < 100000) { ... } // CheckoutController
// و در یک تست: assertTrue(cart.total() < 100_000)
اینجا یک قاعدهٔ کسبوکار در سه جا نمایش داده شده. اگر آستانه عوض شود و یکی جا بماند، باگ تولیدی داری. درمان:
public final class ShippingPolicy {
private static final Money FREE_SHIPPING_THRESHOLD = Money.of(100_000);
public boolean qualifiesForFreeShipping(Money orderTotal) {
return orderTotal.isGreaterThanOrEqual(FREE_SHIPPING_THRESHOLD);
}
}
نمودار: تصمیمگیری دربارهٔ تکرار — Diagram: deciding what to do with duplication
flowchart TD
A[Two similar pieces of code] --> B{Do they change<br/>for the same reason?}
B -- No --> C[Coincidental duplication<br/>Leave them separate]
B -- Yes --> D{Seen it 3 times?}
D -- No --> E[Wait. Duplicate once more<br/>and learn the shape]
D -- Yes --> F[Extract the shared knowledge<br/>into one named concept]
F --> G{Does the abstraction need<br/>a flag or a mode?}
G -- Yes --> C
G -- No --> H[Good abstraction]
قاعدهٔ سه (Rule of Three): بار اول بنویس. بار دوم تکرار کن و اذیت شو. بار سوم انتزاع بساز. دلیلش آماری است: با دو نمونه نمیدانی کدام بخشها واقعاً مشترکاند؛ با سه نمونه، شکل انتزاع درست را میبینی.
WET و AHA: پادزهرهای مفید در برابر افراط در DRY. WET («Write Everything Twice» یا کنایهآمیزتر «We Enjoy Typing») یادآوری میکند که کمی تکرار از یک انتزاع اشتباه ارزانتر است. AHA («Avoid Hasty Abstractions») میگوید: ترجیح بده تکرار را تحمل کنی تا انتزاع اشتباه را — چون تکرار محلی و قابل حذف است، ولی انتزاع اشتباه در کل کد پخش میشود و برداشتنش گران است.
| وضعیت | تکرار را نگه دار | انتزاع بساز |
|---|---|---|
| دو نسخه به دلایل متفاوت تغییر میکنند | ✅ | ❌ |
| هر دو یک قاعدهٔ کسبوکار را بیان میکنند | ❌ | ✅ |
| فقط دو نمونه دیدهای | ✅ (منتظر بمان) | ⚠️ زودهنگام |
| انتزاع نیازمند flag/mode میشود | ✅ | ❌ نشانهٔ انتزاع غلط |
| در دو bounded context مختلفاند | ✅ | ❌ |
| تکرار در تستها برای خوانایی | ✅ | ⚠️ فقط setup مشترک |
فرض کن دو سرویس مختلف، هر کدام یک DTO با فیلدهای مشابه دارند. تیم آنها را در یک ماژول common-model مشترک میکند. شش ماه بعد، سرویس A نیاز به یک فیلد جدید دارد؛ ولی تغییر common-model یعنی build و deploy سرویس B هم عوض میشود. تو یک وابستگی زمانی بین دو تیم ساختهای که در نمودار معماریات دیده نمیشود.
قاعدهٔ سنیور: DRY درون یک مرز (bounded context / سرویس) اعمال میشود، نه بین مرزها. بین سرویسها، تکرار یک DTO تقریباً همیشه ارزانتر از اشتراکگذاری آن است. جزئیات این مرزها در فصلهای ddd و ms-foundations هست.
«DRY یعنی هر تکه دانش باید یک نمایش معتبر یکتا داشته باشد — تأکید روی دانش است نه روی متن کد. اگر دو قطعه فقط از نظر ظاهری شبیهاند ولی به دو دلیل متفاوت تغییر میکنند، این تکرار تصادفی است و یکیکردنشان اشتباه است، چون در اولین واگرایی مجبور میشوی انتزاع را با flag آلوده کنی. معیار عملی من "آیا این دو با هم تغییر میکنند؟" است، نه "آیا شبیهاند؟". قاعدهٔ سه را رعایت میکنم و بین سرویسها/کانتکستهای مختلف اصلاً DRY نمیکنم چون کوپلینگ استقرار میسازد. مکمل ذهنیاش AHA است: تکرار خیلی ارزانتر از انتزاع اشتباه است.»
۶.۳ YAGNI و «عمومیتبخشی حدسی»
YAGNI: «You Aren't Gonna Need It» — قابلیتی را که الان لازم نداری نساز، حتی اگر مطمئنی بعداً لازم میشود.
منطقش سهتایی است: (۱) معمولاً لازم نمیشود؛ (۲) اگر هم بشود، معمولاً به شکلی که حدس زده بودی نیست؛ (۳) در تمام مدت، آن کد باید نگهداری، تست، خوانده و در build حمل شود.
بوی همراهش Speculative Generality است: interface با یک پیادهسازی، پارامتر نوعی که هیچوقت تغییر نمیکند، hook و رویدادی که هیچ شنوندهای ندارد، کانفیگی که همیشه پیشفرض است، abstract کلاسی که فقط یک وارث دارد.
// speculative generality: «شاید بعداً چند provider داشته باشیم»
public interface NotificationChannel { void send(Notification n); }
public class SmsChannel implements NotificationChannel { ... }
public abstract class AbstractNotificationChannel implements NotificationChannel { ... }
public class NotificationChannelRegistry { ... }
public class NotificationChannelProperties { ... }
// و دو سال است فقط SMS داریم
سوءتفاهم رایج: YAGNI یعنی بیخیال طراحی شو. نه. تفاوت ظریف ولی حیاتی این است:
- ساختن قابلیتی که لازم نداری → YAGNI میگوید نساز.
- نساختن قابلیتی که لازم نداری، ولی طوری نوشتن که اضافهکردنش بعداً ارزان باشد → این خودِ طراحی خوب است.
انتزاع را وقتی میسازی که نیاز دوم برسد، نه وقتی که حدس میزنی میرسد. تفاوت این دو در هزینهٔ برگشت است: کدِ ساده را میشود بعداً عمومی کرد؛ ولی انتزاعِ زودهنگامِ اشتباه را باید اول برچید و این معمولاً کسی جرئتش را ندارد.
استثناهای واقعی که سنیور از قبل میسازد و YAGNI بهانهشان نمیشود: مرز امنیتی، مدل دادهای که مهاجرتش گران است، شناسههای عمومی API، و ساختار قابلیت مشاهده (observability). اینها ذاتاً «برگشتناپذیر»اند.
۶.۴ قاعدهٔ پیشاهنگی (Boy Scout Rule)
«محل را تمیزتر از آنکه پیدا کردی ترک کن.» ترجمهٔ مهندسیاش: هر بار که برای یک تیکت فایلی را باز میکنی، یک بهبود کوچک و کمریسک هم بکن — یک نام بهتر، یک متد استخراجشده، یک کامنت مرده حذفشده.
قدرتش در «تدریجی بودن» است: کیفیت را بدون نیاز به پروژهٔ بازنویسی بالا میبرد. خطرش هم مشخص است — اگر PR تو نصف فایلهای پروژه را لمس کند، هیچکس نمیتواند reviewش کند. مرز عملی: بهبود باید در همان فایلهایی باشد که برای تیکت لمس کردهای، و در کمتر از چند دقیقه انجام شود؛ هر چیز بزرگتر، تیکت خودش را میخواهد.
۶.۵ اصل کمترین شگفتی (Principle of Least Astonishment)
کد باید کاری را بکند که نامش وعده میدهد — نه بیشتر و نه کمتر. اگر خواننده بگوید «چی؟!»، تو یک باگ آینده ساختهای.
// شگفتی: نامش «گرفتن» است ولی مینویسد
public User getUser(String id) {
User u = cache.get(id);
if (u == null) {
u = db.load(id);
cache.put(id, u);
auditLog.record("user accessed", id); // اثر جانبی پنهان
}
return u;
}
این متد در تستها ناگهان به دیتابیس و audit وابسته میشود و کسی نمیفهمد چرا. اگر cache و audit جزء قرارداد است، نامش را صادق کن: loadAndAuditUser — یا بهتر، مسئولیتها را جدا کن.
۶.۶ ترکیب بهجای وراثت
جزئیات وراثت و چندریختی در فصل oop-deep و اصل جانشینی Liskov در solid-patterns است. آنچه در سطح صنعتگری روزمره لازم است:
وراثت قویترین کوپلینگ در Java است — زیرکلاس به جزئیات پیادهسازی پدر وابسته میشود و آن وابستگی در زمان کامپایل قفل میشود. ترکیب همان قابلیت را با کوپلینگ ضعیفتر میدهد.
// بد: وراثت برای استفادهٔ مجدد از کد، نه برای «is-a»
public class OrderService extends JdbcSupport { ... } // OrderService یک JdbcSupport نیست
// خوب: ترکیب — وابستگی صریح و قابل تعویض
public class OrderService {
private final OrderRepository orders;
OrderService(OrderRepository orders) { this.orders = orders; }
}
قاعده: از وراثت فقط وقتی استفاده کن که رابطه واقعاً «is-a» باشد و بخواهی زیرنوع در همهجا جای پدر بنشیند. برای «استفادهٔ مجدد از کد»، ترکیب یا متد پیشفرض interface را انتخاب کن.
۶.۷ Tell, Don't Ask و قانون دیمیتر
Tell Don't Ask: به شیء بگو چه کند؛ دادهاش را نگیر تا خودت تصمیم بگیری.
// Ask: منطق از شیء بیرون کشیده شده
if (account.getBalance().compareTo(amount) >= 0) {
account.setBalance(account.getBalance().subtract(amount));
}
// Tell: قاعده داخل شیء است، جایی که داده هست
account.withdraw(amount); // خودش موجودی را چک و استثنا پرتاب میکند
نسخهٔ اول سه مشکل دارد: قاعده در هر محل فراخوانی تکرار میشود (نقض DRY)، شیء نمیتواند تغییرناپذیریاش را تضمین کند، و در همزمانی مسابقه میسازد.
قانون دیمیتر (Law of Demeter) یا «فقط با دوستان نزدیکت حرف بزن»: یک متد فقط باید متدهای اینها را صدا بزند — خود شیء، پارامترهایش، اشیائی که خودش ساخته، و فیلدهای مستقیمش.
// نقض: زنجیرهٔ پیام — به ساختار داخلی سه شیء دیگر وابستهای
String city = order.getCustomer().getAddress().getCity().getName();
// اصلاح: Hide Delegate — از شیء بپرس، نه از درونش
String city = order.shippingCityName();
اگر این قانون را کورکورانه اعمال کنی، به یک انبوه متد getX() واسطه میرسی — یعنی بوی «واسطهٔ بیکار» (Middle Man). دو استثنای درست:
۱. ساختارهای دادهٔ محض (DTO، record، پاسخ JSON) قانون دیمیتر ندارند؛ آنها دادهاند نه شیء رفتاری. dto.getAddress().getCity() مشکلی ندارد.
۲. رابطهای روان (fluent) مثل Stream، builder یا assertThat(x).isNotNull().hasSize(3) زنجیرهاند ولی هر مرحله همان شیء (یا نوع خودش) را برمیگرداند، نه دلورودهاش را.
معیار درست: آیا این زنجیره تو را به ساختار داخلی یک شیء دیگر وابسته میکند؟ اگر بله، نقض است.
۶.۸ اصل تکسطح انتزاع (SLA)
قاعدهٔ جمعبندی این بخش: در هر بلوک کد، همهٔ دستورها باید در یک سطح انتزاع باشند. این تنها معیاری است که «کدام تابع را باید بشکنم؟» را قابل تصمیمگیری میکند، و همان چیزی است که بازآراییهای بخش ۸ را هدایت میکند.
«KISS. چون تکرار یک هزینهٔ محلی و قابل رؤیت است، ولی پیچیدگی ناشی از انتزاع اشتباه، یک هزینهٔ سیستمی و پنهان است. اگر برای حذف تکرار مجبور شوم یک لایهٔ generic با چند flag یا یک سلسلهمراتب وراثتی بسازم که خواندنش سختتر از خود تکرار است، معامله را باختهام. عملاً وقتی به تعارض میرسم، ابتدا میپرسم آیا این واقعاً تکرارِ دانش است یا شباهت متنی؛ اکثر تعارضها همانجا حل میشوند. اگر واقعاً تکرار دانش بود ولی انتزاع تمیزی نداشت، تکرار را نگه میدارم و با یک تست مشترک یا یک ثابت مشترک، ریسک ناهماهنگی را کم میکنم.»
۷. کاتالوگ بوی بد کد
«بوی بد کد» (code smell) یک باگ نیست. یک نشانهٔ سطحی است که معمولاً به یک مشکل عمیقتر اشاره میکند — مثل بوی سوختگی در آشپزخانه: خودش مسئله نیست، ولی باید نگاه کنی.
مهارت سنیور این است که برای هر بو، بازآرایی متناظرش را بشناسد. جدول زیر نقشهٔ کامل است؛ بعدش مثالهای فشرده.
| بو | نشانه | مشکل زیرین | بازآرایی |
|---|---|---|---|
| Long Method | متد طولانی، اسکرول لازم | چند سطح انتزاع در هم | Extract Function |
| Large Class / God Object | کلاسی که همهکار میکند | نقض SRP | Extract Class، Extract Delegate |
| Long Parameter List | ۴+ پارامتر | مفاهیم به هم چسبیده | Introduce Parameter Object |
| Primitive Obsession | String/BigDecimal برای مفاهیم دامنه |
نبود مدل دامنه | Replace Primitive with Object |
| Data Clumps | همان چند فیلد همیشه با هم | یک مفهوم گمشده | Extract Class |
| Feature Envy | متدی که بیشتر با دادهٔ کلاس دیگر کار میکند | متد در جای غلط | Move Function |
| Shotgun Surgery | یک تغییر، ده فایل | دانش پخششده | Move Function/Field، Combine into Class |
| Divergent Change | یک کلاس، به سه دلیل مختلف تغییر میکند | چند مسئولیت | Extract Class |
| Message Chains | a.getB().getC().getD() |
نقض دیمیتر | Hide Delegate |
| Middle Man | کلاسی که فقط delegate میکند | لایهٔ بیارزش | Remove Middle Man، Inline |
| Temporal Coupling | باید init() قبل از run() صدا شود |
قرارداد نامرئی | Combine Functions، سازندهٔ کامل |
| Flag Argument | doThing(true) |
دو کار در یک متد | Remove Flag Argument |
| Anemic Domain Model | موجودیت فقط getter/setter | منطق در سرویسها پخش شده | Move Function به موجودیت |
| Leaky Abstraction | جزئیات پیادهسازی از interface بیرون میزند | مرز اشتباه | تغییر امضا / Encapsulate |
| Speculative Generality | interface با یک پیادهسازی | YAGNI نقض شده | Inline / Collapse Hierarchy |
| Comments (as deodorant) | کامنت برای توضیح کد پیچیده | کد گویا نیست | Extract Function، Rename |
۷.۱ وسواس نوع اولیه و دستههای داده
// بو: Primitive Obsession + Data Clumps (این چهار مقدار همیشه با هم میآیند)
public void ship(String street, String city, String postalCode, String country,
BigDecimal weight, String weightUnit) { ... }
// درمان: دو مفهوم گمشده اسم گرفتند
public record Address(String street, String city, PostalCode postalCode, CountryCode country) {}
public record Weight(BigDecimal value, WeightUnit unit) {
public Weight {
if (value.signum() < 0) throw new IllegalArgumentException("weight must be >= 0");
}
}
public void ship(Address destination, Weight weight) { ... }
سود واقعی فقط خوانایی نیست: حالا اعتبارسنجی یک بار در سازنده انجام میشود (record با compact constructor)، جابهجا کردن تصادفی city و country دیگر کامپایل نمیشود، و رفتار مرتبط (فرمتبندی آدرس، تبدیل واحد وزن) خانه دارد.
۷.۲ Feature Envy و Anemic Domain Model
// بو: متد در OrderService است ولی تمام دادهٔ Order را میخواهد
class OrderService {
boolean isEligibleForFreeShipping(Order order) {
return order.getTotal().compareTo(THRESHOLD) >= 0
&& !order.getShippingAddress().isRemote()
&& order.getCustomer().getTier() != Tier.BASIC;
}
}
// درمان: Move Function — قاعده کنار دادهاش میرود
class Order {
boolean isEligibleForFreeShipping() {
return total.isGreaterThanOrEqual(FREE_SHIPPING_THRESHOLD)
&& !shippingAddress.isRemote()
&& customerTier != Tier.BASIC;
}
}
وقتی هر موجودیت فقط getter/setter دارد و تمام منطق در *Service است، عملاً برنامهنویسی رویهای با نحو شیءگرا نوشتهای. علائمش: سرویسهای هزارخطی، همان if تکراری در چند سرویس، و ناتوانی موجودیت در حفظ ثبات خودش.
اما — و اینجا قضاوت لازم است — همیشه بد نیست. برای یک سرویس CRUD ساده، مدل کمخون کاملاً منطقی و ارزان است. جایی بد میشود که قواعد کسبوکار پیچیده داری. معیار عملی: اگر میتوانی قواعد را در یک جمله بگویی و کمتر از سهتا هستند، نگران نباش؛ اگر یک زبان دامنهای داری با دهها قاعده و حالت، مدل غنی بساز. فصل ddd این را کامل میکند.
۷.۳ کوپلینگ زمانی (Temporal Coupling)
// بو: قرارداد نامرئی — اگر ترتیب را رعایت نکنی، NullPointerException
Report report = new Report();
report.setTemplate(template);
report.loadData();
report.render(); // اگر loadData را فراموش کنی، منفجر میشود
// درمان: شیء یا کامل ساخته میشود یا اصلاً ساخته نمیشود
Report report = Report.from(template, dataSource); // factory، وضعیت معتبر تضمینشده
String html = report.render();
۷.۴ Shotgun Surgery در برابر Divergent Change
این دو قرینهٔ هماند و اشتباهگرفتنشان رایج است:
- Shotgun Surgery: یک تغییر مفهومی → لمس ده فایل. دانش پخش شده؛ باید جمع شود.
- Divergent Change: یک فایل → به سه دلیل بیربط تغییر میکند. مسئولیتها قاطیاند؛ باید جدا شوند.
مثال Shotgun Surgery: افزودن یک روش پرداخت جدید نیازمند تغییر در enum، switch در سرویس، switch در گزارشگیری، و if در UI. درمان: تمام دانش مربوط به هر روش پرداخت را در یک جا (یک کلاس یا یک enum با رفتار) جمع کن — که مستقیم به بازآرایی Replace Conditional with Polymorphism میرسد.
جواب خوب سه بخش دارد: بو، چرا خطرناک بود، و بازآرایی مشخص. مثلاً: «در یک سرویس، یک switch روی نوع پرداخت در چهار فایل مختلف تکرار میشد — Shotgun Surgery. خطرش این بود که اضافهکردن یک روش جدید یعنی چهار جای احتمالی برای فراموشی، و یکی از آنها را واقعاً فراموش کرده بودیم و در گزارش مالی خطا داشتیم. اول برای رفتار موجود characterization test نوشتم، بعد Replace Conditional with Polymorphism زدم: یک interface بهازای رفتار و یک Map<PaymentType, PaymentHandler> که Spring خودش پر میکند. بعدش یک تست ArchUnit اضافه کردم که هیچ switch روی آن enum بیرون از آن پکیج مجاز نباشد.» بخش آخر — جلوگیری از بازگشت — همان چیزی است که سنیور را متمایز میکند.
۸. کاتالوگ بازآرایی و قاعدهٔ آهنین آن
۸.۱ تعریف دقیق
بازآرایی (refactoring): تغییر ساختار داخلی نرمافزار برای آسانتر کردن فهم و ارزانتر کردن تغییر، بدون تغییر رفتار قابل مشاهدهٔ آن.
آن قید آخر همهچیز است. اگر رفتار عوض شد، آن بازآرایی نبود — یا اصلاح باگ بود یا افزودن قابلیت یا (بدترین حالت) یک باگ جدید. به همین دلیل:
بدون یک شبکهٔ ایمنی از تست، آنچه انجام میدهی «بازآرایی» نیست؛ «بازنویسی امیدوارانه» است. ترتیب درست همیشه این است:
۱. مطمئن شو تستِ سبز پوششدهنده وجود دارد (اگر ندارد، اول بنویس — بخش ۹). ۲. یک قدم کوچک بازآرایی بردار. ۳. تستها را بزن. سبز؟ commit کن. ۴. تکرار.
و: بازآرایی و تغییر رفتار را در یک commit یا یک PR قاطی نکن. اگر قاطی کنی، reviewer نمیتواند تشخیص دهد کدام تغییر بیخطر بوده و کدام نبوده؛ عملاً review را بیاثر کردهای. الگوی درست: PR اول «فقط بازآرایی، بدون تغییر رفتار»، PR دوم «قابلیت جدید».
نمودار: چرخهٔ کوچک و امن بازآرایی — Diagram: the small, safe refactoring loop
stateDiagram-v2
[*] --> GreenTests
GreenTests --> SmallStep: apply ONE refactoring
SmallStep --> RunTests
RunTests --> GreenTests: pass -> commit
RunTests --> Revert: fail
Revert --> GreenTests: undo, take a smaller step
GreenTests --> [*]: structure is good enough
۸.۲ یازده بازآرایی که ۹۰٪ کار روزمره را میپوشانند
| بازآرایی | کِی | میانبر IDE (IntelliJ) |
|---|---|---|
| Extract Function | بلوکی که میشود نامش را گفت | Ctrl+Alt+M |
| Extract Variable | عبارت پیچیده در یک شرط | Ctrl+Alt+V |
| Inline Function/Variable | انتزاعی که چیزی اضافه نمیکند | Ctrl+Alt+N |
| Rename (Change Function Declaration) | نام دروغ میگوید | Shift+F6 |
| Extract Class | کلاس چند مسئولیت دارد | F6 / منوی Refactor |
| Introduce Parameter Object | فهرست پارامتر بلند | Ctrl+Alt+P سپس Extract |
| Replace Conditional with Polymorphism | switch روی نوع، تکرارشده |
دستی |
| Replace Magic Literal | عدد/رشتهٔ بینام | Ctrl+Alt+C |
| Replace Nested Conditional with Guard Clauses | تودرتویی عمیق | دستی |
| Decompose Conditional | شرط پیچیده | Ctrl+Alt+M روی شرط |
| Encapsulate Collection | getter مجموعهٔ قابل تغییر | دستی |
| Separate Query from Modifier | متدی که هم میپرسد هم عوض میکند | دستی |
| Replace Constructor with Factory Method | ساخت نیازمند اعتبارسنجی/انتخاب | دستی |
بند نگهبان بهجای تودرتویی
// قبل: چهار سطح تودرتویی، مسیر اصلی ته چاه است
public Money payAmount(Employee e) {
Money result;
if (e.isSeparated()) {
result = Money.ZERO;
} else {
if (e.isRetired()) {
result = retiredAmount();
} else {
if (e.isOnLeave()) {
result = leaveAmount(e);
} else {
result = normalPay(e);
}
}
}
return result;
}
// بعد: حالتهای خاص زود بسته میشوند، مسیر اصلی آخرین خط است
public Money payAmount(Employee e) {
if (e.isSeparated()) return Money.ZERO;
if (e.isRetired()) return retiredAmount();
if (e.isOnLeave()) return leaveAmount(e);
return normalPay(e);
}
تجزیهٔ شرط + جایگزینی با چندریختی
// قبل
if (order.getType() == OrderType.EXPORT) {
tax = base.multiply(BigDecimal.ZERO);
} else if (order.getType() == OrderType.DOMESTIC) {
tax = base.multiply(new BigDecimal("0.09"));
} else if (order.getType() == OrderType.EXEMPT) {
tax = BigDecimal.ZERO;
}
// بعد: دانش هر نوع کنار خودش، و افزودن نوع جدید فقط یک جا را لمس میکند
public enum OrderType {
EXPORT { public Money tax(Money base) { return Money.ZERO; } },
DOMESTIC{ public Money tax(Money base) { return base.times(VAT_RATE); } },
EXEMPT { public Money tax(Money base) { return Money.ZERO; } };
private static final BigDecimal VAT_RATE = new BigDecimal("0.09");
public abstract Money tax(Money base);
}
در Java مدرن، جایگزین دیگر این است که با sealed interface و switch الگو-محور کار کنی؛ آنجا کامپایلر جامعبودن (exhaustiveness) را چک میکند. تفاوت را فصل modern-java باز کرده است. انتخاب بین این دو یک تصمیم واقعی است: enum با رفتار، دانش را در نوع نگه میدارد (خوب وقتی رفتار متعلق به دامنه است)؛ sealed + switch رفتار را بیرون نگه میدارد (خوب وقتی چند عملیات مستقل روی همان نوعها داری و نمیخواهی نوع را با همهشان شلوغ کنی).
کپسولهسازی مجموعه
// بد: هر کسی میتواند لیست داخلی را عوض کند و ثبات سفارش بشکند
public List<OrderLine> getLines() { return lines; }
// خوب: تغییر فقط از راههای معنادار
public List<OrderLine> lines() {
return Collections.unmodifiableList(lines); // یا List.copyOf(lines)
}
public void addLine(Product product, Quantity quantity) {
if (status != OrderStatus.DRAFT) {
throw new OrderNotEditableException(id, status);
}
lines.add(new OrderLine(product, quantity));
}
جداسازی پرسش از تغییردهنده
// بد: هم میگردد هم زنگ خطر میزند
public Customer findCustomerAndNotifyIfBlocked(Email email) { ... }
// خوب
Customer customer = customers.require(email);
if (customer.isBlocked()) {
alerts.blockedCustomerAccessAttempt(customer.id());
}
۸.۳ بازآرایی آمادهسازی (Preparatory Refactoring)
یکی از مفیدترین ایدههای عملی: وقتی افزودن یک قابلیت در ساختار فعلی سخت است، اول ساختار را عوض کن تا افزودن آسان شود، بعد اضافه کن. استعارهٔ معروفش: «اگر میخواهی صد کیلومتر شرق بروی ولی جاده به شمال است، اول برو سر جادهٔ درست.»
مزیت عملیاتیاش در review هم هست: PR اول کاملاً بیرفتار است و سریع تأیید میشود؛ PR دوم کوچک و متمرکز است.
تفاوت واقعی بین «بازآرایی» و «ویرایش پرخطر» اغلب همین است. Shift+F6 (Rename) در IntelliJ تمام ارجاعها را type-safe عوض میکند — شامل رشتههای داخل Javadoc و در بعضی موارد فایلهای کانفیگ Spring. جستوجو-و-جایگزینی متنی این کار را نمیکند و مثلاً یک orderId را در یک رشتهٔ SQL هم عوض میکند یا جا میگذارد.
قاعدهٔ عملی سنیور: هر بازآراییای که IDE اتوماتیک انجام میدهد، بدون تست هم نسبتاً امن است؛ هر بازآراییای که دستی است، بدون تست ممنوع است.
۸.۴ کد تمیز فقط Java نیست: SQL خوانا
همان اصول روی SQL هم صدق میکند: نامگذاری، یک سطح انتزاع در هر مرحله، و حذف عدد جادویی. مقایسه کن:
-- قبل: زیرپرسوجوهای تودرتو، بدون نام، با عدد جادویی
SELECT c.name, SUM(o.total_amount)
FROM customers c
JOIN orders o ON o.customer_id = c.id
WHERE c.status = 'ACTIVE'
AND o.placed_at >= now() - INTERVAL '90 days'
GROUP BY c.name
HAVING SUM(o.total_amount) > 1000;-- قبل: همان شکل در Oracle؛ SYSTIMESTAMP جای now()
SELECT c.name, SUM(o.total_amount)
FROM customers c
JOIN orders o ON o.customer_id = c.id
WHERE c.status = 'ACTIVE'
AND o.placed_at >= SYSTIMESTAMP - INTERVAL '90' DAY
GROUP BY c.name
HAVING SUM(o.total_amount) > 1000;نسخهٔ بازآراییشده با CTE، که هر مرحله یک نام دارد (معادل Extract Function در SQL):
WITH active_customers AS (
SELECT c.id, c.name
FROM customers c
WHERE c.status = 'ACTIVE'
),
orders_in_window AS (
SELECT o.customer_id, o.total_amount
FROM orders o
WHERE o.placed_at >= now() - INTERVAL '90 days'
)
SELECT ac.name,
SUM(oiw.total_amount) AS total_spent
FROM active_customers ac
JOIN orders_in_window oiw ON oiw.customer_id = ac.id
GROUP BY ac.name
HAVING SUM(oiw.total_amount) > 1000;WITH active_customers AS (
SELECT c.id, c.name
FROM customers c
WHERE c.status = 'ACTIVE'
),
orders_in_window AS (
SELECT o.customer_id, o.total_amount
FROM orders o
WHERE o.placed_at >= SYSTIMESTAMP - INTERVAL '90' DAY
)
SELECT ac.name,
SUM(oiw.total_amount) AS total_spent
FROM active_customers ac
JOIN orders_in_window oiw ON oiw.customer_id = ac.id
GROUP BY ac.name
HAVING SUM(oiw.total_amount) > 1000;اینجا یک تفاوت بنیادی با Java هست: در Java، استخراج متد هزینهٔ اجرایی تقریباً صفر دارد (JIT آن را inline میکند). در SQL، بازنویسی «خواناتر» میتواند پلن اجرا را عوض کند. در PostgreSQL از نسخهٔ ۱۲ به بعد، CTEهای غیربازگشتی بهصورت پیشفرض inline میشوند مگر MATERIALIZED بنویسی — پس معمولاً بیهزینهاند؛ ولی در نسخههای قدیمیتر CTE یک «حصار بهینهسازی» بود. در Oracle هم optimizer ممکن است CTE را materialize کند.
قاعدهٔ سنیور: در SQL، خوانایی را بهبود بده ولی همیشه پلن قبل و بعد را مقایسه کن (EXPLAIN (ANALYZE, BUFFERS) در PostgreSQL، DBMS_XPLAN.DISPLAY_CURSOR در Oracle). جزئیات در فصلهای rdbms-tuning و oracle-postgres-dialects است.
«بازآرایی یعنی تغییر ساختار داخلی بدون تغییر رفتار قابل مشاهده — پس ذاتاً به یک راه برای اثبات بیتغییر ماندن رفتار نیاز دارد، یعنی تست. اگر تست نداشته باشم، اول یک لایهٔ نازک تست مشخصهنگار (characterization test) مینویسم: کد را اجرا میکنم، خروجی فعلی را — حتی اگر اشتباه باشد — بهعنوان انتظار ثبت میکنم، تا هر تغییر ناخواسته دیده شود. اگر کد اصلاً تستپذیر نیست (وابستگی static، new مستقیم)، اول کمینهترین تغییر را برای ساختن یک seam میدهم — مثلاً استخراج یک متد قابل override یا تزریق یک وابستگی از سازنده — و آن یک تغییر را بهعنوان یک PR جدا میفرستم. تنها بازآراییهایی که بدون تست انجام میدهم، آنهایی هستند که IDE بهصورت خودکار و type-safe انجام میدهد، مثل Rename.»
۹. کار با کد legacy: تغییر کدی که از آن میترسی
تعریف عملی و بیرحمانهای که در صنعت جا افتاده: کد legacy یعنی کدی که تست ندارد. سن، زبان و فریمورک مهم نیستند؛ چیزی که کد را legacy میکند این است که نمیتوانی با اطمینان عوضش کنی.
و اینجا یک دور باطل داریم: برای تغییر امن، تست لازم است؛ برای نوشتن تست، معمولاً باید کد را عوض کنی (چون تستناپذیر است). راه بیرون آمدن، تکنیکهای زیر است.
نمودار: الگوریتم تغییر امن در کد legacy — Diagram: the algorithm for changing legacy code safely
flowchart TD
A[Change request] --> B{Tests cover<br/>this behaviour?}
B -- Yes --> C[Refactor, then change]
B -- No --> D{Can I test it<br/>as-is?}
D -- Yes --> E[Write characterization tests]
E --> C
D -- No --> F[Find or create a seam<br/>minimal, reviewed change]
F --> E
D -- Too risky --> G[Sprout method / Sprout class<br/>or Wrap method]
G --> H[New code is tested,<br/>old code untouched]
۹.۱ تست مشخصهنگار (Characterization Test)
تست معمولی میگوید «کد باید این کار را بکند». تست مشخصهنگار میگوید «کد الان این کار را میکند». تفاوت حیاتی است: تو رفتار فعلی را — درست یا غلط — قفل میکنی تا بازآراییات آن را عوض نکند.
روش عملی:
@Test
void characterize_legacyInvoiceTotal() {
LegacyInvoiceCalculator calc = new LegacyInvoiceCalculator();
// 1) یک assert عمداً غلط بنویس تا مقدار واقعی را ببینی
// assertThat(calc.total(sampleInvoice())).isEqualByComparingTo("0");
// خروجی تست: expected 0 but was 1234500
// 2) مقدار واقعی را ثبت کن — این «درست» نیست، «فعلی» است
assertThat(calc.total(sampleInvoice())).isEqualByComparingTo("1234500");
}
نکتهٔ مهم فرهنگی: این تستها را با نام و کامنت مشخص کن (characterize_*) تا کسی آنها را با مشخصات کسبوکار اشتباه نگیرد. اگر بعداً معلوم شد رفتار فعلی باگ است، تست تغییر میکند و آن تغییر یک تصمیم آگاهانه است، نه یک تصادف.
برای کد legacy با خروجی بزرگ (JSON، گزارش، فایل)، تست تأییدی (approval test) نسخهٔ عملیتری است: خروجی را در یک فایل «تأییدشده» ذخیره میکنی و هر تفاوت را diff میبینی.
۹.۲ Seam: نقطهای که میتوانی رفتار را عوض کنی بدون ویرایش کد
Seam جایی در برنامه است که میتوانی رفتار را از بیرون عوض کنی، بدون تغییر در آن نقطه. در Java مهمترین نوع، object seam است: جایی که یک وابستگی از طریق polymorphism قابل تعویض است.
// قبل: هیچ seamی نیست — وابستگی سخت درون متد ساخته میشود
public class OrderProcessor {
public void process(Order o) {
PaymentGateway gw = new HttpPaymentGateway("https://prod..."); // غیرقابل تست
gw.charge(o.total());
}
}
// بعد: کمترین تغییر ممکن که یک seam میسازد — تزریق از سازنده
public class OrderProcessor {
private final PaymentGateway gateway;
public OrderProcessor(PaymentGateway gateway) { // seam
this.gateway = gateway;
}
public void process(Order o) {
gateway.charge(o.total());
}
}
اگر حتی این تغییر هم ریسک دارد (مثلاً دهها فراخوانی new OrderProcessor() در کد هست)، تکنیک محافظهکارانهتر «استخراج و override برای تست» است: ساخت وابستگی را به یک متد protected ببر و در تست، زیرکلاسی بساز که آن را override میکند. زشت است، ولی کمترین ریسک را دارد و پلهای برای بعد است.
۹.۳ Sprout و Wrap: وقتی اصلاً نمیخواهی کد قدیمی را لمس کنی
Sprout Method / Sprout Class: قابلیت جدید را بهصورت کد جدید و کاملاً تستشده بنویس و فقط یک خط فراخوانی به کد قدیمی اضافه کن.
public void postEntries(List<Entry> entries) {
// ... ۲۰۰ خط کد قدیمی بدون تست ...
validateAgainstFiscalCalendar(entries); // <- تنها خط جدید (sprout)
// ... ۱۰۰ خط دیگر ...
}
// کلاس جدید، کاملاً تستشده، مستقل
final class FiscalCalendarValidator { ... }
Wrap Method: وقتی رفتار جدید باید قبل یا بعد از رفتار موجود اجرا شود، متد قدیمی را تغییر نام بده و یک متد جدید با نام قدیمی بساز که هر دو را صدا میزند.
// قدم ۱: تغییر نام متد اصلی (با IDE، امن)
private void postEntriesInternal(List<Entry> entries) { /* کد قدیمی، دستنخورده */ }
// قدم ۲: متد جدید با نام قدیمی — فراخوانندگان چیزی نمیفهمند
public void postEntries(List<Entry> entries) {
postEntriesInternal(entries);
auditTrail.recordPosting(entries); // رفتار جدید، تستپذیر
}
Wrap Class همان ایده در سطح کلاس است (الگوی Decorator): کلاس جدیدی که همان interface را پیاده میکند، کلاس قدیمی را در خود میگیرد و رفتار جدید را اضافه میکند — بدون یک خط تغییر در کلاس قدیمی.
سه سؤال، به همین ترتیب:
۱. کوچکترین تغییری که کارم را راه میاندازد چیست؟ بزرگتر از آن، ریسک بیدلیل است. ۲. میتوانم رفتار موجود را دستنخورده بگذارم؟ اگر بله، sprout یا wrap کن. ۳. اگر مجبورم لمسش کنم، اول چطور رفتار فعلی را قفل کنم؟ characterization test.
و یک قاعدهٔ عملیاتی: هرگز یک ماژول legacy را «برای تمیزکاری» بهصورت یک PR بزرگ بازنویسی نکن. اگر واقعاً باید جایگزین شود، الگوی Strangler Fig را استفاده کن — ترافیک را تکهتکه به پیادهسازی جدید ببر و قدیمی را وقتی خالی شد حذف کن. فصل ms-runtime-legacy این را کامل توضیح داده است.
«اول کل ماژول را بازنویسی نمیکنم — این وسوسهانگیزترین و پرخطرترین کار است. ترتیب کارم این است: (۱) باگ را با یک تست شکستخورده بازتولید میکنم، در کوچکترین سطحی که ممکن است؛ اگر واحد ممکن نبود، حتی یک تست integration با Testcontainers. (۲) دور محل تغییر یک حلقهٔ characterization test میبندم تا رفتار جانبی موجود قفل شود. (۳) اگر کد تستپذیر نیست، کمینهترین seam را میسازم و همان را بهعنوان یک PR جدا میفرستم. (۴) باگ را حل میکنم، تستها سبز میشوند. (۵) قاعدهٔ پیشاهنگی: همان اطراف را کمی تمیز میکنم، نه بیشتر. (۶) اگر ماژول واقعاً پرریسک است، یک تیکت بدهی فنی با شواهد ثبت میکنم — تعداد تغییر در شش ماه گذشته، تعداد باگهای تولیدی — تا اولویتگذاری دادهمحور باشد نه سلیقهای.»
۱۰. بدهی فنی: دفتر حساب، نه بهانه
استعارهٔ «بدهی» دقیقتر از آن است که به نظر میرسد: مثل وام، بدهی فنی میتواند تصمیم درستی باشد (سرمایهای میگیری که الان لازم داری) به شرط اینکه ثبت شود و بهرهاش را بدانی. چیزی که آن را سمی میکند، بدهیِ ناخواسته و نامرئی است.
۱۰.۱ چهار ربع بدهی فنی
نمودار: چهار ربع بدهی فنی — Diagram: the technical debt quadrant
flowchart TD
subgraph Deliberate
A["Prudent + Deliberate<br/>'Ship now, we know the cost'<br/>-> record it, schedule payback"]
B["Reckless + Deliberate<br/>'No time for design'<br/>-> the dangerous one"]
end
subgraph Inadvertent
C["Prudent + Inadvertent<br/>'Now we know how we should<br/>have done it'<br/>-> normal learning"]
D["Reckless + Inadvertent<br/>'What is layering?'<br/>-> a skill gap, not debt"]
end
- آگاهانه و محتاطانه: «میدانیم راه درست چیست، ولی برای رسیدن به این تاریخ عرضه، میانبر میزنیم و در اسپرینت بعد پرداختش میکنیم.» این بدهیِ درست است.
- آگاهانه و بیاحتیاط: «وقت طراحی نداریم.» این جایی است که سیستمها میمیرند.
- ناآگاهانه و محتاطانه: «حالا که تمامش کردیم، فهمیدیم چطور باید مینوشتیم.» این یادگیری طبیعی است و اجتنابناپذیر — منبع اصلی بازآرایی سالم.
- ناآگاهانه و بیاحتیاط: تیم اصلاً نمیداند لایهبندی یعنی چه. این بدهی نیست، شکاف مهارتی است و درمانش آموزش و review است، نه تیکت.
۱۰.۲ چطور بدهی را مرئی کنیم
بدهی نامرئی همیشه به «برنامهنویسها کند شدهاند» ترجمه میشود. مرئی کردنش سه ابزار دارد:
۱. دفتر بدهی (debt register): تیکتهای واقعی در همان بکلاگ محصول، با همان فرمت. هر مورد باید سه چیز داشته باشد: اثر («هر تغییر در قیمتگذاری ۲ روز طول میکشد چون سه جا تکرار شده»)، ریسک («احتمال ناهماهنگی مالی»)، هزینهٔ پرداخت («۳ روز»).
۲. کامنت TODO با شناسهٔ تیکت — و یک قاعده در linter که TODO بدون شناسه build را قرمز کند.
۳. عدد از ابزار: SonarQube مفهوم «technical debt ratio» و «remediation effort» را محاسبه میکند؛ ولی این عدد را بهعنوان روند استفاده کن نه هدف مطلق.
۱۰.۳ چطور بدهی را پرداخت کنیم
سه الگوی جوابداده:
- قاعدهٔ پیشاهنگی بهصورت پیوسته: بیشترین بازده در ازای کمترین ریسک، و نیازی به مذاکره ندارد.
- بودجهٔ ثابت: درصد مشخصی از ظرفیت هر اسپرینت (مثلاً ۱۰–۲۰٪) به بدهی. مزیتش این است که مذاکره یکبار انجام میشود نه هر اسپرینت.
- پرداخت فرصتمحور: وقتی قابلیتی نیاز به لمس ناحیهٔ بدهکار دارد، بازآرایی آمادهسازی را در برآورد همان قابلیت بگنجان. این طبیعیترین و کماصطکاکترین راه است.
کدی که هرگز درست نبود، بدهی نیست — بیکیفیتی است. تفاوت را با یک آزمون ساده تشخیص بده: آیا در زمان نوشتن، تصمیم آگاهانهای گرفته شد و ثبت شد؟ اگر نه، آن را «بدهی» نام نگذار، چون آن اسم مسئولیت را پنهان میکند.
نکتهٔ دوم: پرداخت بدهی هم میتواند اشتباه باشد. کدی که در ماژولی است که سالی یک بار لمس میشود، بهرهٔ عملاً صفر دارد. اولویتگذاری بدهی باید بر اساس نرخ تغییر آن ناحیه باشد، نه بر اساس زشتیاش. یک git log --format=format: --name-only | sort | uniq -c | sort -rn | head -30 سریع به تو میگوید کدام فایلها پرتغییرتریناند؛ آنها اولویت واقعیاند.
«با زبان کد صحبت نمیکنم؛ با زبان ریسک و زمان تحویل. سه چیز میآورم: (۱) داده — «سه تیکت آخر در این ماژول بهطور میانگین ۲.۵ برابر برآورد اولیه طول کشیدند» و «۴۰٪ باگهای تولیدی سه ماه اخیر از همین دو فایل آمدهاند». (۲) یک پیشنهاد محدود و قابل تأیید — نه «سه هفته بازنویسی»، بلکه «۳ روز، فقط ماژول قیمتگذاری، با معیار موفقیت مشخص: زمان افزودن یک قاعدهٔ قیمت جدید از ۲ روز به نیم روز». (۳) گزینهٔ «کاری نکنیم» را هم صادقانه میگویم و هزینهاش را. عملاً بهترین راه این است که بازآرایی را به یک قابلیت گره بزنم: «برای این قابلیت، دو روز اول آمادهسازی است و بعدش پیادهسازی سریع میشود» — این تقریباً همیشه پذیرفته میشود چون در مسیر ارزش است.»
۱۱. Code review که هم کد را بهتر میکند هم آدمها را
review بد یکی از دو حالت است: یا «LGTM» در سی ثانیه (بیفایده)، یا سی کامنت دربارهٔ فاصلهگذاری (مخرب). هر دو یک ریشه دارند: نبود ترتیب و نبود اتوماسیون.
۱۱.۱ به چه چیزی، به چه ترتیبی نگاه کن
ترتیب مهم است چون توجه محدود است. اگر با nit شروع کنی، ظرفیتت قبل از رسیدن به طراحی تمام میشود.
| اولویت | چه چیزی | نمونه سؤال |
|---|---|---|
| ۱ | درستی | آیا حالتهای مرزی و خطا پوشش دارند؟ همزمانی؟ |
| ۲ | امنیت و داده | ورودی اعتبارسنجی شده؟ لاگ داده حساس ندارد؟ مهاجرت داده برگشتپذیر است؟ |
| ۳ | طراحی و مرزها | این منطق در لایهٔ درست است؟ وابستگی جدید لازم بود؟ |
| ۴ | تستها | آیا تست رفتار را میسنجد یا پیادهسازی را؟ اگر منطق را خراب کنم، تستی قرمز میشود؟ |
| ۵ | خوانایی و نامگذاری | آیا شش ماه بعد این را میفهمم؟ |
| ۶ | nit / سلیقه | فرمت، ترتیب import — که باید اتوماتیک باشد |
خیلی از reviewerها وقتی چیزی را نمیفهمند سکوت میکنند تا بیسواد به نظر نرسند. این دقیقاً برعکس است: اگر تو با تمام زمینهای که داری نفهمیدی، نفر بعدی هم نمیفهمد. آن را بنویس — نه به شکل «این غلط است»، بلکه «من نتوانستم دنبال کنم که این شرط چه حالتی را پوشش میدهد؛ میشود اسم بهتری بگذاریم یا یک تست اضافه کنیم که نشانش بدهد؟»
۱۱.۲ بازخوردی که مینشیند
چند قاعدهٔ عملی که تفاوت واقعی میسازند:
- دربارهٔ کد حرف بزن، نه دربارهٔ آدم. «این متد سه کار میکند» بهجای «تو متد را بد نوشتی».
- سطح را علامت بزن. پیشوند بگذار:
nit:(سلیقه، آزادی رد کردن)،question:(نمیفهمم)،suggestion:(بهتر است ولی اجباری نیست)،blocking:(تا حل نشود merge نمیکنم). این یک تغییر کوچک است ولی بیشترین اثر را روی فرهنگ review دارد، چون ابهام «آیا این اجباری است؟» را حذف میکند. - دلیل را بگو، نه فقط حکم را. «این را به
Moneyتبدیل کن» ضعیف است؛ «doubleبرای پول خطای گرد کردن میسازد و در جمعهای زیاد انباشته میشود» آموزنده است. - چیزهای خوب را هم بگو. یک جملهٔ «این تست خوب حالت مرزی را گرفت» هزینهای ندارد و کل لحن review را عوض میکند.
- اگر بحث طولانی شد، از متن بیرون بیا. بیش از سه رفتوبرگشت روی یک موضوع یعنی وقت یک تماس پنجدقیقهای است.
۱۱.۳ اندازه و تأخیر review
دو عدد عملیاتی که اثرشان از هر چکلیستی بیشتر است:
- اندازه: PRهای بزرگ review واقعی نمیشوند. بالای چند صد خط، کیفیت یافتهها بهشدت افت میکند و نرخ «LGTM» بالا میرود. اگر تغییرت بزرگ است، آن را به یک زنجیرهٔ PR بشکن: اول بازآرایی محض، بعد قابلیت.
- تأخیر: PRای که یک روز منتظر میماند، نویسندهاش context را از دست میدهد و شروع میکند به کار موازی — که خودش شاخههای بیشتر و تعارض بیشتر میسازد. توافق تیمی مثل «هر PR ظرف نیمروز کاری اولین بازخورد را میگیرد» بیشتر از هر قاعدهٔ سبکی به کیفیت کمک میکند.
نمودار: مسیر یک تغییر از commit تا merge — Diagram: the path of a change from commit to merge
sequenceDiagram
participant Dev as Developer
participant CI as CI pipeline
participant Rev as Reviewer
Dev->>CI: push branch
CI->>CI: format check, lint, static analysis
CI->>CI: tests + coverage on new code
CI-->>Dev: fast feedback (style, obvious bugs)
Dev->>Rev: open PR (small, one intent)
Rev->>Rev: correctness -> security -> design -> tests -> naming
Rev-->>Dev: blocking / suggestion / nit
Dev->>CI: push fixes
CI-->>Rev: green + quality gate passed
Rev->>Dev: approve, merge
۱۱.۴ بگذار ماشین چیزهای ماشینی را بگوید
تقسیم کار درست:
| ماشین باید بگوید | انسان باید بگوید |
|---|---|
| فرمت، فاصله، ترتیب import | آیا این انتزاع درست است؟ |
| متغیر استفادهنشده، import اضافی | آیا این در لایهٔ درست است؟ |
الگوهای باگ شناختهشده (== روی String) |
آیا حالت مرزی واقعی پوشش دارد؟ |
| پوشش تست روی کد جدید | آیا تستها رفتار درست را میسنجند؟ |
| وابستگی با CVE شناختهشده | آیا این وابستگی اصلاً لازم بود؟ |
| نقض قواعد معماری (ArchUnit) | آیا این قاعدهٔ معماری هنوز درست است؟ |
اگر انسانها هنوز دربارهٔ فاصلهگذاری بحث میکنند، مشکل تیم فرهنگی نیست — ابزار ندارند. بخش بعد دقیقاً همان ابزارهاست.
اگر نویسنده بداند که هر PR بیست کامنت سلیقهای میگیرد، دو رفتار دفاعی یاد میگیرد: PRهای بزرگتر میفرستد (تا دفعات کمتر شود) و از reviewer خاصی دوری میکند. هر دو دقیقاً برعکس چیزی است که میخواستی.
معیار عملی: اگر یک نظر سلیقهای است و میشود آن را در formatter/linter کد کرد، کدش کن، نکامنتش کن. اگر نمیشود کدش کرد، nit: بزن و روی رد شدنش اصرار نکن.
«اول عنوان و توضیح PR — میخواهم بدانم قرار است چه کاری بکند، چون بدون نیت نمیشود قضاوت کرد. بعد تستها را قبل از کد میخوانم؛ تستها به من میگویند نویسنده چه رفتاری را قصد کرده و آیا حالتهای مرزی را دیده. بعد سراغ درستی و امنیت میروم، بعد طراحی و مرزها، و آخر از همه خوانایی. چیزهای سبکی را اصلاً کامنت نمیکنم چون در CI با Spotless و Checkstyle اجباریاند. و سطح هر نظر را با پیشوند مشخص میکنم تا معلوم باشد کدام blocking است — این جلوی رفتوبرگشتهای بیفایده را میگیرد.»
۱۲. ابزار در دنیای Java
هدف این بخش یک چیز است: انسانها طراحی را review کنند، ماشین سبک و الگوهای باگ را. همهٔ نسخهها مطابق وضعیت میانهٔ ۲۰۲۶ است؛ قبل از استفاده با Maven Central چک کن.
۱۲.۱ نقشهٔ ابزارها
| ابزار | چه چیزی را میگیرد | زمان اجرا | آیا build را میشکند؟ |
|---|---|---|---|
| Spotless + google-java-format | فرمتبندی (و اصلاح خودکار) | validate/verify |
بله (check) |
| Checkstyle | قواعد سبک و ساختار (طول متد، نامگذاری، Javadoc) | verify |
بله |
| PMD | الگوهای بد کد، پیچیدگی، کد مرده، کپیشده (CPD) | verify |
بله |
| SpotBugs | الگوهای باگ روی bytecode (NPE، منابع بستهنشده) | بعد از compile |
بله |
| Error Prone | باگ در زمان کامپایل، با اصلاح خودکار | compile |
بله |
| SonarQube | تجمیع + quality gate روی «کد جدید» | بعد از تستها | بله (gate) |
| JaCoCo | پوشش تست | test/verify |
بله (rule) |
| ArchUnit | قواعد معماری بهعنوان تست | test |
بله |
۱۲.۲ Spotless: بحث سبک را برای همیشه ببند
اولین ابزاری که باید اضافه کنی. دلیلش: بیشترین اصطکاک review را با کمترین بحث حذف میکند.
<plugin>
<groupId>com.diffplug.spotless</groupId>
<artifactId>spotless-maven-plugin</artifactId>
<version>3.9.0</version>
<configuration>
<!-- روی پروژهٔ موجود: فقط فایلهایی که نسبت به main عوض شدهاند -->
<ratchetFrom>origin/main</ratchetFrom>
<java>
<googleJavaFormat>
<version>1.28.0</version>
<style>GOOGLE</style>
</googleJavaFormat>
<removeUnusedImports/>
<formatAnnotations/>
</java>
</configuration>
<executions>
<execution>
<goals>
<goal>check</goal>
</goals>
</execution>
</executions>
</plugin>
mvn spotless:apply # اصلاح خودکار
mvn spotless:check # فقط بررسی؛ در CI اجرا میشود
ratchetFrom کلید پذیرش در پروژههای موجود است: بهجای یک commit غولپیکر که کل تاریخچهٔ git blame را خراب میکند، فقط فایلهای تغییریافته را فرمت میکند.
Spotless در نسخههای ۳.x به Maven روی JRE 17+ نیاز دارد. Checkstyle در خط ۱۳.x به JRE 21+ نیاز دارد. Error Prone باید روی JDK 21 یا بالاتر اجرا شود (هرچند میتواند برای بایتکد قدیمیتر کامپایل کند). چون Java 25 آخرین نسخهٔ LTS است و Java 26 هم منتشر شده، این پیشنیازها معمولاً مسئلهساز نیستند — ولی اگر روی JDK 17 ماندهای، نسخهٔ سازگار هر ابزار را از مستندات خودش بردار.
۱۲.۳ Checkstyle
نکتهٔ مهمی که خیلیها از قلم میاندازند: maven-checkstyle-plugin نسخهٔ خودش را دارد و نسخهٔ Checkstyle داخلش قدیمی است؛ باید صریحاً ارتقاء بدهی.
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-checkstyle-plugin</artifactId>
<version>3.6.0</version>
<dependencies>
<!-- بدون این، نسخهٔ قدیمی و پیشفرض استفاده میشود -->
<dependency>
<groupId>com.puppycrawl.tools</groupId>
<artifactId>checkstyle</artifactId>
<version>13.10.0</version>
</dependency>
</dependencies>
<configuration>
<configLocation>build/checkstyle.xml</configLocation>
<consoleOutput>true</consoleOutput>
<failsOnError>true</failsOnError>
<violationSeverity>warning</violationSeverity>
<includeTestSourceDirectory>true</includeTestSourceDirectory>
</configuration>
<executions>
<execution>
<id>checkstyle-validate</id>
<phase>validate</phase>
<goals><goal>check</goal></goals>
</execution>
</executions>
</plugin>
یک کانفیگ کوچک و واقعی — فقط قواعدی که به فصل مربوطاند:
<?xml version="1.0"?>
<!DOCTYPE module PUBLIC
"-//Checkstyle//DTD Checkstyle Configuration 1.3//EN"
"https://checkstyle.org/dtds/configuration_1_3.dtd">
<module name="Checker">
<property name="severity" value="warning"/>
<module name="TreeWalker">
<!-- توابع کوچک -->
<module name="MethodLength">
<property name="max" value="60"/>
</module>
<module name="ParameterNumber">
<property name="max" value="4"/>
</module>
<!-- پیچیدگی -->
<module name="CyclomaticComplexity">
<property name="max" value="10"/>
</module>
<module name="NPathComplexity">
<property name="max" value="200"/>
</module>
<!-- نامگذاری -->
<module name="MethodName"/>
<module name="ConstantName"/>
<module name="LocalVariableName"/>
<!-- عدد جادویی -->
<module name="MagicNumber">
<property name="ignoreNumbers" value="-1, 0, 1, 2"/>
</module>
<!-- تلههای واقعی -->
<module name="EqualsHashCode"/>
<module name="IllegalCatch"/>
<module name="EmptyCatchBlock"/>
<module name="TodoComment">
<!-- TODO باید شناسهٔ تیکت داشته باشد -->
<property name="format" value="TODO(?!\([A-Z]+-\d+\))"/>
</module>
</module>
</module>
Checkstyle سه پیکربندی استاندارد آماده هم دارد (sun_checks.xml، google_checks.xml و openjdk_style.xml) که نقطهٔ شروع خوبیاند، ولی تقریباً هیچ تیمی آنها را دستنخورده استفاده نمیکند.
۱۲.۴ PMD و CPD (تشخیص کپیکاری)
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-pmd-plugin</artifactId>
<version>3.28.0</version>
<configuration>
<rulesets>
<ruleset>/category/java/bestpractices.xml</ruleset>
<ruleset>/category/java/errorprone.xml</ruleset>
<ruleset>/category/java/design.xml</ruleset>
</rulesets>
<printFailingErrors>true</printFailingErrors>
<minimumTokens>100</minimumTokens> <!-- آستانهٔ CPD -->
</configuration>
<executions>
<execution>
<goals>
<goal>check</goal>
<goal>cpd-check</goal>
</goals>
</execution>
</executions>
</plugin>
cpd-check مستقیماً به بحث DRY وصل است: بلوکهای کپیشده را پیدا میکند. ولی یادت باشد که CPD شباهت متنی را میبیند نه تکرار دانش — پس خروجیاش را بهعنوان یک فهرست برای بررسی انسانی بخوان، نه بهعنوان فهرست کارهای اجباری.
۱۲.۵ SpotBugs
روی bytecode کار میکند، پس چیزهایی میبیند که تحلیل متنی نمیبیند.
<plugin>
<groupId>com.github.spotbugs</groupId>
<artifactId>spotbugs-maven-plugin</artifactId>
<version>4.10.3.0</version>
<configuration>
<excludeFilterFile>build/spotbugs-exclude.xml</excludeFilterFile>
<xmlOutput>true</xmlOutput>
<plugins>
<plugin>
<groupId>com.h3xstream.findsecbugs</groupId>
<artifactId>findsecbugs-plugin</artifactId>
<version>1.14.0</version>
</plugin>
</plugins>
</configuration>
<executions>
<execution>
<goals><goal>check</goal></goals>
</execution>
</executions>
</plugin>
فایل استثنا برای مواردی که آگاهانه قبولشان داری:
<FindBugsFilter>
<Match>
<Class name="~.*Entity"/>
<Bug pattern="EI_EXPOSE_REP,EI_EXPOSE_REP2"/>
</Match>
</FindBugsFilter>
۱۲.۶ Error Prone: باگگیری در زمان کامپایل
قویترین بازخورد، سریعترین بازخورد است. Error Prone بهعنوان یک plugin روی خود javac سوار میشود، پس خطاها همان لحظهٔ کامپایل میآیند و بسیاریشان اصلاح خودکار دارند.
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.14.0</version>
<configuration>
<compilerArgs>
<arg>-XDcompilePolicy=simple</arg>
<arg>--should-stop=ifError=FLOW</arg>
<arg>-Xplugin:ErrorProne -Xep:MissingOverride:ERROR -Xep:NullAway:ERROR</arg>
<arg>-XDaddTypeAnnotationsToSymbol=true</arg>
</compilerArgs>
<annotationProcessorPaths>
<path>
<groupId>com.google.errorprone</groupId>
<artifactId>error_prone_core</artifactId>
<version>2.50.0</version>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>
-XDcompilePolicy=simple و --should-stop=ifError=FLOW اختیاری نیستند؛ بدون آنها Error Prone یا اجرا نمیشود یا رفتار عجیب نشان میدهد. -XDaddTypeAnnotationsToSymbol=true وقتی با JDK 21 و بالاتر build میکنی لازم است. و یک نکتهٔ عملی که خیلیها را گیر میاندازد: اگر Lombok هم داری، هر دو باید در annotationProcessorPaths باشند — بهمحض اینکه این بخش را تعریف کنی، Maven دیگر processorها را از classpath برنمیدارد و Lombok بیصدا از کار میافتد. فصل lombok این تعامل را باز کرده است.
۱۲.۷ SonarQube و quality gate روی «کد جدید»
مهمترین ایدهٔ کاربردی SonarQube در نسخههای اخیر Clean as You Code است: gate تقریباً فقط روی کد جدید (چیزی که از یک خط پایه به بعد اضافه یا تغییر کرده) اعمال میشود.
چرا این تغییر مهم است؟ چون در یک پروژهٔ ۲۰۰ هزار خطی، اجبار به رفع ده هزار مشکل قدیمی غیرممکن است و در عمل باعث میشود تیم gate را خاموش کند. ولی «کد جدیدت تمیز باشد» کاملاً شدنی است — و ریاضیاش کار میکند: با گذشت زمان و لمس شدن فایلها، درصد کد تمیز بالا میرود.
mvn -B verify org.sonarsource.scanner.maven:sonar-maven-plugin:5.7.0.6970:sonar \
-Dsonar.projectKey=my-service \
-Dsonar.host.url=https://sonar.example.internal \
-Dsonar.token=$SONAR_TOKEN
شرطهای معمول یک gate سالم — همه روی کد جدید: پوشش تست حداقل ۸۰٪، خطوط تکراری کمتر از ۳٪، هیچ مشکل جدیدی وارد نشود، و رتبهٔ امنیت/قابلیت نگهداری در حد قابل قبول. تعریف «کد جدید» را هم باید انتخاب کنی — معمولاً «نسخهٔ قبلی» یا «N روز اخیر» — چون بدون خط پایه، شرطهای کد جدید بیمعنیاند.
اگر پوشش را هدف کنی، تیم یاد میگیرد تستهایی بنویسد که کد را اجرا میکنند ولی هیچ assert معناداری ندارند — پوشش ۹۰٪ با اطمینان صفر. دو پادزهر: (۱) پوشش را روی کد جدید بگذار نه کل پروژه، چون آنجا هنوز معنا دارد؛ (۲) اگر واقعاً میخواهی کیفیت تست را بسنجی، mutation testing استفاده کن — که در فصل qa-testing-discipline آمده. یک قاعدهٔ ساده هم کمک میکند: در review بپرس «اگر این شرط را برعکس کنم، کدام تست قرمز میشود؟»
۱۲.۸ ArchUnit: قواعد معماری بهعنوان تست
تصمیمهای معماریای که فقط در ویکی نوشته شدهاند، ظرف چند ماه نقض میشوند. ArchUnit آنها را به تست تبدیل میکند.
<dependency>
<groupId>com.tngtech.archunit</groupId>
<artifactId>archunit-junit5</artifactId>
<version>1.5.0</version>
<scope>test</scope>
</dependency>
import com.tngtech.archunit.junit.AnalyzeClasses;
import com.tngtech.archunit.junit.ArchTest;
import com.tngtech.archunit.lang.ArchRule;
import com.tngtech.archunit.library.GeneralCodingRules;
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes;
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses;
import static com.tngtech.archunit.library.Architectures.layeredArchitecture;
import static com.tngtech.archunit.library.dependencies.SlicesRuleDefinition.slices;
@AnalyzeClasses(packages = "com.example.shop")
class ArchitectureTest {
@ArchTest
static final ArchRule layers = layeredArchitecture()
.consideringOnlyDependenciesInLayers()
.layer("Web").definedBy("..web..")
.layer("Application").definedBy("..application..")
.layer("Domain").definedBy("..domain..")
.layer("Persistence").definedBy("..persistence..")
.whereLayer("Web").mayNotBeAccessedByAnyLayer()
.whereLayer("Application").mayOnlyBeAccessedByLayers("Web")
.whereLayer("Persistence").mayOnlyBeAccessedByLayers("Application");
@ArchTest
static final ArchRule domainStaysPure = noClasses()
.that().resideInAPackage("..domain..")
.should().dependOnClassesThat()
.resideInAnyPackage("org.springframework..", "jakarta.persistence..");
@ArchTest
static final ArchRule noCycles = slices()
.matching("com.example.shop.(*)..")
.should().beFreeOfCycles();
@ArchTest
static final ArchRule noFieldInjection =
GeneralCodingRules.NO_CLASSES_SHOULD_USE_FIELD_INJECTION;
@ArchTest
static final ArchRule noStandardStreams =
GeneralCodingRules.NO_CLASSES_SHOULD_ACCESS_STANDARD_STREAMS;
@ArchTest
static final ArchRule servicesAreNamedProperly = classes()
.that().resideInAPackage("..application..")
.and().areAnnotatedWith(org.springframework.stereotype.Service.class)
.should().haveSimpleNameEndingWith("Service");
}
برای پروژههای موجود که همین حالا هزار نقض دارند، FreezingArchRule نقضهای فعلی را «منجمد» میکند و فقط نقضهای جدید را قرمز میکند — همان فلسفهٔ Clean as You Code، در سطح معماری:
import com.tngtech.archunit.library.freeze.FreezingArchRule;
@ArchTest
static final ArchRule frozenLayers = FreezingArchRule.freeze(
noClasses().that().resideInAPackage("..domain..")
.should().dependOnClassesThat().resideInAPackage("..web.."));
# src/test/resources/archunit.properties
freeze.store.default.path=archunit_store
freeze.store.default.allowStoreCreation=true
۱۲.۹ سیمکشی همهچیز در CI
# مثال: مراحل کیفیت در یک pipeline (نحو GitHub Actions)
jobs:
quality:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
with:
fetch-depth: 0 # برای ratchetFrom و «کد جدید» لازم است
- uses: actions/setup-java@v5
with:
distribution: temurin
java-version: '25'
cache: maven
- name: Format + static analysis + tests
run: mvn -B verify # spotless, checkstyle, pmd, spotbugs, tests, jacoco
- name: SonarQube
env:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
run: >
mvn -B org.sonarsource.scanner.maven:sonar-maven-plugin:5.7.0.6970:sonar
-Dsonar.projectKey=my-service
اگر روز اول Checkstyle + PMD + SpotBugs + Sonar را با شدت کامل روی یک مخزن ۲۰۰ هزار خطی فعال کنی، ۱۵ هزار هشدار میگیری. نتیجهٔ قطعی: تیم هشدارها را نادیده میگیرد و ظرف دو هفته کل قضیه خاموش میشود.
ترتیب پذیرش که جواب میدهد:
۱. Spotless با ratchetFrom — بیدرد، بحث سبک را حذف میکند.
۲. Error Prone فقط با چند check در سطح ERROR — کمنویز، ارزش بالا.
۳. Checkstyle با یک کانفیگ کوچک که فقط چیزهای مهم را دارد؛ بهمرور اضافه کن.
۴. SpotBugs با فایل exclude برای پیشزمینهٔ موجود.
۵. Sonar با gate فقط روی کد جدید.
۶. ArchUnit با FreezingArchRule.
اصل حاکم بر همه یکی است: خط پایه را قفل کن، فقط جلوی بدتر شدن را بگیر.
«هرگز با «بیایید ۱۵ هزار هشدار را رفع کنیم» شروع نمیکنم، چون شکست میخورد. استراتژیام «خط پایه را قفل کن» است: Spotless را با ratchetFrom=origin/main میآورم تا فقط کد لمسشده فرمت شود؛ برای Sonar گیت را روی کد جدید تنظیم میکنم؛ برای ArchUnit از FreezingArchRule استفاده میکنم. بعد یک قاعدهٔ ساده در تیم میگذارم: هیچ چیز جدیدی حق ندارد وضع را بدتر کند. سپس هر ماه یک قاعدهٔ کوچک از فهرست منجمد را برمیداریم و بهعنوان یک تیکت کوچک رفعش میکنیم. مهمتر از ابزار، دو تصمیم است: هشدارهای بیارزش را واقعاً خاموش کنم (هشدار نادیدهگرفتهشده بدتر از هشدار نبودن است)، و ابزار را در CI اجباری کنم نه اختیاری — چون چیزی که اختیاری باشد اجرا نمیشود.»
۱۳. قضاوت سنیور: کِی این قواعد را بشکن
۱۳.۱ قواعدی که باید شکسته شوند
هر قاعدهای در این فصل یک ابزار است برای کمکردن هزینهٔ تغییر. جایی که هزینهٔ تغییر موضوعیت ندارد، قاعده هم موضوعیت ندارد.
| موقعیت | قاعدهای که کنار میرود | چرا |
|---|---|---|
| حلقهٔ داغ با نیاز اثباتشدهٔ کارایی | استخراج متد، انتزاع | وقتی profiler ثابت کرده باشد؛ با کامنت و بنچمارک مستند کن |
| اسکریپت یکبارمصرف مهاجرت | تست، لایهبندی، نامگذاری دقیق | عمر مفیدش یک بار اجراست |
| کد تولیدشده (generated) | همهٔ قواعد سبک | مالکش تو نیستی؛ از تحلیل استاتیک مستثنایش کن |
| تستها | DRY | تکرار در تست معمولاً خوانایی میآورد؛ تست باید خودش را توضیح دهد |
| رفع فوری در incident | همهچیز بهجز درستی | اول سرویس را برگردان؛ بعد تیکت پاکسازی با تاریخ |
| کتابخانهٔ عمومی با کاربر بیرونی | سادگی امضا | سازگاری عقبرو مهمتر از زیبایی API است |
۱۳.۲ انسجام با محیط، مهمتر از ترجیح شخصی
این احتمالاً مهمترین درس این فصل برای کسی که تازه سنیور میشود.
اگر وارد یک کدبیس میشوی که همهجا از یک الگو استفاده میکند — حتی الگویی که تو دوستش نداری — و تو در فایل خودت الگوی «بهتر» را میآوری، خالص نتیجه منفی است: حالا کدبیس دو الگو دارد و خواننده باید هر دو را بشناسد. ناهمگونی، هزینهٔ شناختی میسازد؛ و این هزینه معمولاً از سود آن الگوی بهتر بیشتر است.
مسیر درست: با همان الگوی موجود بنویس؛ اگر واقعاً معتقدی الگوی بهتری هست، آن را بهعنوان یک تصمیم تیمی مطرح کن (ADR)، و اگر پذیرفته شد، مسیر مهاجرت هم بده. تغییر سبک با یک PR یکطرفه، نه پذیرفته میشود نه باقی میماند.
پنج حرکت که واقعاً جواب میدهند:
۱. اول ابزار، بعد آدم. هر چیزی که میشود اتوماتیک شود را اتوماتیک کن. آنوقت «قاعده» میشود چیزی بیطرف که CI میگوید، نه چیزی که تو میگویی.
۲. با کد خودت شروع کن. یک PR نمونه که واضحاً تمیزتر است، از ده کامنت آموزنده مؤثرتر است.
۳. یکبار در یک زمان. اگر این ماه روی «تست برای منطق جدید» تمرکز کردهای، همزمان سر نامگذاری و ساختار پکیج بحث نکن.
۴. قواعد را بنویس و مالکشان را تیم کن. یک CONTRIBUTING.md کوتاه که تیم رویش توافق کرده، بحث را از «سلیقهٔ تو در برابر سلیقهٔ من» به «توافق ما» میبرد.
۵. نسبت خود را حفظ کن. اگر بیشتر نظرهایت blocking است، تیم یاد میگیرد از تو دوری کند. سنیور بودن یعنی بدانی کدام تپه ارزش جنگیدن دارد — و بیشترشان ندارند.
۱۳.۳ چطور مصاحبهگر این مهارت را میسنجد
سه فرمت رایج:
۱) تمرین code review. یک قطعهٔ ۵۰–۱۵۰ خطی به تو میدهند و میگویند نظر بده. آنچه واقعاً سنجیده میشود: آیا ترتیب داری؟ کاندیدای ضعیف با فرمت و نام متغیر شروع میکند؛ کاندیدای قوی اول میپرسد این کد قرار است چه کند، بعد به درستی و حالتهای مرزی میرود (nullها، همزمانی، مدیریت خطا، منابع بستهنشده)، بعد به طراحی، و آخر خوانایی — و صراحتاً میگوید «اینها nit هستند و در CI باید اتوماتیک باشند».
۲) کاتای بازآرایی. یک کلاس بدبو میدهند و میگویند بهترش کن. آنچه سنجیده میشود: آیا اول از تست میپرسی؟ آیا قدمهای کوچک برمیداری و بین قدمها تست میزنی؟ آیا نام بازآراییای که میزنی را میدانی («اینجا Introduce Parameter Object میزنم») — چون دانستن نام یعنی کاتالوگ را میشناسی، نه اینکه تصادفی کد را جابهجا میکنی.
۳) سؤال قضاوتی. «کِی کد تکراری را نگه میداری؟»، «کِی تست نمینویسی؟»، «کِی بهعمد بدهی فنی میگیری؟» اینها فیلتر سنیورند: جواب دگم («همیشه تست مینویسم») امتیاز کمتری از جواب مشروط دارد.
«قاعدهٔ «تابع باید کوچک باشد» را در یک mapper نقض کردم: متدی که یک شیء دامنه را به DTO خارجی تبدیل میکرد و حدود ۷۰ خط انتساب فیلد داشت. میشد به شش متد کوچک بشکنمش، ولی نتیجه بدتر میشد: خواننده برای دیدن کل نگاشت باید بین شش متد میپرید و هیچکدام نامی معنادارتر از «بخش دوم نگاشت» نداشت. آن کد یک سطح انتزاع داشت، یک دلیل تغییر داشت، و خطی خوانده میشد. تصمیمم را با یک کامنت کوتاه و یک @SuppressWarnings هدفمند برای Checkstyle مستند کردم تا بعدی بداند این آگاهانه بوده. معیار من طول نبود؛ این بود که شکستنش خوانایی را بهتر میکند یا بدتر.»
«تعارض را قبول میکنم، ولی بهشکل انتخابی: بعضی چیزها قابل معاملهاند و بعضی نه. غیرقابل معامله: درستی، امنیت، برگشتپذیری مهاجرت داده، و تست برای منطق جدیدِ حساس — چون هزینهٔ خرابیشان در تولید از هر ددلاینی بیشتر است. قابل معامله: کامل بودن انتزاعها، حذف تکرار، پوشش تست برای مسیرهای کمریسک، و زیبایی ساختار. وقتی معامله میکنم، آن را ثبت میکنم: یک تیکت با اثر و هزینهٔ مشخص، و اگر ممکن باشد یک TODO با شمارهٔ همان تیکت. چیزی که هرگز نمیکنم این است که بگویم «تمیز نوشتم» در حالی که ننوشتم — چون مدیریت باید بداند سرعت امروز از کجا قرض گرفته شده.»
«میانی کدی مینویسد که کار میکند و تمیز به نظر میرسد؛ سنیور کدی مینویسد که تیم بتواند شش ماه بعد با اطمینان عوضش کند — و میداند کجا سرمایهگذاری کند و کجا نه. سه تفاوت مشخص: (۱) سنیور برای تغییر بهینه میکند نه برای نوشتن؛ یعنی مرزها، تستها و نامها را جدی میگیرد و انتزاعهای زودهنگام را جدی نمیگیرد. (۲) سنیور میداند قاعدهها استثنا دارند و میتواند دلیلش را بگوید؛ میانی قاعدهها را یکنواخت اعمال میکند. (۳) سنیور کیفیت را به سیستم میسپارد نه به تلاش شخصی — ابزار در CI، تستهای معماری، الگوی review — چون میداند چیزی که به انضباط فردی وابسته باشد، در فشار اولین ددلاین از بین میرود.»
اقتصاد، نه زیبایی: کد بیشتر خوانده میشود تا نوشته، و هزینهٔ عمر سیستم عمدتاً هزینهٔ تغییر آن است؛ پس برای خواننده و تغییردهنده بهینه کن.
نامگذاری بالاترین اهرم است: نام باید نیت را فاش کند، اطلاعات غلط ندهد و قابل جستوجو باشد. در تیم فارسیزبان: شناسهها انگلیسی، آوانویسی ممنوع، و یک واژهنامهٔ تیمی برای اصطلاحات دامنه.
توابع: کوچک، یک سطح انتزاع، قاعدهٔ stepdown، کمترین آرگومان، بدون آرگومان boolean، CQS بهعنوان پیشفرض، و بدنهٔ try/catch استخراجشده.
خطا بخشی از طراحی است: استثنا بهجای کد خطا، هرگز null برنگردان، Optional فقط بهعنوان نوع بازگشتی، و استثنایی تعریف کن که فراخواننده لازم دارد.
اصول با دقت: KISS یعنی حذف پیچیدگی عارضی؛ DRY یعنی تکرار دانش نه متن (قاعدهٔ سه، AHA، و هرگز بین مرزهای سرویس)؛ YAGNI یعنی انتزاع را در نیاز دوم بساز نه در حدس اول.
بو → بازآرایی: برای هر بو یک بازآرایی نامدار هست. یازده بازآرایی، ۹۰٪ کار روزانه را میپوشانند — و قاعدهٔ آهنین: قدم کوچک، تست سبز، commit؛ و هرگز بازآرایی و تغییر رفتار در یک PR.
کد legacy: یعنی کد بدون تست. characterization test بنویس، seam بساز، و اگر ترسناک است sprout یا wrap کن بهجای بازنویسی.
بدهی فنی یک دفتر حساب است: آگاهانهی محتاطانه قابل قبول است اگر ثبت شود؛ اولویت پرداخت بر اساس نرخ تغییر ناحیه است، نه زشتیاش.
Review با ترتیب: درستی ← امنیت ← طراحی ← تست ← خوانایی ← nit؛ سطح هر نظر را علامت بزن، PR را کوچک نگه دار، و هر چه ماشینی است را به ماشین بسپار.
ابزار: Spotless (با ratchetFrom)، Checkstyle (نسخهاش را صریح ارتقاء بده)، PMD/CPD، SpotBugs، Error Prone در زمان کامپایل، quality gate روی «کد جدید»، و ArchUnit برای معماری — همه با فلسفهٔ «خط پایه را قفل کن».
و قضاوت: قواعد ابزارند نه دین. انسجام با کد اطراف معمولاً بر ترجیح شخصی میچربد، و بالا بردن استاندارد تیم از راه ابزار و مثال انجام میشود، نه از راه سختگیری در review.
Put a stack of backend job postings side by side and you notice something odd: next to "Spring Boot", "Kafka" and "PostgreSQL" there is almost always one line with no version number and no API — "Clean Code", "SOLID", "familiarity with refactoring". That line is not filler and it is not decoration. The hiring team is measuring one specific thing with it: when somebody has to change the code you wrote six months from now, how long will it take and how likely is something to break?
This chapter is not about beautiful code. It is about cost. A mid-level developer writes code that works; a senior engineer writes code that can be changed safely — and that skill, contrary to popular belief, is not a bag of stylistic preferences. It is a precise technical body of knowledge with named concepts, named catalogues, mechanical techniques and tools you can run in CI.
The site already teaches SOLID and design patterns (solid-patterns), DDD, hexagonal architecture, testing with JUnit and Mockito, and exception mechanics elsewhere. This chapter does not re-teach them; it builds the thing that sits underneath all of them: the daily craft of writing, reading, smelling and refactoring code.
- Why readability is an economic argument: the read/write ratio, the cost of change, and the debt curve.
- Naming as the highest-leverage skill — including the special case of a non-English-speaking team.
- Comments: which ones are a smell and which ones genuinely earn their place.
- Functions: small, one level of abstraction, the stepdown rule, argument count, boolean flags and CQS.
- Error handling as design: exceptions over error codes, null,
Optionalused correctly, and extracting try/catch bodies. - The principles with real refactorings: KISS, DRY (duplication of knowledge, not text), YAGNI, the Boy Scout rule, POLA, Tell Don't Ask, Law of Demeter and SLA.
- The code-smell catalogue with the refactoring that answers each one, in compact Java.
- The refactoring catalogue and its iron rule: refactoring without tests does not exist.
- Legacy code: characterization tests, seams, sprout and wrap.
- Technical debt as a ledger: the four quadrants, when taking debt is right, how to make it visible.
- Code review that improves both the code and the people.
- The Java toolchain: Spotless, Checkstyle, PMD, SpotBugs, Error Prone, SonarQube, JaCoCo and ArchUnit with real configuration.
- Senior judgment: when to break these rules, and exactly what an interviewer is probing for.
1. Readability is an economic argument, not an aesthetic one
Picture two pharmacies. Both stock exactly the same medicines. In the first, shelves are organised by therapeutic class, every box has a legible label, and stock nearest to expiry sits at the front. In the second, everything "works" — every medicine you want is in there somewhere — but the layout follows the order deliveries arrived.
On day one they are identical. By month six, the second pharmacy takes three times as long per prescription and, more importantly, occasionally hands over the wrong box. The difference is not "beauty"; it is error rate and time per operation.
Code is exactly the same. Two measured realities sit behind this entire chapter:
One: code is read far more often than it is written. You write a method once and then read it dozens of times — while debugging, while reviewing, while adding a feature, while responding to an incident. Every second you save in writing and push onto reading comes back multiplied.
Two: the lifetime cost of a system is dominated by maintenance, not initial construction. A successful system lives for years and changes throughout all of them. If initial construction is 20% of total cost, then every decision that makes the code harder to understand and change gets multiplied against the other 80%.
Out of those two comes the conclusion you should be able to state in an interview: "clean code" means optimising for the reader and the changer, not the writer. Any stylistic argument becomes decidable the moment you route it back through that criterion.
Diagram: the cost-of-change feedback loop — نمودار: حلقهٔ بازخورد هزینهٔ تغییر
flowchart LR
A[Unclear code] --> B[Longer to understand]
B --> C[Change made<br/>with low confidence]
C --> D[Defects and rework]
D --> E[Pressure to ship fast]
E --> F[Copy-paste and patches]
F --> A
C --> G[Tests + refactor]
G --> H[Clearer code]
H --> B
The loop has two paths and both are self-reinforcing. The lower path (tests plus refactoring) lowers the cost of understanding; the upper path raises it. What a senior actually does is not write flawless code every time — it is notice which loop the team is on and reverse it.
"Later" is not a date on a calendar. Code written dirty under deadline pressure is precisely the code nobody dares touch tomorrow — so it never gets refactored and never gets tests. The only defensible form of "later" is: a ticket with a number, an owner, and a date in this sprint or the next. If those three things are missing, you are taking on debt without recording it — and section 10 explains why that is the worst case of all.
Weak answer: "Because the code is more readable and prettier and others understand it more easily."
Senior answer: "Because the cost of owning software is dominated by the cost of changing it, not the cost of writing it the first time. Code is read far more than written, so the optimisation target is the reader. And the effect is measurable: lead time for a small change, change failure rate, and how long a new joiner takes to become productive. I treat clean code as a lever on those three numbers, not as a taste preference. Where those numbers don't apply — a one-off migration script, say — I deliberately invest less in cleanliness."
That last sentence is what turns a memorised answer into a judged one.
2. Naming: the highest-leverage daily skill
When you move house, what do you write on the boxes? "Box 3" or "Kitchen — fragile"? Both are names and both "work". The difference shows up at eleven at night when you are hunting for a saucepan. A good name eliminates a search.
Naming has the best effort-to-impact ratio in all of software engineering: renaming costs essentially nothing to execute (the IDE does it), yet it can remove the need for a comment, and sometimes for a whole diagram.
2.1 A name must reveal intent
A good name answers three questions: what is this, what does it do, how is it used? If you need a comment to answer those three, the name has failed.
// Before: the names reveal no intent at all
public List<int[]> getThem() {
List<int[]> list1 = new ArrayList<>();
for (int[] x : theList) {
if (x[0] == 4) { // what does 4 mean?
list1.add(x);
}
}
return list1;
}
// After: no comment needed, everything is explicit
public List<Cell> flaggedCells() {
List<Cell> flaggedCells = new ArrayList<>();
for (Cell cell : gameBoard) {
if (cell.isFlagged()) {
flaggedCells.add(cell);
}
}
return flaggedCells;
}
Nothing structural changed — not one if removed, not one loop. Only names and one data type changed, and the code went from encrypted to self-explanatory. That is exactly what "naming is a lever" means.
2.2 The four classic naming failures
| Failure | Bad example | Why it hurts | Fix |
|---|---|---|---|
| Disinformation | accountList that is actually a Set |
The reader assumes List semantics (order, duplicates) |
accounts |
| Noise words | CustomerData, CustomerInfo, CustomerObject |
Data/Info/Object draw no distinction |
Pick one: Customer |
| Meaningless distinction | getActiveAccount(), getActiveAccounts(), getActiveAccountInfo() |
The reader cannot tell which to call | Name by the real behavioural difference |
| Unsearchable names | d, e, the literal 86400 |
You cannot grep for them | elapsedTimeInDays, SECONDS_PER_DAY |
Practical rule on name length: name length should be proportional to scope. i inside a three-line loop is perfectly fine; a static field on a public class needs a name that is unique and greppable across the whole repository.
2.3 Problem-domain versus solution-domain names
If a concept belongs to the business, use business language (SettlementBatch, ChargebackWindow). If it belongs to the technology, use technical language (RetryingHttpClient, OrderRepository). Mixing them — OrderManagerHelperImpl — is a sign that you do not know what that class is responsible for either. This is the "ubiquitous language" idea you meet in depth in the DDD chapter; naming is the same idea at the small, everyday scale.
If a project contains fetchUser, getUser, retrieveUser and loadUser, every reader spends time working out whether they differ — and usually they do not. Pick one verb per operation and stick to it repository-wide. The converse is also true: if add means "append to a collection", do not also use add for "sum two numbers".
A convention that works well in teams: get* for cheap, no-I/O access; fetch*/load* when it crosses a network or database; find* when the thing may not exist (returning Optional); require*/getOrThrow when absence is an error.
2.4 The special case: naming in a non-English-speaking team
This section is missing from almost every English-language book, yet it is a daily reality for many teams.
Rule one, non-negotiable: identifiers are in English. Not because English is better, but for engineering reasons: the whole ecosystem (frameworks, libraries, logs, error messages) is English, and mixing two languages on one line slows reading; tools and searches work better with one alphabet; and the next person on the team may not speak your language.
Rule two: no transliteration. Identifiers like getMoshtari(), tarikhSodur, mablaghKol are the worst possible case — unreadable to a native speaker of either language and absent from any dictionary.
Rule three, and here is the subtlety: for domain terms with no clean translation, build a team glossary and keep it in the repository. What do you call an accounting document? Voucher? JournalEntry? AccountingDocument? All three are right somewhere. Decide, write it down, and have everyone use the same one. A GLOSSARY.md with two columns (domain term in the local language / approved English identifier) does more for naming consistency than any tool.
Rule four: comments and documentation may be in the local language if the team is monolingual and the decision was made deliberately — but the decision must be uniform. The worst repository is the one where half the comments are in one language and half in another. One important exception: commit messages and Javadoc on shared libraries are better kept in English.
A common trap: to make the UI show the local language, the team puts localised text directly into code — into enum constants, into exception messages, into API responses. The result: changing one word requires a deployment, tests start depending on localised strings, and i18n later becomes impossible.
The correct boundary: machine-readable identifiers and codes are stable and English; display text comes from a separate source.
public enum OrderStatus {
PENDING_PAYMENT,
PAID,
SHIPPED,
CANCELLED
}
and the localised text lives in messages_fa.properties under the key order.status.PENDING_PAYMENT. The mechanics belong to the Spring chapters; the point here is simply: a name is part of the contract; a rendering is not.
"Fix the names first." And the reason: rename is the only refactoring with near-zero practical risk (the IDE does it type-safely, behaviour does not change) while adding the most information for the reader. Then I re-read the code and find that structural problems which were previously hidden now announce themselves — for example, two variables whose correct name turns out to be the same, meaning I have duplicated knowledge; or a class I cannot name without inventing three names, meaning it has three responsibilities.
3. Comments: which ones smell and which ones earn their place
The key sentence is this: every comment is a small failure of the code to express itself — which is sometimes an unavoidable and completely correct failure.
The logic is simple: a comment is not compiled, not tested, and no tool can guarantee it stayed in sync with the code. So comments drift into lies, and a lying comment is worse than no comment at all.
3.1 Comments that smell
// Bad: restating the code
// if the user is active and has a subscription
if (user.isActive() && user.hasSubscription()) { ... }
// Bad: a comment standing in for a good name
int d; // elapsed time in days
// Bad: a changelog comment — that is git's job
/*
* 2024-08-03 - A: method added
* 2024-08-25 - B: rounding bug fixed
*/
// Bad: commented-out code — delete it, git remembers
// processLegacyFormat(payload);
// Bad: ceremonial Javadoc that says nothing
/**
* Gets the name.
* @return the name
*/
public String getName() { return name; }
The cure for the first three was in the naming section: if (user.canAccessPremiumContent()), int elapsedTimeInDays;.
3.2 Comments that genuinely earn their place
A good comment answers "why", not "what". The code always states the what; what the code cannot state is intent, an external constraint, and the history of a decision.
// 1) Explaining a non-obvious decision — information that is not in the code
// Provider's API rejects more than 50 IDs per call (documented limit),
// so we chunk here rather than in the caller.
private static final int PROVIDER_BATCH_SIZE = 50;
// 2) Warning of consequences
// NOT thread-safe: SimpleDateFormat keeps mutable state.
// Each call deliberately creates its own instance.
// 3) Explaining a dense regex or algorithm
// Matches IBAN: 2 letters (country) + 2 check digits + up to 30 alphanumerics
private static final Pattern IBAN = Pattern.compile("^[A-Z]{2}\\d{2}[A-Z0-9]{1,30}$");
// 4) A TODO with a ticket and an owner — not an orphan TODO
// TODO(PLAT-4821): remove after the legacy pricing service is decommissioned (Q3).
// 5) Contract Javadoc for a public API: preconditions, postconditions, exceptions
/**
* Reserves stock for an order.
*
* @param orderId non-null identifier of an existing order
* @return the reservation, never {@code null}
* @throws InsufficientStockException if any line cannot be fully reserved;
* no partial reservation is created in that case
*/
The fifth case matters most: for code other people call (another team, a library consumer), Javadoc is part of the contract, not an extra note. What you must write is what the signature cannot express: is null allowed? Does a failure leave partial effects? Is it thread-safe?
"We might need it later" is an illusion. That code is in git and git log -S will find it. Commented-out code costs you three concrete things: readers spend time working out why it is there, search tools count it as a real usage, and over time nobody dares delete it because nobody knows why it stayed. If you really need it, cut a branch or link it in the ticket.
No — that is a bad slogan. The precise answer: a comment that says what is evidence the code is not expressive enough, and the cure is refactoring, not commenting. But a comment that says why carries information that cannot be expressed in code at all: a documented limit in an external service, a deliberate oddity working around a known bug, or the reason an obvious-looking alternative was rejected. In real projects I am not trying to eliminate comments; I am trying to move them from the "what" bucket into the "why" bucket — and for bigger decisions I write an ADR instead of a comment.
4. Functions: small, single-level, and readable like prose
4.1 Small, and then smaller
The rule is empirical and unforgiving: a function should do one thing; do it well; and do only that. The practical test: if you can extract another function from it whose name is not merely a restatement of the implementation, then the function was doing more than one thing.
4.2 One level of abstraction and the stepdown rule
The biggest single cause of an unreadable function is mixed levels of abstraction: one line talks about a business concept and the next talks about an array index.
The stepdown rule: code should read top-down like prose; each function should call functions exactly one level of abstraction below itself.
// Before: four levels of abstraction in one method
public void processOrder(Order order) {
if (order == null || order.getLines().isEmpty()) {
throw new IllegalArgumentException("bad order");
}
BigDecimal total = BigDecimal.ZERO;
for (OrderLine line : order.getLines()) {
BigDecimal lineTotal = line.getUnitPrice()
.multiply(BigDecimal.valueOf(line.getQuantity()));
if (line.getDiscountPercent() > 0) {
lineTotal = lineTotal.multiply(
BigDecimal.ONE.subtract(
BigDecimal.valueOf(line.getDiscountPercent())
.divide(BigDecimal.valueOf(100), 4, RoundingMode.HALF_UP)));
}
total = total.add(lineTotal);
}
if (order.getCustomer().getTier() == Tier.GOLD) {
total = total.multiply(new BigDecimal("0.95"));
}
order.setTotal(total.setScale(2, RoundingMode.HALF_UP));
jdbcTemplate.update("UPDATE orders SET total = ? WHERE id = ?",
order.getTotal(), order.getId());
mailSender.send(order.getCustomer().getEmail(), "Order confirmed", "...");
}
// After: one level per method; it reads like a table of contents
public void processOrder(Order order) {
validate(order);
Money total = pricing.priceOf(order);
order.applyTotal(total);
orderRepository.save(order);
notifications.orderConfirmed(order);
}
And one level down:
final class OrderPricing {
private static final BigDecimal GOLD_TIER_FACTOR = new BigDecimal("0.95");
Money priceOf(Order order) {
Money subtotal = order.lines().stream()
.map(this::priceOfLine)
.reduce(Money.ZERO, Money::plus);
return applyTierDiscount(subtotal, order.customerTier());
}
private Money priceOfLine(OrderLine line) {
return line.unitPrice()
.times(line.quantity())
.minusPercent(line.discountPercent());
}
private Money applyTierDiscount(Money amount, Tier tier) {
return tier == Tier.GOLD ? amount.times(GOLD_TIER_FACTOR) : amount;
}
}
Several things improved at once: abstraction levels separated, raw BigDecimal disappeared behind a domain type (Money) — the antidote to primitive obsession you meet in section 7 — the magic number 0.95 got a name, and the body of processOrder can now be read aloud to a business person.
4.3 Argument count
The preferred order is zero > one > two > three. Above three requires justification.
- Zero arguments (niladic): best. Usually means the function operates on the object's own state.
- One argument (monadic): either asks a question (
boolean exists(File f)) or transforms something (InputStream open(File f)). - Two arguments (dyadic): fine when the two values are natural parts of one concept (
new Point(x, y)).assertEquals(expected, actual)is poor because the order must be memorised, not inferred. - Three or more: almost always means several arguments together form one concept and belong in an object → Introduce Parameter Object.
// Bad: the reader must memorise the order, and the compiler cannot help
transfer(fromAccountId, toAccountId, amount, currency, reference, true, false);
// Better: each value's meaning lives in its type
transfer(new TransferCommand(
AccountId.of(fromAccountId),
AccountId.of(toAccountId),
Money.of(amount, currency),
Reference.of(reference)));
sendReport(true) means nothing at the call site. The reader has to go and read the signature. Worse: a boolean in a signature means the method does two different things and you have crammed them into one body — "one function, one thing" has been violated.
// Bad
public void save(Order order, boolean validate) {
if (validate) { validator.check(order); }
repository.persist(order);
}
// Good: the Remove Flag Argument refactoring
public void save(Order order) {
repository.persist(order);
}
public void validateAndSave(Order order) {
validator.check(order);
save(order);
}
The genuine exception: when a boolean is domain data rather than a behaviour switch — new User(name, emailVerified). Even there, if you have more than one boolean, build an enum or an object so the call site carries meaning.
4.4 Command-Query Separation (CQS)
CQS: every function either does something (changes state and returns nothing) or answers a question (returns something and changes nothing) — never both.
// Bad: it both asks and mutates. At the call site the reader is lost:
// if (set("username", "admin")) ... means "if it was set" or "if it existed"?
public boolean set(String attribute, String value) { ... }
// Good
if (attributeExists("username")) {
setAttribute("username", "admin");
}
Several well-known APIs break it deliberately and correctly: Stack.pop(), Iterator.next(), Queue.poll(), Map.put() returning the previous value, and every atomic operation like AtomicInteger.getAndIncrement(). In all of them, breaking CQS has a reason: without it the operation would not be atomic, or would not be usable at all.
Senior rule: treat CQS as the default; when you break it, announce it in the method name (getAndIncrement, putIfAbsent, removeAndReturn). What actually damages code is not violating CQS, it is violating it silently.
4.5 Output arguments and hidden side effects
An output argument (void appendFooter(StringBuilder report)) forces the reader to consult the signature to discover what is being mutated. In modern Java it is usually better to return a value and keep inputs immutable. A hidden side effect is worse: a method called checkPassword that also resets the session on the way through. That effect must either be in the name or not happen at all.
This is a trap question. A senior answer does not start with a number: "My criterion is not line count, it is the number of abstraction levels and the number of reasons to change. A 30-line method whose lines are all at one level doing one thing — a switch over an enum, say — is perfectly acceptable; an 8-line method that mixes business logic, SQL and string formatting is not. In practice, when I have to scroll to understand a method, or when I cannot describe what it does in one sentence without using the word 'and', it is time to extract. In a team I will set a Checkstyle ceiling like 60 lines, but as an alarm, not as a law."
5. Error handling as design
The mechanics of exceptions (checked vs unchecked, try-with-resources, the cause chain) belong to the exceptions chapter. Here we look only at the design dimension: errors are part of your API, not an afterthought.
5.1 Exceptions over error codes
Error codes interleave the main logic with error logic and force you to check immediately after every call. Exceptions separate the two.
// Bad: the happy path is buried under error checks
int code = device.shutDown();
if (code == DeviceCode.OK) {
int c2 = registry.unregister(device);
if (c2 == DeviceCode.OK) { log.info("done"); }
else { log.error("unregister failed"); }
} else {
log.error("shutdown failed");
}
// Good: the happy path is readable, errors handled in one place
try {
device.shutDown();
registry.unregister(device);
} catch (DeviceOperationException e) {
log.error("Could not decommission device {}", device.id(), e);
}
5.2 Extract the body of try/catch
try should be almost the first word of the method, and nothing should follow the catch. The reason is the same "one function, one thing": error handling is itself one thing.
public void delete(Page page) {
try {
deletePageAndAllReferences(page);
} catch (DataAccessException e) {
throw new PageDeletionFailedException(page.id(), e);
}
}
private void deletePageAndAllReferences(Page page) {
pageRepository.delete(page);
referenceRepository.deleteByPage(page.id());
searchIndex.remove(page.id());
}
5.3 Define the exception the caller actually needs
The single biggest mistake in exception design is building the hierarchy around the source of the error, when the caller decides based on what it must do next.
// Bad: the caller has to handle three unrelated things that all mean the same
try {
port.call();
} catch (SocketTimeoutException | ConnectException | SSLHandshakeException e) {
// all three mean: not right now, retry later
}
// Good: a thin layer translating technical failure into a domain concept
class PaymentGatewayClient {
PaymentResult charge(ChargeRequest request) {
try {
return httpPort.post(request);
} catch (IOException | SSLException e) {
throw new PaymentGatewayUnavailableException(request.reference(), e);
}
}
}
Now the caller has one decision instead of three. This is the wrapper pattern, and it simultaneously keeps the HTTP library out of your business logic.
5.4 Don't return null, don't pass null
null is not a value; it is a time bomb that carries no information about its origin at the point where it explodes.
// Bad
List<Employee> employees = getEmployees();
if (employees != null) { // this if is repeated at every call site
for (Employee e : employees) { ... }
}
// Good: an empty collection, never null
public List<Employee> employees() {
return employees == null ? List.of() : List.copyOf(employees);
}
For parameters, be explicit at public boundaries:
public Reservation reserve(OrderId orderId, Quantity quantity) {
Objects.requireNonNull(orderId, "orderId");
Objects.requireNonNull(quantity, "quantity");
...
}
Optional was designed in the JDK for one purpose: the return type of methods that may have no result. Three common abuses:
Optionalfields —Optionalis not serialisable, behaves oddly with Jackson/JPA, and adds a wrapper to every instance.Optionalparameters — the caller is forced to writeOptional.of(x); provide an overload instead.optional.get()without checking — that is aNullPointerExceptionunder a different name (aNoSuchElementExceptionin modern JDKs).
The correct shape:
public Optional<Customer> findByEmail(Email email) { ... }
// At the call site — never get()
Customer customer = customers.findByEmail(email)
.orElseThrow(() -> new CustomerNotFoundException(email));
String label = customers.findByEmail(email)
.map(Customer::displayName)
.orElse("unknown");
The difference is where the error is discovered and what the signature declares. null is invisible in a signature: Customer findByEmail(Email e) says nothing about absence, so the caller forgets and a NullPointerException detonates somewhere further away at runtime. Optional<Customer> puts the same information into the type: the compiler forces the caller to make a decision. So Optional moves the problem from runtime to compile time and from documentation into the signature. Of course, if somebody calls get() without checking, the problem returns — so the tool alone is not enough; the usage discipline is part of it.
6. The principles, with real refactorings — not slogans
6.1 KISS and accidental complexity
KISS says "keep it simple". But what does "simple" mean? The precise, usable definition separates essential complexity from accidental complexity.
- Essential complexity comes from the problem itself. Computing VAT with several rate bands really is complicated; no design removes that.
- Accidental complexity is what we add: a layer that adds nothing, an abstraction over a single implementation, configuration that never changes, a framework of which 5% is used.
// Accidental complexity: four types for what one method does
public interface DiscountStrategy { BigDecimal apply(BigDecimal amount); }
public class DiscountStrategyFactory { ... }
public class DefaultDiscountStrategy implements DiscountStrategy { ... }
public class DiscountStrategyConfig { ... }
// and there is exactly one implementation in the whole project
// KISS: until there is a real need to swap implementations
BigDecimal discounted = amount.multiply(GOLD_TIER_FACTOR);
6.2 DRY: duplication of knowledge, not of text
This is the most misunderstood principle in all of software engineering. The precise definition: every piece of knowledge must have a single, unambiguous, authoritative representation within a system.
The operative word is "knowledge", not "text". Two pieces of code that look alike but change for different reasons are not duplication — they are coincidental duplication, and merging them is a future bug.
// These two only *look* the same
BigDecimal invoiceTotal = quantity.multiply(unitPrice); // invoice amount
BigDecimal shippingCost = weightKg.multiply(ratePerKg); // shipping cost
If you unify them into multiplyTwoNumbers(a, b) you have centralised no knowledge at all — you have merely glued two independent concepts together. The day shipping becomes "minimum 5 units + weight × rate", you must either pollute the shared function with a boolean (hello, flag argument) or split it apart again.
By contrast, this really is duplicated knowledge:
// The same business rule in three files: "orders under 100,000 pay shipping"
if (order.total().compareTo(new BigDecimal("100000")) < 0) { ... } // OrderService
if (o.getTotal().doubleValue() < 100000) { ... } // CheckoutController
// and in a test: assertTrue(cart.total() < 100_000)
One business rule represented in three places. If the threshold changes and one site is missed, you have a production bug. The cure:
public final class ShippingPolicy {
private static final Money FREE_SHIPPING_THRESHOLD = Money.of(100_000);
public boolean qualifiesForFreeShipping(Money orderTotal) {
return orderTotal.isGreaterThanOrEqual(FREE_SHIPPING_THRESHOLD);
}
}
Diagram: deciding what to do with duplication — نمودار: تصمیمگیری دربارهٔ تکرار
flowchart TD
A[Two similar pieces of code] --> B{Do they change<br/>for the same reason?}
B -- No --> C[Coincidental duplication<br/>Leave them separate]
B -- Yes --> D{Seen it 3 times?}
D -- No --> E[Wait. Duplicate once more<br/>and learn the shape]
D -- Yes --> F[Extract the shared knowledge<br/>into one named concept]
F --> G{Does the abstraction need<br/>a flag or a mode?}
G -- Yes --> C
G -- No --> H[Good abstraction]
The Rule of Three: write it once. Duplicate it the second time and be annoyed. Abstract on the third. The reasoning is statistical: with two samples you cannot tell which parts are genuinely shared; with three, the right shape of the abstraction becomes visible.
WET and AHA are the useful counterweights to over-DRYing. WET ("Write Everything Twice", or more sarcastically "We Enjoy Typing") reminds you that a little duplication is cheaper than a wrong abstraction. AHA ("Avoid Hasty Abstractions") states it as a preference: prefer duplication over the wrong abstraction — because duplication is local and removable, whereas a wrong abstraction spreads through the codebase and is expensive to undo.
| Situation | Keep the duplication | Build the abstraction |
|---|---|---|
| The two copies change for different reasons | ✅ | ❌ |
| Both express the same business rule | ❌ | ✅ |
| You have only seen two instances | ✅ (wait) | ⚠️ premature |
| The abstraction would need a flag/mode | ✅ | ❌ sign of a wrong abstraction |
| They live in two different bounded contexts | ✅ | ❌ |
| Duplication in tests for readability | ✅ | ⚠️ shared setup only |
Suppose two services each have a DTO with similar fields. The team pulls them into a shared common-model module. Six months later service A needs a new field; but changing common-model means service B must be rebuilt and redeployed too. You have created a temporal dependency between two teams that appears nowhere on your architecture diagram.
Senior rule: DRY applies inside a boundary (a bounded context, a service), not across boundaries. Between services, duplicating a DTO is almost always cheaper than sharing it. The ddd and ms-foundations chapters cover where those boundaries come from.
"DRY means every piece of knowledge should have a single authoritative representation — the emphasis is on knowledge, not on the text of the code. If two fragments merely look alike but change for different reasons, that is coincidental duplication and merging them is wrong, because at the first divergence you are forced to pollute the abstraction with a flag. My practical test is 'do these change together?', not 'do these look alike?'. I apply the Rule of Three, and I deliberately do not apply DRY across service or context boundaries because it creates deployment coupling. The mental counterweight is AHA: duplication is far cheaper than the wrong abstraction."
6.3 YAGNI and speculative generality
YAGNI — "You Aren't Gonna Need It": do not build the capability you do not need now, even when you are sure you will need it later.
The reasoning has three parts: (1) usually you never need it; (2) when you do, it is usually not in the shape you guessed; (3) meanwhile that code must be maintained, tested, read and carried through every build.
Its companion smell is speculative generality: an interface with one implementation, a type parameter that never varies, hooks and events with no listeners, configuration that is always left at default, an abstract class with exactly one subclass.
// Speculative generality: "maybe we will have several providers later"
public interface NotificationChannel { void send(Notification n); }
public class SmsChannel implements NotificationChannel { ... }
public abstract class AbstractNotificationChannel implements NotificationChannel { ... }
public class NotificationChannelRegistry { ... }
public class NotificationChannelProperties { ... }
// and for two years there has only ever been SMS
A common misreading is that YAGNI means "don't design". It does not. The distinction is subtle but vital:
- Building a capability you do not need → YAGNI says don't.
- Not building a capability you do not need, but writing things so that adding it later is cheap → that is simply good design.
You build the abstraction when the second need arrives, not when you predict one will. The difference lies in the cost of reversal: simple code can be generalised later; a wrong early abstraction must first be torn out, and usually nobody dares.
The genuine exceptions a senior does build up front, and where YAGNI is not an excuse: security boundaries, data models whose migration is expensive, public API identifiers, and the observability skeleton. Those are inherently hard to reverse.
6.4 The Boy Scout Rule
"Leave the campground cleaner than you found it." Engineering translation: every time you open a file for a ticket, make one small, low-risk improvement too — a better name, an extracted method, a dead comment removed.
Its power is that it is incremental: quality rises without a rewrite project. Its risk is obvious — if your PR touches half the repository, nobody can review it. The practical boundary: the improvement must be inside files you touched for the ticket, and take less than a few minutes; anything bigger deserves its own ticket.
6.5 The Principle of Least Astonishment (POLA)
Code must do what its name promises — no more and no less. If the reader says "wait, what?", you have manufactured a future bug.
// Astonishing: it is called "get" but it writes
public User getUser(String id) {
User u = cache.get(id);
if (u == null) {
u = db.load(id);
cache.put(id, u);
auditLog.record("user accessed", id); // hidden side effect
}
return u;
}
In tests this method suddenly depends on a database and an audit sink, and nobody understands why. If caching and auditing are part of the contract, make the name honest: loadAndAuditUser — or better, separate the responsibilities.
6.6 Composition over inheritance
Inheritance mechanics and polymorphism belong to oop-deep, and the Liskov Substitution Principle to solid-patterns. What matters at the daily-craft level:
Inheritance is the strongest coupling in Java — the subclass becomes dependent on the parent's implementation details, and that dependency is locked at compile time. Composition gives you the same reuse with weaker coupling.
// Bad: inheritance for code reuse, not for "is-a"
public class OrderService extends JdbcSupport { ... } // an OrderService is not a JdbcSupport
// Good: composition — an explicit, swappable dependency
public class OrderService {
private final OrderRepository orders;
OrderService(OrderRepository orders) { this.orders = orders; }
}
Rule: use inheritance only when the relationship is genuinely "is-a" and you want the subtype to be substitutable everywhere the parent appears. For "code reuse", pick composition or an interface default method.
6.7 Tell, Don't Ask and the Law of Demeter
Tell Don't Ask: tell an object what to do; do not pull its data out to decide for it.
// Ask: the rule has been dragged out of the object
if (account.getBalance().compareTo(amount) >= 0) {
account.setBalance(account.getBalance().subtract(amount));
}
// Tell: the rule lives with the data
account.withdraw(amount); // it checks the balance and throws itself
The first version has three problems: the rule is repeated at every call site (violating DRY), the object cannot guarantee its own invariants, and under concurrency it is a race.
The Law of Demeter — "only talk to your immediate friends": a method should only call methods on itself, its parameters, objects it creates, and its direct fields.
// Violation: a message chain — you depend on the internals of three other objects
String city = order.getCustomer().getAddress().getCity().getName();
// Fix: Hide Delegate — ask the object, not its innards
String city = order.shippingCityName();
Apply it blindly and you end up with a mountain of pass-through getX() methods — that is the Middle Man smell. Two correct exceptions:
- Pure data structures (DTOs, records, parsed JSON) have no Law of Demeter; they are data, not behavioural objects.
dto.getAddress().getCity()is fine. - Fluent interfaces like
Stream, builders, orassertThat(x).isNotNull().hasSize(3)are chains, but each step returns the same object (or its own type), not its internals.
The right criterion: does this chain make you dependent on the internal structure of another object? If yes, it is a violation.
6.8 The Single Level of Abstraction principle (SLA)
The summarising rule for this section: within any block of code, every statement should sit at the same level of abstraction. It is the only criterion that makes "which function should I split?" decidable, and it is what drives all the refactorings in section 8.
"KISS. Because duplication is a local, visible cost, whereas the complexity from a wrong abstraction is a systemic, hidden one. If removing duplication forces me to build a generic layer with several flags, or an inheritance hierarchy that is harder to read than the duplication was, I have lost the trade. In practice, when I hit that conflict, I first ask whether this is really duplicated knowledge or just textual similarity; most conflicts dissolve there. If it genuinely is duplicated knowledge but there is no clean abstraction, I keep the duplication and reduce the risk of divergence another way — a shared constant, or a shared test that pins both sites."
7. The code-smell catalogue
A code smell is not a bug. It is a surface symptom that usually points at a deeper problem — like a burning smell in a kitchen: not itself the problem, but you go and look.
The senior skill is knowing, for each smell, the refactoring that answers it. The table below is the full map; compact examples follow.
| Smell | Symptom | Underlying problem | Refactoring |
|---|---|---|---|
| Long Method | You have to scroll | Several abstraction levels mixed | Extract Function |
| Large Class / God Object | A class that does everything | SRP violated | Extract Class, Extract Delegate |
| Long Parameter List | 4+ parameters | Concepts glued together | Introduce Parameter Object |
| Primitive Obsession | String/BigDecimal for domain concepts |
No domain model | Replace Primitive with Object |
| Data Clumps | The same fields always travel together | A missing concept | Extract Class |
| Feature Envy | A method uses another class's data more than its own | Method in the wrong place | Move Function |
| Shotgun Surgery | One change, ten files | Knowledge scattered | Move Function/Field, Combine into Class |
| Divergent Change | One class changes for three unrelated reasons | Multiple responsibilities | Extract Class |
| Message Chains | a.getB().getC().getD() |
Law of Demeter violated | Hide Delegate |
| Middle Man | A class that only delegates | A worthless layer | Remove Middle Man, Inline |
| Temporal Coupling | You must call init() before run() |
An invisible contract | Combine Functions, complete constructor |
| Flag Argument | doThing(true) |
Two things in one method | Remove Flag Argument |
| Anemic Domain Model | Entities are only getters/setters | Logic scattered into services | Move Function into the entity |
| Leaky Abstraction | Implementation details escape the interface | Wrong boundary | Change signature / Encapsulate |
| Speculative Generality | An interface with one implementation | YAGNI violated | Inline / Collapse Hierarchy |
| Comments (as deodorant) | Comments explaining complicated code | Code is not expressive | Extract Function, Rename |
7.1 Primitive obsession and data clumps
// Smell: Primitive Obsession + Data Clumps (these four always travel together)
public void ship(String street, String city, String postalCode, String country,
BigDecimal weight, String weightUnit) { ... }
// Cure: two missing concepts finally get names
public record Address(String street, String city, PostalCode postalCode, CountryCode country) {}
public record Weight(BigDecimal value, WeightUnit unit) {
public Weight {
if (value.signum() < 0) throw new IllegalArgumentException("weight must be >= 0");
}
}
public void ship(Address destination, Weight weight) { ... }
The real payoff is not just readability: validation now happens once in the constructor (a record with a compact constructor), accidentally swapping city and country no longer compiles, and related behaviour (address formatting, unit conversion) has a home.
7.2 Feature Envy and the anemic domain model
// Smell: the method lives in OrderService but wants all of Order's data
class OrderService {
boolean isEligibleForFreeShipping(Order order) {
return order.getTotal().compareTo(THRESHOLD) >= 0
&& !order.getShippingAddress().isRemote()
&& order.getCustomer().getTier() != Tier.BASIC;
}
}
// Cure: Move Function — the rule goes to live next to its data
class Order {
boolean isEligibleForFreeShipping() {
return total.isGreaterThanOrEqual(FREE_SHIPPING_THRESHOLD)
&& !shippingAddress.isRemote()
&& customerTier != Tier.BASIC;
}
}
When every entity is only getters and setters and all the logic lives in *Service classes, you have written procedural code with object-oriented syntax. The symptoms: thousand-line services, the same if repeated across several services, and entities that cannot protect their own invariants.
But — and here judgment is required — it is not always wrong. For a simple CRUD service, an anemic model is entirely rational and cheap. It turns bad when you have complex business rules. Practical test: if you can state the rules in one sentence and there are fewer than three of them, don't worry; if you have a domain language with dozens of rules and states, build a rich model. The ddd chapter completes this picture.
7.3 Temporal coupling
// Smell: an invisible contract — get the order wrong and you get a NullPointerException
Report report = new Report();
report.setTemplate(template);
report.loadData();
report.render(); // forget loadData and it explodes
// Cure: the object is either fully constructed or not constructed at all
Report report = Report.from(template, dataSource); // factory, valid state guaranteed
String html = report.render();
7.4 Shotgun Surgery versus Divergent Change
These two are mirror images and are constantly confused:
- Shotgun Surgery: one conceptual change → ten files touched. Knowledge is scattered; it must be gathered.
- Divergent Change: one file → changes for three unrelated reasons. Responsibilities are mixed; they must be split.
A Shotgun Surgery example: adding a new payment method requires changing an enum, a switch in a service, another switch in reporting, and an if in the UI. The cure: gather all the knowledge about each payment method into one place (a class, or an enum with behaviour) — which lands you directly on Replace Conditional with Polymorphism.
A good answer has three parts: the smell, why it was dangerous, and the named refactoring. For example: "In one service, a switch over payment type was duplicated across four files — Shotgun Surgery. The danger was that adding a new method meant four places to forget, and we had in fact forgotten one, producing an error in a financial report. I first wrote characterization tests for the existing behaviour, then applied Replace Conditional with Polymorphism: one interface per behaviour and a Map<PaymentType, PaymentHandler> that Spring populates. Then I added an ArchUnit test forbidding any switch over that enum outside the package." That final part — preventing the regression — is what marks a senior.
8. The refactoring catalogue and its iron rule
8.1 The precise definition
Refactoring: changing the internal structure of software to make it easier to understand and cheaper to change, without changing its observable behaviour.
That last clause is everything. If behaviour changed, it was not a refactoring — it was a bug fix, a feature, or (worst case) a new bug. Hence:
Without a safety net of tests, what you are doing is not "refactoring"; it is "hopeful rewriting". The correct order is always:
- Make sure a green test covers the behaviour (if not, write one first — section 9).
- Take one small refactoring step.
- Run the tests. Green? Commit.
- Repeat.
And: never mix refactoring and behaviour change in one commit or one PR. If you mix them, the reviewer cannot tell which change was safe and which was not; you have effectively neutralised the review. The right pattern is: first PR "refactoring only, no behaviour change", second PR "the new feature".
Diagram: the small, safe refactoring loop — نمودار: چرخهٔ کوچک و امن بازآرایی
stateDiagram-v2
[*] --> GreenTests
GreenTests --> SmallStep: apply ONE refactoring
SmallStep --> RunTests
RunTests --> GreenTests: pass -> commit
RunTests --> Revert: fail
Revert --> GreenTests: undo, take a smaller step
GreenTests --> [*]: structure is good enough
8.2 The eleven refactorings that cover 90% of daily work
| Refactoring | When | IDE shortcut (IntelliJ) |
|---|---|---|
| Extract Function | A block you can name | Ctrl+Alt+M |
| Extract Variable | A complex expression inside a condition | Ctrl+Alt+V |
| Inline Function/Variable | An abstraction that adds nothing | Ctrl+Alt+N |
| Rename (Change Function Declaration) | The name lies | Shift+F6 |
| Extract Class | The class has several responsibilities | F6 / Refactor menu |
| Introduce Parameter Object | Long parameter list | Ctrl+Alt+P, then extract |
| Replace Conditional with Polymorphism | A repeated switch over a type |
manual |
| Replace Magic Literal | An unnamed number/string | Ctrl+Alt+C |
| Replace Nested Conditional with Guard Clauses | Deep nesting | manual |
| Decompose Conditional | A complicated condition | Ctrl+Alt+M on the condition |
| Encapsulate Collection | A getter returning a mutable collection | manual |
| Separate Query from Modifier | A method that both asks and mutates | manual |
| Replace Constructor with Factory Method | Construction needs validation/selection | manual |
Guard clauses instead of nesting
// Before: four levels of nesting; the main path is at the bottom of a well
public Money payAmount(Employee e) {
Money result;
if (e.isSeparated()) {
result = Money.ZERO;
} else {
if (e.isRetired()) {
result = retiredAmount();
} else {
if (e.isOnLeave()) {
result = leaveAmount(e);
} else {
result = normalPay(e);
}
}
}
return result;
}
// After: special cases close early, the main path is the last line
public Money payAmount(Employee e) {
if (e.isSeparated()) return Money.ZERO;
if (e.isRetired()) return retiredAmount();
if (e.isOnLeave()) return leaveAmount(e);
return normalPay(e);
}
Decompose Conditional plus Replace Conditional with Polymorphism
// Before
if (order.getType() == OrderType.EXPORT) {
tax = base.multiply(BigDecimal.ZERO);
} else if (order.getType() == OrderType.DOMESTIC) {
tax = base.multiply(new BigDecimal("0.09"));
} else if (order.getType() == OrderType.EXEMPT) {
tax = BigDecimal.ZERO;
}
// After: each type's knowledge lives with it; adding a type touches one place
public enum OrderType {
EXPORT { public Money tax(Money base) { return Money.ZERO; } },
DOMESTIC{ public Money tax(Money base) { return base.times(VAT_RATE); } },
EXEMPT { public Money tax(Money base) { return Money.ZERO; } };
private static final BigDecimal VAT_RATE = new BigDecimal("0.09");
public abstract Money tax(Money base);
}
In modern Java the alternative is a sealed interface with a pattern-matching switch, where the compiler checks exhaustiveness — the modern-java chapter covers the mechanics. Choosing between them is a real decision: an enum with behaviour keeps knowledge inside the type (good when the behaviour belongs to the domain); sealed + switch keeps behaviour outside (good when you have several independent operations over the same types and do not want to clutter the type with all of them).
Encapsulate Collection
// Bad: anyone can mutate the internal list and break the order's invariants
public List<OrderLine> getLines() { return lines; }
// Good: mutation only through meaningful operations
public List<OrderLine> lines() {
return Collections.unmodifiableList(lines); // or List.copyOf(lines)
}
public void addLine(Product product, Quantity quantity) {
if (status != OrderStatus.DRAFT) {
throw new OrderNotEditableException(id, status);
}
lines.add(new OrderLine(product, quantity));
}
Separate Query from Modifier
// Bad: it both looks up and raises an alarm
public Customer findCustomerAndNotifyIfBlocked(Email email) { ... }
// Good
Customer customer = customers.require(email);
if (customer.isBlocked()) {
alerts.blockedCustomerAccessAttempt(customer.id());
}
8.3 Preparatory refactoring
One of the most useful practical ideas: when adding a feature is hard in the current structure, first change the structure so the addition becomes easy, then add it. The famous metaphor: if you want to go a hundred kilometres east but the road runs north, first get onto the right road.
It also pays off in review: the first PR has no behaviour change at all and is approved quickly; the second is small and focused.
This is often the real difference between "refactoring" and "risky editing". Shift+F6 (Rename) in IntelliJ updates every reference type-safely — including strings inside Javadoc and, in some cases, Spring configuration files. Textual search-and-replace does not: it will happily rewrite an identifier inside a SQL string, or miss one.
Practical senior rule: any refactoring the IDE performs automatically is relatively safe even without tests; any refactoring done by hand is forbidden without them.
8.4 Clean code is not only Java: readable SQL
The same principles apply to SQL: naming, one level of abstraction per step, and no magic numbers. Compare:
-- Before: nested, unnamed steps with a magic threshold
SELECT c.name, SUM(o.total_amount)
FROM customers c
JOIN orders o ON o.customer_id = c.id
WHERE c.status = 'ACTIVE'
AND o.placed_at >= now() - INTERVAL '90 days'
GROUP BY c.name
HAVING SUM(o.total_amount) > 1000;-- Before: the same shape in Oracle; SYSTIMESTAMP replaces now()
SELECT c.name, SUM(o.total_amount)
FROM customers c
JOIN orders o ON o.customer_id = c.id
WHERE c.status = 'ACTIVE'
AND o.placed_at >= SYSTIMESTAMP - INTERVAL '90' DAY
GROUP BY c.name
HAVING SUM(o.total_amount) > 1000;The refactored version with CTEs, where every step has a name (the SQL equivalent of Extract Function):
WITH active_customers AS (
SELECT c.id, c.name
FROM customers c
WHERE c.status = 'ACTIVE'
),
orders_in_window AS (
SELECT o.customer_id, o.total_amount
FROM orders o
WHERE o.placed_at >= now() - INTERVAL '90 days'
)
SELECT ac.name,
SUM(oiw.total_amount) AS total_spent
FROM active_customers ac
JOIN orders_in_window oiw ON oiw.customer_id = ac.id
GROUP BY ac.name
HAVING SUM(oiw.total_amount) > 1000;WITH active_customers AS (
SELECT c.id, c.name
FROM customers c
WHERE c.status = 'ACTIVE'
),
orders_in_window AS (
SELECT o.customer_id, o.total_amount
FROM orders o
WHERE o.placed_at >= SYSTIMESTAMP - INTERVAL '90' DAY
)
SELECT ac.name,
SUM(oiw.total_amount) AS total_spent
FROM active_customers ac
JOIN orders_in_window oiw ON oiw.customer_id = ac.id
GROUP BY ac.name
HAVING SUM(oiw.total_amount) > 1000;Here is a fundamental difference from Java: in Java, extracting a method costs essentially nothing at runtime (the JIT inlines it). In SQL, a "more readable" rewrite can change the execution plan. In PostgreSQL from version 12 onwards, non-recursive CTEs are inlined by default unless you write MATERIALIZED — so they are usually free; in older versions a CTE was an optimisation fence. In Oracle the optimizer may also choose to materialise a CTE.
Senior rule: in SQL, improve readability but always compare the plan before and after (EXPLAIN (ANALYZE, BUFFERS) in PostgreSQL, DBMS_XPLAN.DISPLAY_CURSOR in Oracle). The details are in rdbms-tuning and oracle-postgres-dialects.
"Refactoring is changing internal structure without changing observable behaviour — so by definition it needs a way to prove the behaviour did not change, which means tests. If there are none, I first write a thin layer of characterization tests: run the code, record the current output — even if it is wrong — as the expectation, so that any unintended change shows up. If the code is not testable at all (static dependencies, direct new), I make the minimum change needed to create a seam — extracting an overridable method, or injecting a dependency through the constructor — and I send that one change as its own carefully reviewed PR. The only refactorings I will do without tests are the ones the IDE performs automatically and type-safely, like Rename."
9. Working with legacy code: changing code you are afraid of
The blunt, widely adopted industry definition: legacy code is code without tests. Age, language and framework are irrelevant; what makes code legacy is that you cannot change it with confidence.
And here is the vicious circle: to change safely you need tests; to write tests you usually have to change the code (because it is untestable). The techniques below are the way out.
Diagram: the algorithm for changing legacy code safely — نمودار: الگوریتم تغییر امن در کد legacy
flowchart TD
A[Change request] --> B{Tests cover<br/>this behaviour?}
B -- Yes --> C[Refactor, then change]
B -- No --> D{Can I test it<br/>as-is?}
D -- Yes --> E[Write characterization tests]
E --> C
D -- No --> F[Find or create a seam<br/>minimal, reviewed change]
F --> E
D -- Too risky --> G[Sprout method / Sprout class<br/>or Wrap method]
G --> H[New code is tested,<br/>old code untouched]
9.1 Characterization tests
A normal test says "the code should do this". A characterization test says "the code currently does this". The distinction is crucial: you are locking in current behaviour — right or wrong — so your refactoring does not alter it.
The practical technique:
@Test
void characterize_legacyInvoiceTotal() {
LegacyInvoiceCalculator calc = new LegacyInvoiceCalculator();
// 1) Write a deliberately wrong assertion to discover the real value
// assertThat(calc.total(sampleInvoice())).isEqualByComparingTo("0");
// test output: expected 0 but was 1234500
// 2) Record the real value — this is not "correct", it is "current"
assertThat(calc.total(sampleInvoice())).isEqualByComparingTo("1234500");
}
An important cultural note: mark these tests clearly by name and comment (characterize_*) so nobody mistakes them for a business specification. If it later turns out the current behaviour is a bug, the test changes — and that change is a deliberate decision, not an accident.
For legacy code with large outputs (JSON, reports, files), approval testing is the more practical variant: store the output in an "approved" file and diff against it.
9.2 Seams: places where you can change behaviour without editing code
A seam is a place in the program where you can alter behaviour from outside without editing at that point. In Java the most important kind is the object seam: a place where a dependency is swappable through polymorphism.
// Before: no seam at all — the dependency is constructed inside the method
public class OrderProcessor {
public void process(Order o) {
PaymentGateway gw = new HttpPaymentGateway("https://prod..."); // untestable
gw.charge(o.total());
}
}
// After: the smallest change that creates a seam — constructor injection
public class OrderProcessor {
private final PaymentGateway gateway;
public OrderProcessor(PaymentGateway gateway) { // seam
this.gateway = gateway;
}
public void process(Order o) {
gateway.charge(o.total());
}
}
If even that is risky (there are dozens of new OrderProcessor() call sites), the more conservative technique is "extract and override for test": move the construction into a protected method and, in the test, subclass and override it. It is ugly, but it carries the least risk and is a stepping stone.
9.3 Sprout and Wrap: when you do not want to touch the old code at all
Sprout Method / Sprout Class: write the new capability as new, fully tested code and add exactly one call line to the old code.
public void postEntries(List<Entry> entries) {
// ... 200 lines of untested legacy code ...
validateAgainstFiscalCalendar(entries); // <- the only new line (sprout)
// ... 100 more lines ...
}
// New class, fully tested, independent
final class FiscalCalendarValidator { ... }
Wrap Method: when the new behaviour must run before or after existing behaviour, rename the old method and create a new one with the old name that calls both.
// Step 1: rename the original (with the IDE — safe)
private void postEntriesInternal(List<Entry> entries) { /* old code, untouched */ }
// Step 2: a new method with the old name — callers notice nothing
public void postEntries(List<Entry> entries) {
postEntriesInternal(entries);
auditTrail.recordPosting(entries); // new, testable behaviour
}
Wrap Class is the same idea one level up (the Decorator pattern): a new class implementing the same interface, holding the old one and adding behaviour — without a single line changed in the old class.
Three questions, in this order:
- What is the smallest change that solves my problem? Anything larger is gratuitous risk.
- Can I leave the existing behaviour untouched? If yes, sprout or wrap.
- If I must touch it, how do I pin the current behaviour first? Characterization tests.
And one operational rule: never rewrite a legacy module "for cleanliness" in one big PR. If it genuinely must be replaced, use the Strangler Fig pattern — move traffic piece by piece to the new implementation and delete the old one when it is empty. The ms-runtime-legacy chapter covers this in full.
"I do not start by rewriting it — that is the most tempting and most dangerous move. My order is: (1) reproduce the bug with a failing test at the smallest level possible; if a unit test is impossible, even an integration test with Testcontainers. (2) Put a ring of characterization tests around the change site so existing side behaviour is pinned. (3) If the code is untestable, create the minimum seam and send that as a separate PR. (4) Fix the bug; the tests go green. (5) Boy Scout rule: tidy the immediate neighbourhood, no more. (6) If the module really is high-risk, I file a technical-debt ticket with evidence — how many times it changed in the last six months, how many production bugs came from it — so prioritisation is data-driven rather than opinion-driven."
10. Technical debt: a ledger, not an excuse
The "debt" metaphor is more precise than it looks: like a loan, technical debt can be the right decision (you get capital you need now) provided it is recorded and you know the interest rate. What makes it toxic is debt that is unintentional and invisible.
10.1 The four quadrants
Diagram: the technical debt quadrant — نمودار: چهار ربع بدهی فنی
flowchart TD
subgraph Deliberate
A["Prudent + Deliberate<br/>'Ship now, we know the cost'<br/>-> record it, schedule payback"]
B["Reckless + Deliberate<br/>'No time for design'<br/>-> the dangerous one"]
end
subgraph Inadvertent
C["Prudent + Inadvertent<br/>'Now we know how we should<br/>have done it'<br/>-> normal learning"]
D["Reckless + Inadvertent<br/>'What is layering?'<br/>-> a skill gap, not debt"]
end
- Deliberate and prudent: "We know the right way, but to hit this launch date we take a shortcut and repay it next sprint." This is correct debt.
- Deliberate and reckless: "We don't have time for design." This is where systems die.
- Inadvertent and prudent: "Now that it's finished, we understand how we should have built it." This is normal learning and unavoidable — the main source of healthy refactoring.
- Inadvertent and reckless: the team does not know what layering is. That is not debt, it is a skill gap, and the cure is teaching and review, not a ticket.
10.2 Making debt visible
Invisible debt always gets translated as "the developers have got slower". Three tools make it visible:
- A debt register: real tickets in the same product backlog, in the same format. Each item needs three things: impact ("any pricing change takes two days because the rule is duplicated in three places"), risk ("financial inconsistency"), and cost to repay ("three days").
TODOcomments carrying a ticket id — plus a lint rule that fails the build on aTODOwithout one.- A number from a tool: SonarQube computes a "technical debt ratio" and remediation effort; use it as a trend, never as an absolute target.
10.3 Paying it down
Three patterns that work:
- Continuous Boy Scouting: the best return for the least risk, and it needs no negotiation.
- A fixed budget: a stated percentage of each sprint's capacity (say 10–20%) goes to debt. The advantage is that the negotiation happens once instead of every sprint.
- Opportunistic repayment: when a feature needs to touch the indebted area, fold preparatory refactoring into that feature's estimate. This is the most natural and least friction-generating route.
Code that was never right is not debt — it is poor quality. Distinguish them with a simple test: was a conscious decision made at the time, and was it recorded? If not, do not call it "debt", because the word hides the accountability.
Second point: repaying debt can also be wrong. Code in a module touched once a year carries effectively zero interest. Prioritise debt by the change rate of the area, not by how ugly it is. A quick git log --format=format: --name-only | sort | uniq -c | sort -rn | head -30 tells you which files change most; those are the real priorities.
"I don't speak in code; I speak in risk and delivery time. I bring three things: (1) data — 'the last three tickets in this module took on average 2.5× the original estimate' and '40% of production bugs in the last quarter came from these two files'. (2) A bounded, approvable proposal — not 'three weeks of rewriting' but 'three days, pricing module only, with a stated success criterion: adding a new pricing rule drops from two days to half a day'. (3) I present the 'do nothing' option honestly and price it too. In practice the best route is to tie refactoring to a feature: 'for this feature, the first two days are preparation and then the implementation is fast' — that is accepted almost every time because it sits on the value path."
11. Code review that improves both the code and the people
Bad review takes one of two forms: "LGTM" in thirty seconds (useless), or thirty comments about whitespace (destructive). Both have the same root cause: no ordering and no automation.
11.1 What to look at, in what order
Order matters because attention is finite. Start with nits and your capacity is gone before you reach the design.
| Priority | What | Sample question |
|---|---|---|
| 1 | Correctness | Are edge cases and failures covered? Concurrency? |
| 2 | Security and data | Is input validated? Are secrets kept out of logs? Is the migration reversible? |
| 3 | Design and boundaries | Is this logic in the right layer? Was the new dependency necessary? |
| 4 | Tests | Do they test behaviour or implementation? If I break the logic, does a test go red? |
| 5 | Readability and naming | Will I understand this in six months? |
| 6 | Nits / taste | Formatting, import order — which should be automated |
Many reviewers stay silent when they do not understand something, so as not to look ignorant. It is exactly the opposite: if you, with all your context, could not follow it, the next person will not either. Write it down — not as "this is wrong", but as "I couldn't follow which case this condition covers; could we rename it or add a test that demonstrates it?"
11.2 Feedback that lands
A handful of practical rules that make a real difference:
- Talk about the code, not the person. "This method does three things" instead of "you wrote this badly".
- Label the level. Prefix comments:
nit:(taste, free to ignore),question:(I don't understand),suggestion:(better but optional),blocking:(I won't merge until this is resolved). It is a tiny change with the biggest cultural effect, because it removes the ambiguity of "is this mandatory?". - Give the reason, not just the verdict. "Convert this to
Money" is weak; "doubleintroduces rounding error for money and it accumulates across sums" teaches. - Say the good things too. One sentence — "nice catch on that boundary case in the test" — costs nothing and changes the tone of the whole review.
- When the thread gets long, leave the text. More than three round-trips on one topic means it is time for a five-minute call.
11.3 Review size and latency
Two operational numbers that matter more than any checklist:
- Size: large PRs do not get reviewed, they get rubber-stamped. Above a few hundred lines the quality of findings falls sharply and the "LGTM" rate rises. If your change is big, break it into a chain: refactoring first, feature second.
- Latency: a PR that waits a day loses its author's context, and the author starts working in parallel — which produces more branches and more conflicts. A team agreement like "every PR gets first feedback within half a working day" contributes more to quality than any style rule.
Diagram: the path of a change from commit to merge — نمودار: مسیر یک تغییر از commit تا merge
sequenceDiagram
participant Dev as Developer
participant CI as CI pipeline
participant Rev as Reviewer
Dev->>CI: push branch
CI->>CI: format check, lint, static analysis
CI->>CI: tests + coverage on new code
CI-->>Dev: fast feedback (style, obvious bugs)
Dev->>Rev: open PR (small, one intent)
Rev->>Rev: correctness -> security -> design -> tests -> naming
Rev-->>Dev: blocking / suggestion / nit
Dev->>CI: push fixes
CI-->>Rev: green + quality gate passed
Rev->>Dev: approve, merge
11.4 Let the machine say the machine things
The right division of labour:
| The machine should say | The human should say |
|---|---|
| Formatting, whitespace, import order | Is this abstraction the right one? |
| Unused variables, redundant imports | Is this in the right layer? |
Known bug patterns (== on String) |
Is the real edge case covered? |
| Coverage on new code | Do the tests assert the right behaviour? |
| Dependencies with known CVEs | Was this dependency necessary at all? |
| Architecture rule violations (ArchUnit) | Is that architecture rule still correct? |
If humans are still arguing about whitespace, the team's problem is not culture — it has no tooling. The next section is exactly that tooling.
If an author knows every PR attracts twenty taste comments, they learn two defensive behaviours: they send bigger PRs (to reduce the number of occasions) and they avoid a particular reviewer. Both are the opposite of what you wanted.
Practical criterion: if a comment is a matter of taste and could be encoded in a formatter or linter, encode it, don't comment it. If it cannot be encoded, prefix it nit: and do not press the point.
"First the PR title and description — I need to know what it is supposed to do, because you cannot judge code without knowing the intent. Then I read the tests before the code; the tests tell me what behaviour the author intended and whether they considered the edge cases. Then correctness and security, then design and boundaries, and readability last. I do not comment on style at all, because Spotless and Checkstyle enforce it in CI. And I label each comment's level so it is obvious which are blocking — that alone removes most of the pointless back-and-forth."
12. Tooling in the Java world
The goal of this section is one thing: humans review design, machines review style and bug patterns. Versions reflect the state of the ecosystem in mid-2026; check Maven Central before you copy them.
12.1 The tool map
| Tool | What it catches | When it runs | Can it fail the build? |
|---|---|---|---|
| Spotless + google-java-format | Formatting (and auto-fixing it) | validate/verify |
yes (check) |
| Checkstyle | Style and structure rules (method length, naming, Javadoc) | verify |
yes |
| PMD | Bad-code patterns, complexity, dead code, copy-paste (CPD) | verify |
yes |
| SpotBugs | Bug patterns on bytecode (NPEs, unclosed resources) | after compile |
yes |
| Error Prone | Bugs at compile time, with auto-fixes | compile |
yes |
| SonarQube | Aggregation plus a quality gate on "new code" | after tests | yes (gate) |
| JaCoCo | Test coverage | test/verify |
yes (rule) |
| ArchUnit | Architecture rules as tests | test |
yes |
12.2 Spotless: end the style argument forever
The first tool to add. Reason: it removes the most review friction for the least debate.
<plugin>
<groupId>com.diffplug.spotless</groupId>
<artifactId>spotless-maven-plugin</artifactId>
<version>3.9.0</version>
<configuration>
<!-- On an existing project: only files changed relative to main -->
<ratchetFrom>origin/main</ratchetFrom>
<java>
<googleJavaFormat>
<version>1.28.0</version>
<style>GOOGLE</style>
</googleJavaFormat>
<removeUnusedImports/>
<formatAnnotations/>
</java>
</configuration>
<executions>
<execution>
<goals>
<goal>check</goal>
</goals>
</execution>
</executions>
</plugin>
mvn spotless:apply # auto-fix
mvn spotless:check # verify only; this is what CI runs
ratchetFrom is the key to adoption on existing projects: instead of one gigantic commit that destroys the whole git blame history, only changed files get reformatted.
Spotless 3.x requires Maven running on JRE 17+. Checkstyle on the 13.x line requires JRE 21+. Error Prone must run on JDK 21 or newer (though it can still target older bytecode). Since Java 25 is the current LTS and Java 26 has shipped, these prerequisites are rarely a problem — but if you are stuck on JDK 17, take the compatible version of each tool from its own documentation.
12.3 Checkstyle
An important detail many people miss: maven-checkstyle-plugin has its own version and the Checkstyle engine bundled inside it is old; you must upgrade it explicitly.
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-checkstyle-plugin</artifactId>
<version>3.6.0</version>
<dependencies>
<!-- Without this, the plugin's old default Checkstyle is used -->
<dependency>
<groupId>com.puppycrawl.tools</groupId>
<artifactId>checkstyle</artifactId>
<version>13.10.0</version>
</dependency>
</dependencies>
<configuration>
<configLocation>build/checkstyle.xml</configLocation>
<consoleOutput>true</consoleOutput>
<failsOnError>true</failsOnError>
<violationSeverity>warning</violationSeverity>
<includeTestSourceDirectory>true</includeTestSourceDirectory>
</configuration>
<executions>
<execution>
<id>checkstyle-validate</id>
<phase>validate</phase>
<goals><goal>check</goal></goals>
</execution>
</executions>
</plugin>
A small, realistic configuration containing only rules relevant to this chapter:
<?xml version="1.0"?>
<!DOCTYPE module PUBLIC
"-//Checkstyle//DTD Checkstyle Configuration 1.3//EN"
"https://checkstyle.org/dtds/configuration_1_3.dtd">
<module name="Checker">
<property name="severity" value="warning"/>
<module name="TreeWalker">
<!-- Small functions -->
<module name="MethodLength">
<property name="max" value="60"/>
</module>
<module name="ParameterNumber">
<property name="max" value="4"/>
</module>
<!-- Complexity -->
<module name="CyclomaticComplexity">
<property name="max" value="10"/>
</module>
<module name="NPathComplexity">
<property name="max" value="200"/>
</module>
<!-- Naming -->
<module name="MethodName"/>
<module name="ConstantName"/>
<module name="LocalVariableName"/>
<!-- Magic numbers -->
<module name="MagicNumber">
<property name="ignoreNumbers" value="-1, 0, 1, 2"/>
</module>
<!-- Real traps -->
<module name="EqualsHashCode"/>
<module name="IllegalCatch"/>
<module name="EmptyCatchBlock"/>
<module name="TodoComment">
<!-- A TODO must carry a ticket id -->
<property name="format" value="TODO(?!\([A-Z]+-\d+\))"/>
</module>
</module>
</module>
Checkstyle also ships three standard configurations (sun_checks.xml, google_checks.xml and openjdk_style.xml) which make a good starting point, though almost no team uses them untouched.
12.4 PMD and CPD (copy-paste detection)
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-pmd-plugin</artifactId>
<version>3.28.0</version>
<configuration>
<rulesets>
<ruleset>/category/java/bestpractices.xml</ruleset>
<ruleset>/category/java/errorprone.xml</ruleset>
<ruleset>/category/java/design.xml</ruleset>
</rulesets>
<printFailingErrors>true</printFailingErrors>
<minimumTokens>100</minimumTokens> <!-- CPD threshold -->
</configuration>
<executions>
<execution>
<goals>
<goal>check</goal>
<goal>cpd-check</goal>
</goals>
</execution>
</executions>
</plugin>
cpd-check connects directly to the DRY discussion: it finds copy-pasted blocks. But remember that CPD sees textual similarity, not duplicated knowledge — so read its output as a list to investigate, not as a list of mandatory fixes.
12.5 SpotBugs
It works on bytecode, so it sees things textual analysis cannot.
<plugin>
<groupId>com.github.spotbugs</groupId>
<artifactId>spotbugs-maven-plugin</artifactId>
<version>4.10.3.0</version>
<configuration>
<excludeFilterFile>build/spotbugs-exclude.xml</excludeFilterFile>
<xmlOutput>true</xmlOutput>
<plugins>
<plugin>
<groupId>com.h3xstream.findsecbugs</groupId>
<artifactId>findsecbugs-plugin</artifactId>
<version>1.14.0</version>
</plugin>
</plugins>
</configuration>
<executions>
<execution>
<goals><goal>check</goal></goals>
</execution>
</executions>
</plugin>
An exclusion file for findings you consciously accept:
<FindBugsFilter>
<Match>
<Class name="~.*Entity"/>
<Bug pattern="EI_EXPOSE_REP,EI_EXPOSE_REP2"/>
</Match>
</FindBugsFilter>
12.6 Error Prone: bug hunting at compile time
The strongest feedback is the fastest feedback. Error Prone rides on javac itself as a plugin, so findings arrive at compile time and many of them carry automatic fixes.
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.14.0</version>
<configuration>
<compilerArgs>
<arg>-XDcompilePolicy=simple</arg>
<arg>--should-stop=ifError=FLOW</arg>
<arg>-Xplugin:ErrorProne -Xep:MissingOverride:ERROR -Xep:NullAway:ERROR</arg>
<arg>-XDaddTypeAnnotationsToSymbol=true</arg>
</compilerArgs>
<annotationProcessorPaths>
<path>
<groupId>com.google.errorprone</groupId>
<artifactId>error_prone_core</artifactId>
<version>2.50.0</version>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>
-XDcompilePolicy=simple and --should-stop=ifError=FLOW are required; without them Error Prone either does not run or behaves strangely. -XDaddTypeAnnotationsToSymbol=true is needed when building on JDK 21 and later. And one practical trap that catches many teams: if you also use Lombok, both must be listed in annotationProcessorPaths — the moment you declare that element, Maven stops discovering processors from the classpath and Lombok silently stops working. The lombok chapter covers that interaction.
12.7 SonarQube and the quality gate on "new code"
The most practically important idea in recent SonarQube versions is Clean as You Code: the gate applies almost entirely to new code — whatever has been added or changed since a defined baseline.
Why does that matter? Because in a 200,000-line project, demanding that ten thousand pre-existing issues be fixed is impossible, and in practice it makes teams switch the gate off. "Your new code must be clean", however, is entirely achievable — and the arithmetic works: as time passes and files get touched, the clean share of the codebase rises.
mvn -B verify org.sonarsource.scanner.maven:sonar-maven-plugin:5.7.0.6970:sonar \
-Dsonar.projectKey=my-service \
-Dsonar.host.url=https://sonar.example.internal \
-Dsonar.token=$SONAR_TOKEN
Typical conditions for a healthy gate, all on new code: coverage at least 80%, duplicated lines under 3%, no new issues introduced, and acceptable security/maintainability ratings. You must also choose the New Code definition — usually "previous version" or "last N days" — because without a baseline the new-code conditions are meaningless.
Make coverage the target and the team learns to write tests that execute code without asserting anything meaningful — 90% coverage with zero confidence. Two antidotes: (1) apply coverage to new code rather than the whole project, where it still means something; (2) if you genuinely want to measure test quality, use mutation testing — covered in the qa-testing-discipline chapter. One simple habit also helps: in review, ask "if I invert this condition, which test goes red?"
12.8 ArchUnit: architecture rules as tests
Architectural decisions that live only in a wiki get violated within months. ArchUnit turns them into tests.
<dependency>
<groupId>com.tngtech.archunit</groupId>
<artifactId>archunit-junit5</artifactId>
<version>1.5.0</version>
<scope>test</scope>
</dependency>
import com.tngtech.archunit.junit.AnalyzeClasses;
import com.tngtech.archunit.junit.ArchTest;
import com.tngtech.archunit.lang.ArchRule;
import com.tngtech.archunit.library.GeneralCodingRules;
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes;
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses;
import static com.tngtech.archunit.library.Architectures.layeredArchitecture;
import static com.tngtech.archunit.library.dependencies.SlicesRuleDefinition.slices;
@AnalyzeClasses(packages = "com.example.shop")
class ArchitectureTest {
@ArchTest
static final ArchRule layers = layeredArchitecture()
.consideringOnlyDependenciesInLayers()
.layer("Web").definedBy("..web..")
.layer("Application").definedBy("..application..")
.layer("Domain").definedBy("..domain..")
.layer("Persistence").definedBy("..persistence..")
.whereLayer("Web").mayNotBeAccessedByAnyLayer()
.whereLayer("Application").mayOnlyBeAccessedByLayers("Web")
.whereLayer("Persistence").mayOnlyBeAccessedByLayers("Application");
@ArchTest
static final ArchRule domainStaysPure = noClasses()
.that().resideInAPackage("..domain..")
.should().dependOnClassesThat()
.resideInAnyPackage("org.springframework..", "jakarta.persistence..");
@ArchTest
static final ArchRule noCycles = slices()
.matching("com.example.shop.(*)..")
.should().beFreeOfCycles();
@ArchTest
static final ArchRule noFieldInjection =
GeneralCodingRules.NO_CLASSES_SHOULD_USE_FIELD_INJECTION;
@ArchTest
static final ArchRule noStandardStreams =
GeneralCodingRules.NO_CLASSES_SHOULD_ACCESS_STANDARD_STREAMS;
@ArchTest
static final ArchRule servicesAreNamedProperly = classes()
.that().resideInAPackage("..application..")
.and().areAnnotatedWith(org.springframework.stereotype.Service.class)
.should().haveSimpleNameEndingWith("Service");
}
For an existing project that already has a thousand violations, FreezingArchRule freezes the current violations and only fails on new ones — the Clean as You Code philosophy applied at the architecture level:
import com.tngtech.archunit.library.freeze.FreezingArchRule;
@ArchTest
static final ArchRule frozenLayers = FreezingArchRule.freeze(
noClasses().that().resideInAPackage("..domain..")
.should().dependOnClassesThat().resideInAPackage("..web.."));
# src/test/resources/archunit.properties
freeze.store.default.path=archunit_store
freeze.store.default.allowStoreCreation=true
12.9 Wiring it all into CI
# Example: the quality stages of a pipeline (GitHub Actions syntax)
jobs:
quality:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
with:
fetch-depth: 0 # required for ratchetFrom and "new code"
- uses: actions/setup-java@v5
with:
distribution: temurin
java-version: '25'
cache: maven
- name: Format + static analysis + tests
run: mvn -B verify # spotless, checkstyle, pmd, spotbugs, tests, jacoco
- name: SonarQube
env:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
run: >
mvn -B org.sonarsource.scanner.maven:sonar-maven-plugin:5.7.0.6970:sonar
-Dsonar.projectKey=my-service
If on day one you enable Checkstyle + PMD + SpotBugs + Sonar at full strength on a 200,000-line repository, you will get 15,000 warnings. The guaranteed outcome: the team ignores them and within two weeks the whole thing is switched off.
The adoption order that works:
- Spotless with
ratchetFrom— painless, kills the style debate. - Error Prone with only a handful of checks at ERROR — low noise, high value.
- Checkstyle with a small configuration containing only what matters; grow it over time.
- SpotBugs with an exclude file covering the existing backlog.
- Sonar with a gate on new code only.
- ArchUnit with
FreezingArchRule.
The principle behind all six is the same: freeze the baseline, and only prevent it from getting worse.
"I never start with 'let's fix 15,000 warnings', because that fails. My strategy is 'freeze the baseline': Spotless with ratchetFrom=origin/main so only touched files get formatted; a Sonar gate scoped to new code; FreezingArchRule for ArchUnit. Then one simple team rule: nothing new is allowed to make it worse. After that we take one small rule off the frozen list each month and fix it as a small ticket. More important than the tooling are two decisions: actually turn off the low-value warnings (an ignored warning is worse than no warning), and make the tools mandatory in CI rather than optional — because anything optional does not get run."
13. Senior judgment: when to break these rules
13.1 Rules that should be broken
Every rule in this chapter is a tool for lowering the cost of change. Where the cost of change is not the issue, the rule is not the issue either.
| Situation | Rule that gets set aside | Why |
|---|---|---|
| A hot loop with a proven performance need | Extract method, abstraction | Only when a profiler proved it; document with a comment and a benchmark |
| A one-off migration script | Tests, layering, careful naming | Its useful life is a single execution |
| Generated code | All style rules | You do not own it; exclude it from static analysis |
| Tests | DRY | Duplication in tests usually aids readability; a test should explain itself |
| An emergency incident fix | Everything except correctness | Restore service first; then a cleanup ticket with a date |
| A public library with external consumers | Signature simplicity | Backward compatibility beats API elegance |
13.2 Consistency with the surroundings beats personal preference
This is probably the most important lesson in the chapter for someone newly senior.
If you join a codebase that uses one pattern everywhere — even a pattern you dislike — and you introduce the "better" pattern in your own file, the net result is negative: the codebase now has two patterns and readers must know both. Inconsistency creates cognitive cost, and that cost usually exceeds the benefit of the better pattern.
The right path: write in the existing style; if you genuinely believe there is a better pattern, raise it as a team decision (an ADR), and if it is accepted, provide a migration path. Changing style through a unilateral PR is neither accepted nor durable.
Five moves that actually work:
- Tools first, people second. Automate everything that can be automated. Then "the rule" becomes something neutral that CI says, not something you say.
- Start with your own code. One exemplary PR that is visibly cleaner beats ten instructive comments.
- One thing at a time. If this month's focus is "tests for new logic", do not simultaneously argue about naming and package structure.
- Write the rules down and give the team ownership. A short
CONTRIBUTING.mdthe team agreed on moves the argument from "your taste versus mine" to "our agreement". - Watch your ratio. If most of your comments are
blocking, the team learns to avoid you. Being senior means knowing which hills are worth it — and most are not.
13.3 How interviewers probe this skill
Three common formats:
1) A code review exercise. They hand you 50–150 lines and ask for your comments. What is actually assessed: do you have an order? A weak candidate opens with formatting and variable names; a strong one first asks what the code is supposed to do, then goes to correctness and edge cases (nulls, concurrency, error handling, unclosed resources), then design, and readability last — explicitly saying "these are nits and should be automated in CI".
2) A refactoring kata. They give you a smelly class and ask you to improve it. What is assessed: do you ask about tests first? Do you take small steps and run the tests between them? Do you know the name of the refactoring you are applying ("I'll do Introduce Parameter Object here") — because knowing the name proves you know the catalogue rather than shuffling code at random.
3) A judgment question. "When would you keep duplicated code?", "When would you not write tests?", "When would you deliberately take on technical debt?" These are the senior filters: a dogmatic answer ("I always write tests") scores lower than a conditional one.
"I broke 'functions should be small' in a mapper: a method converting a domain object into an external DTO with about seventy lines of field assignments. I could have split it into six small methods, but the result would have been worse: to see the whole mapping a reader would have to jump between six methods, and none of them had a name more meaningful than 'the second part of the mapping'. That code had one abstraction level, one reason to change, and read linearly. I documented the decision with a short comment and a targeted @SuppressWarnings for Checkstyle so the next person knows it was deliberate. My criterion was not length; it was whether splitting made it more readable or less."
"I accept the conflict, but selectively: some things are negotiable and some are not. Non-negotiable: correctness, security, reversibility of data migrations, and tests for sensitive new logic — because the cost of those failing in production exceeds any deadline. Negotiable: completeness of abstractions, removal of duplication, coverage of low-risk paths, and structural elegance. When I trade, I record it: a ticket with the impact and the cost, and where possible a TODO carrying that ticket number. The one thing I never do is claim I wrote it cleanly when I did not — because management has to know where today's speed was borrowed from."
"A mid-level developer writes code that works and looks clean; a senior writes code that the team can change confidently six months later — and knows where to invest and where not to. Three concrete differences: (1) a senior optimises for change, not for writing, which means taking boundaries, tests and names seriously and premature abstractions not seriously. (2) A senior knows the rules have exceptions and can articulate them; a mid-level applies rules uniformly. (3) A senior delegates quality to the system rather than to personal effort — tooling in CI, architecture tests, a review pattern — because anything that depends on individual discipline evaporates under the first deadline."
Economics, not aesthetics: code is read far more than written, and the lifetime cost of a system is dominated by the cost of change — so optimise for the reader and the changer.
Naming is the highest lever: a name must reveal intent, avoid disinformation, and be searchable. In a non-English-speaking team: identifiers in English, no transliteration, and a team glossary for domain terms.
Functions: small, one level of abstraction, the stepdown rule, minimal arguments, no boolean flags, CQS as the default, and try/catch bodies extracted.
Errors are design: exceptions over error codes, never return null, Optional only as a return type, and define the exception the caller actually needs.
Principles, precisely: KISS means removing accidental complexity; DRY means duplication of knowledge, not of text (Rule of Three, AHA, and never across service boundaries); YAGNI means building the abstraction on the second need, not the first guess.
Smell → refactoring: every smell has a named refactoring. Eleven refactorings cover 90% of daily work — and the iron rule: small step, green tests, commit; never refactoring and behaviour change in the same PR.
Legacy code means code without tests. Write characterization tests, create a seam, and when it is frightening, sprout or wrap instead of rewriting.
Technical debt is a ledger: deliberate and prudent is acceptable when it is recorded; prioritise repayment by the change rate of the area, not by how ugly it is.
Review with an order: correctness → security → design → tests → readability → nits; label the level of each comment, keep PRs small, and hand everything mechanical to the machine.
Tooling: Spotless (with ratchetFrom), Checkstyle (upgrade its engine explicitly), PMD/CPD, SpotBugs, Error Prone at compile time, a quality gate on new code, and ArchUnit for architecture — all under the "freeze the baseline" philosophy.
And judgment: the rules are tools, not doctrine. Consistency with the surrounding code usually beats personal preference, and you raise a team's standards through tooling and example, not through severity in review.