
Іменування пакетів, про яке ви не дбаєте (але повинні)
Іменування пакетів та їх організація є фундаментальними аспектами написання коду, який легко підтримувати. Те, як ми обираємо групувати файли та модулі, впливає не лише на читабельність, але й на легкість навігації та майбутньої розробки.
У цій статті ми коротко розглянемо, як використовуються пакети, намагаючись створити деякі правила та надати обґрунтування того, коли створення окремого пакету є поганою ідеєю, а коли ні.
Оригінал статті опубліковано на dev.to — https://dev.to/y9vad9/package-naming-nobody-cares-about-but-should-3i5
Що таке пакет насправді?
Пакет — це одна з перших концепцій, з якою ви стикаєтесь одразу після написання вашої базової програми "Hello World" на Java або Kotlin. Найпростіший і водночас оманливий спосіб описати це — просто як структуру папок, що використовується для організації коду та запобігання конфліктам імен.
І хоча це частково правда, це може дати вам неправильне уявлення про те, коли насправді варто його використовувати. Але давайте дотримуватися якогось визначення:
Назва пакету в Kotlin та Java — це простір імен (namespace), що використовується для організації набору пов'язаних типів (класів, інтерфейсів, перерахувань тощо) в єдину згуртовану одиницю. Він служить подвійній меті: наданню логічної структури кодовій базі та запобіганню конфліктам імен у великомасштабних додатках або при інтеграції сторонніх бібліотек.
Беручи до уваги наше попереднє "просте" пояснення, це правда, що пакети допомагають нам уникати конфліктів імен, оскільки рідко буває так, щоб імена класів були на 100% унікальними.
Пакети як папки
Незважаючи на переваги, які надають пакети, є значний недолік: через те, як пакети реалізовані в Java та Kotlin — і як IDE обробляють їх — вони не повною мірою служать справжніми просторами імен, хоча ідеально мали б. З цієї причини ми схильні називати наші класи так, щоб вони були достатньо виразними самі по собі, без необхідності покладатися на назву пакету. Наприклад:
UserAnalyticsReportOrderRepositoryUserFileStorageService
Хоча ці класи можуть бути розміщені в різних логічних пакетах, наприклад:
com.example.user.UserAnalyticsReportcom.example.order.OrderRepositorycom.example.user.UserFileStorageService
що теоретично могло б забезпечити розрізнення, якби ми могли написати щось на зразок:
import com.example.user
val report = user::AnalyticsReport(...)
На жаль (і на щастя), ми не в C++ (що не обов'язково добре чи погано). Оскільки більшість людей покладаються на функції автоімпорту та автодоповнення в IDE, вони рідко мають справу безпосередньо з пакетами і, отже, часто не замислюються про структуру пакетів.
Цей підхід може призвести до хибного мислення при проєктуванні власної структури пакетів — ви починаєте думати категоріями групування файлів, а не визначення значущих просторів імен та меж, що призводить до нечітких обов'язків. Результатом часто стає директорія, повна класів, організованих за місцем розташування, а не за призначенням.
Хоча це дає відчуття, що речі "організовані", зазвичай це призводить до багатьох проблем. Це неправильна ментальна модель.
Пакети як простори імен (Namespaces)
Краща ментальна модель — думати про пакети як про семантичні межі — вони говорять вам, на яку частину системи ви дивитесь і, в ідеалі, за що відповідає цей код.
Але будемо чесними: більшість людей ставляться до пакетів як до простих папок, часто через те, що їх лякає кількість файлів у пакеті. Цей менталітет "шухляди" заважає нам глибше замислитися над тим, чому ми групуємо код саме так. Я сам довго боровся з цим мисленням.
Коли ви починаєте ставитися до пакетів як до справжніх просторів імен, відбувається кілька корисних речей:
- Сам пакет стає частиною документації — просто дивлячись на те, де живе клас, ви отримуєте уявлення про те, що він робить і за що відповідає.
- Пов'язана функціональність природним чином залишається разом, тоді як непов'язаний код залишається окремо. Це також змушує вас більше думати про "за що це відповідає?"/"Хто є власником цього конкретного класу/функції?".
Крім того, багато поширених головних болів у дизайні програмного забезпечення просто зникають. Одним з найбільших винуватців у великих кодових базах є надмірне або неправильне використання загальних пакетів "категорій", таких як model, dto або entity.
Наприклад, розглянемо великий проєкт, де кілька команд працюють над функціями, пов'язаними з користувачем. Ви можете побачити такі пакети:
com.example.user.model.profile com.example.user.profile.model com.example.user.utils.profile com.example.user.profile.utils com.example.user.dto.profile com.example.user.profile.dto
(Це ще більш імовірно в багатомодульних проєктах, особливо коли залучено кілька команд!)
Тут концепція "профілю" розкидана по декількох пакетах і підпакетах, часто з дубльованим або перевернутим порядком іменування. Іноді "профіль" навіть не є самостійною концепцією домену, а лише частиною агрегату користувача — проте він розглядається як власний пакет, щоб зменшити кількість файлів у user.model. Команди ненавмисно відтворюють одні й ті ж логічні групування кілька разів, тому що не усвідомлюють, що еквівалентний пакет вже існує десь в іншому місці. Це часто призводить до непослідовного іменування пакетів на основі особистих уподобань, а не чітких конвенцій, що ускладнює онбординг нових членів команди та уповільнює процес. Щоб ніколи більше не чути: "Чому це не тут, а тут?".
Хоча цей приклад спрощений, ви неминуче зіткнетеся з цією проблемою в реальних проєктах — особливо якщо ви підтримуєте бібліотеку, де зворотна сумісність між версіями є критичною. У таких випадках непослідовні або нечіткі структури пакетів можуть призвести до довгострокових головних болів з підтримкою та збільшити ризик критичних змін (breaking changes).
Ця ситуація швидко перетворюється на лабіринт, де:
- Ви витрачаєте більше часу на вгадування, де щось може знаходитись, ніж на розуміння того, що воно робить.
- Термінологія стає непослідовною між командами — одні називають певні класи "моделями", інші називають ті самі або схожі концепції "сутностями" або "DTO".
- Ви заплутуєтесь у зайвих або суперечливих пакетах, таких як
user.utils.profileпротиuser.profile.utils, без чіткого власника або відповідальності.
У таких випадках загальні назви пакетів, як utils, model або dto, стають безглуздими ярликами. Замість того, щоб допомагати вам знайти код на основі його відповідальності, вони змушують вас покладатися виключно на термінологію — а оскільки різні люди або команди часто використовують ці терміни по-різному, цей словник може змінюватися або конфліктувати з часом. Це робить розуміння кодової бази більш залежним від опанування змінного та неоднозначного глосарію, ніж від інтуїтивних архітектурних меж.
Натомість, коли пакети представляють відповідальність, а не просто категорії файлів або розмиті групування, кодова база стає більш зручною для навігації, легшою для розуміння та стійкішою до змін у команді або термінології.
Що заслуговує на простір імен?
Хоча відносно легко аргументувати відмову від створення .model, .util, .impl і так далі, залишається велике питання без відповіді — як визначити, що заслуговує, а що не заслуговує на простір імен?
Давайте створимо приклад:
com.example.user
├─ User.kt
├─ UserId.kt
├─ UserFactory.kt
├─ settings
│ ├─ UserSettings.kt
│ ├─ NotificationPreferences.kt
│ └─ PrivacyOptions.kt
├─ profile
│ ├─ UserProfile.kt
│ ├─ ProfilePicture.kt
│ └─ Bio.kt
├─ security
│ ├─ Password.kt
│ ├─ SecurityQuestions.kt
│ └─ TwoFactorAuth.kt
└─ utils
├─ UserValidators.kt
└─ UserMappers.kt
Для простоти ми уникнемо згадування будь-якого розшарування в нашій структурі.
Хоча згортання всіх папок може зробити структуру чистою та мінімалістичною, це часто перетворюється на довгий, заплутаний ланцюжок підпакетів без чіткої мети. Візьмемо, наприклад, profile — він може виглядати як згуртована, незалежна одиниця, але ми повинні зупинитися і запитати себе:
- Чи має сенс
.profileбез концепції користувача? - Чи має сенс фактичне використання сутностей всередині
.profileза межами пакетуuser(наприклад, чи він лише загорнутий всередині іншого класу та керується ним)? (мапери не рахуються 😁) - Чи надає цей пакет значущий "чорний ящик" зі своїм власним API, який використовується не лише користувачем? Або це щось, що має сенс лише всередині User — наприклад, UserProfile, який ніколи не існує незалежно, завжди розгортається з User і не може бути доступний без нього?
- Якщо мені доведеться змінити щось у цій групі класів, чи зазвичай я змінюватиму й інші?
Якщо будь-яка з відповідей "так" → вони, швидше за все, належать до одного пакету. Якщо всі "ні" → можливо, це заслуговує на власний пакет.
Пакети .common / .core
Інший крайній випадок, про який, на мою думку, варто згадати — це пакети .common / .core, які я особисто часто бачу в кодових базах. Хоча спочатку вони здаються зручними, здебільшого їхній прихований зміст буквально означає "це речі, які більше нікуди не вписуються". Таким чином, це насправді не представляє згуртовану концепцію або чітку межу.
Питання просте — навіщо вводити com.example.common, якщо це має бути або локалізовано у відповідному місці, або просто розміщено всередині com.example? Це ще один приклад менталітету "шухляди", що призводить до ситуації, коли з часом кожен член команди кидатиме туди непов'язаний код без зайвих роздумів. І незабаром пакет стає звалищем усього і нічого водночас.
Оскільки туди скидається все більше і більше непов'язаного коду, це перетворюється на частину системи, від якої залежить найбільше всього, створюючи приховану зв'язність скрізь. Це, в свою чергу, робить рефакторинг небезпечним, оскільки винесення чогось звідти здається ризикованим, коли ви не впевнені, хто на це покладається. З часом результатом стає архітектурна ерозія: замість чітко визначених меж система тяжіє навколо роздутого God-пакета в її центрі.
Бонусний спосіб мислення
Як додатковий спосіб мислення про це, ви можете розглянути "Агрегат" з DDD як ментальну модель для ідентифікації цілісної концепції, яка заслуговує на власний простір імен. Ідея полягає в тому, що пакет повинен представляти щось, що має значення та корисність само по собі для зовнішнього світу.
Об'єкти-значення, доменні сутності та події часто не кваліфікуються для окремих пакетів, тому що вони існують виключно для підтримки агрегату — вони не мають незалежного значення за його межами. Розділення їх на власні пакети порушило б принцип надання чіткого API назовні та створило б штучні межі, де ніщо не має сенсу в ізоляції.
Все всередині агрегату є сильно зв'язаним, і ця зв'язність є саме тією причиною, чому воно належить до одного пакету: це повідомляє, що ці елементи мають значення лише в контексті агрегату.
Цей підхід гарантує, що пакети залишаються значущими одиницями організації, а не довільними папками, які приховують справжню структуру системи.
Простори імен на макрорівні
Досі ми зосереджувалися на пакетах на мікрорівні, як-от всередині домену. Але як щодо самих шарів — domain, infrastructure, presentation?
На цьому рівні пакети сигналізують про архітектурні межі і, очевидно, приносять багато переваг. domain містить основну бізнес-логіку, presentation має справу з API або UI, а infrastructure обгортає технічні деталі, такі як бази даних або обмін повідомленнями. Важливо, що навіть, наприклад, інфраструктура часто містить самодостатню логіку та типи для доступу до своєї логіки (в ідеалі), що робить її згуртованою одиницею саму по собі. Ключовим є те, що кожен шар все ще групує значущі одиниці, а не діє як звалище. Якщо все зроблено правильно, простори імен макрорівня дають розробникам швидку ментальну карту системи та роблять залежності від шарів явними.
Отже, ми можемо розглядати це як позитивну річ, а не як безглуздий технічний ярлик — ці межі здебільшого є самодостатніми.
Іменування простору імен (пакету)
Тепер, коли ми розглянули, коли створювати пакет, давайте поговоримо про те, як його називати. Пакет повинен називатися на честь концепції, яку він представляє, а не кількості речей всередині нього.
Наприклад, якщо ваш код побудований навколо управління одним Order — його валідацією, життєвим циклом та операціями — називати пакет orders є оманливим. Множина передбачає колекцію, що не відображає того факту, що пакет стосується самої концепції Замовлення (Order). Правильним вибором є order, що повідомляє, що цей простір імен стосується концепції домену, а не списку замовлень.
Аналогічно, не називайте пакети errors, events або notifications тільки тому, що вони містять кілька елементів цього типу. Питання, яке слід задати: яку концепцію або відповідальність охоплює цей пакет? Назвіть його на честь цієї концепції, а не кількості екземплярів, які він містить. Це робить назви ваших пакетів точними, значущими та узгодженими з ментальною моделлю вашої системи.
Чи може назва пакету колись бути у множині? Звичайно. Але на практиці це набагато менш поширене, ніж люди припускають. Більшість часу пакет представляє єдину концепцію, а не колекцію, тому за замовчуванням ми обираємо однину.
Робіть правильний фокус
Недостатньо просто створювати значущі простори імен — фокус цих просторів імен має таке ж значення.
Розглянемо дві структури:
com.example.domain
└─ user
└─ User.kt
проти:
com.example.user
└─ domain
└─ User.kt
Обидві виглядають валідними, але вони повідомляють про дуже різні пріоритети. Перша (com.example.domain.user) ставить технічний шар на перше місце і в центр. Друга (com.example.user.domain) тримає в центрі уваги концепцію — user, а шар є вторинним.
Ця різниця стає важливою в міру зростання системи. Не кожна концепція або фіча навіть матиме .domain або .presentation (наприклад, авторизація, швидше за все, не має бізнес-логіки, тому не заслуговує на .domain), або будь-який інший шар, який ви очікуєте. Це означає, що навігація швидко стає незручною: ви не можете сказати, що фіча робить або містить, лише те, що вона може знаходитися десь під технічним ярликом. Результатом є нерівномірні ієрархії та папки, які здаються роздутими шарами, тоді як фактичні бізнес-концепції ховаються.
Модулі демонструють ту ж закономірність. У невеликому масштабі наявність :domain:user та :domain:task може виглядати нормально. Але як тільки ви додаєте :application:auth, :application:user, :application:task (тоді як :domain:auth не існує), навігація стає дивною. Ви не можете одразу сказати про фактичну спроможність фічі або обмеженого контексту: чи має auth взагалі доменну логіку, чи це просто код додатку? Структура висуває технічні межі на перше місце, залишаючи концептуальні межі нечіткими.
Підхід "концепція насамперед" — com.example.user.domain, com.example.order.infrastructure — уникає цієї проблеми. Ви завжди починаєте з того, що важливо для бізнесу, і лише потім уточнюєте за шаром.
Чи має він шар domain або infrastructure? Це не має значення — важлива сама концепція, і лише потім те, як вона реалізована всередині. Це особливо важливо, оскільки структура, ймовірно, зміниться з часом. І що ви будете робити тоді?
Висновок
Давайте підсумуємо основні моменти, починаючи з мікрорівня:
- Перш за все, моя рекомендація — йти не шляхом "коли не створювати пакет", а шляхом "коли створювати", тобто ми не створюємо окремий пакет за замовчуванням. Хіба що у нас є вагома причина.
- Виправдано мати окремий пакет, якщо ми хочемо ізоляції, наприклад, коли ми говоримо про багатошарову архітектуру. Це також виправдано, коли пакет насправді є незалежною згуртованою одиницею, яка має незалежне значення.
- Ми точно не створюємо пакети типу
utils/ext(ensions)/helpers/implі тому подібні. - Ми не створюємо пакети, які не представляють себе в загальному контексті.
На макрорівні цілком нормально мати пакети, такі як domain або infrastructure, які відображають архітектурний шар, в якому ви працюєте. Крім того, розставляйте пріоритети правильно — спочатку концепція, потім шар, на якому ви оперуєте.
Нарешті, використовуйте назви в однині за замовчуванням, якщо тільки сама концепція не є за своєю суттю множиною, як news.
Ось приклад структури просто для довідки:
com.example
├─ user
│ ├─ domain
│ │ ├─ User.kt
│ │ ├─ UserId.kt
│ │ └─ UserName.kt
│ ├─ application
│ │ ├─ UserService.kt / SomeUseCase.kt
│ │ └─ UserRepository.kt
│ └─ infrastructure
│ ├─ database
│ │ └─ UserDataSource.kt
│ └─ adapter
│ └─ UserRepositoryImpl.kt
│ └─ messaging
│ └─ UserEventsPublisher.kt
├─ order
│ ├─ domain
│ └─ ...
Загалом, ви можете застосовувати ці правила до інших мов поза екосистемою JVM, таких як TypeScript з його системами модулів або будь-чого іншого, що має простори імен як концепцію.
Більше 5 файлів у директорії — це не так страшно, хлопці!