1. Плануємо модулі заздалегідь
Коли проєкт маленький, легко повірити, що план модулів — це щось із світу «корпоративної архітектури» та презентацій на 80 слайдів. Проте неприємна правда в тому, що хаос у коді розростається швидше, ніж функціональність: спочатку ви додаєте «ще одну команду», потім «ще один формат зберігання», і раптом у вас print() живе поруч із валідацією домену, а помилки перетворюються на кашу з рядків.
План модулів потрібен не тому, що «так модно», а тому, що він змушує вас завчасно ухвалити кілька здорових рішень: де живуть доменні правила, хто має право читати й записувати дані, хто знає про мережу і хто взагалі має право робити print("Помилка: ..."). Якщо план є, нові фічі майже автоматично лягають у правильні місця — і ви менше сперечаєтеся із самим собою через тиждень, коли забудете, що хотіли зробити «красиво».
Ще одна практична причина: модульні межі — це чудовий античит. Поки все в одному місці, ви можете випадково імпортувати будь-що будь-куди. А коли межі стають технічними (targets у SwiftPM), компілятор починає говорити: «Ні, друже, Domain не повинен бачити Storage». І це один із рідкісних випадків, коли компілятор справді дбає про ваше ментальне здоров’я.
Шар і модуль: як не плутатися
Слово «шар» зазвичай описує відповідальність: що робить код і чого він не повинен робити. Слово «модуль» частіше описує межу компіляції та імпорту: хто кого може import, що збирається окремо і де закінчується «видимість». Ці поняття пов’язані, але не тотожні — і через це новачки часто роблять два крайні висновки: або «модулі не потрібні, достатньо шарів», або «давайте наріжемо все на 20 модулів і переможемо ентропію».
Насправді все простіше: шар — це домовленість, а модуль — спосіб підтримати її інструментами. Сьогодні, в межах цієї лекції, ми ще не переходимо до технічної реалізації збирання, але нам важливо обрати майбутні межі так, щоб вони збіглися з шарами відповідальності. Тоді пізніше, коли ми справді розділимо проєкт на targets, майже не доведеться переписувати архітектуру — просто зафіксуємо дисципліну компілятором.
Корисна аналогія: шар — це «правила кухні» (де ріжемо, де смажимо, де миємо посуд), а модуль — це «стіни та двері». Можна домовитися, що ножі не кладемо в холодильник, але двері до холодильника теж іноді допомагають.
3. План модулів і залежності
Чотири логічні модулі курсу
Якщо дивитися на наш проєкт як на CLI‑застосунок «бібліотека» — умовний LibraryCLI, — у курсі ми заздалегідь плануємо чотири великі логічні компоненти. Вони не зобов’язані сьогодні існувати як окремі папки, але ми хочемо, щоб кожен файл у проєкті легко належав до одного з них. Так ми прибираємо вічне питання «куди покласти цей код» і знижуємо ризик того, що застосунок перетвориться на один гігантський main.swift. Такий моноліт зазвичай живе недовго й болісно руйнується.
Нижче — компактна таблиця ролей. Це не «канон архітектури», а робоча домовленість курсу:
| Модуль | Роль (чим займається) | Чого не повинен робити |
|---|---|---|
|
Сенс: доменні моделі, правила, помилки домену, контракти, зокрема protocol-контракти | Не друкує в консоль, не ходить у файли чи мережу, не знає про JSON/CSV |
|
Доступ до даних: реалізації репозиторіїв, DTO для зберігання, мапінг формату | Не вирішує доменні правила, не форматує повідомлення для користувача |
|
Доступ до мережі: клієнти/DTO для API, перетворення відповіді в доменні типи | Не чіпає консоль, не знає про CLI-команди безпосередньо |
|
Ввід і вивід: парсинг команд, друк, composition root, запуск сценаріїв | Не ховає доменні правила в рядках, не реалізує зберігання чи мережу всередині себе |
Зверніть увагу на важливу річ: Application Service як шар у нас нікуди не зникає. Просто як «модуль» він буде там, де йому логічніше жити. У межах плану курсу сервіси найчастіше опиняються ближче до Domain — як частина «ядра», а LibraryCLI залишається тонким: підготував параметри → викликав сервіс → надрукував результат.
Напрям залежностей: хто кого знає
Можна скільки завгодно красиво назвати папки Domain і Storage, але якщо Domain почне імпортувати Storage, сенс загубиться. Тому головний артефакт цієї лекції — не структура каталогів, а граф залежностей. Він має бути простим: залежності спрямовані «всередину», а не «назовні».
Уявімо наші модулі стрілками:
flowchart LR
CLI[Модуль CLI] --> Domain[Домен]
CLI --> Storage[Сховище]
CLI --> Net[Мережа]
Storage --> Domain
Net --> Domain
Ідея така: Domain — центр сенсу і правил. Він намагається залежати від мінімуму речей: в ідеалі лише від стандартної бібліотеки, а інколи й від Foundation, якщо нам потрібен UUID. Storage і Networking — це інфраструктура: вони знають про доменні типи, тому що мають їх зберігати або отримувати ззовні. LibraryCLI — «точка збирання»: вона знає про все, бо саме вона обирає, яку реалізацію підключити і як спілкуватися з користувачем.
Якщо залежності починають закільцьовуватися — наприклад, Domain імпортує Storage, бо «мені тут потрібен InMemoryRepo» — це майже завжди сигнал, що ви переплутали відповідальність. Домен повинен залежати від контракту (protocol), а не від реалізації. А реалізація живе в інфраструктурі.
4. Що кладемо в кожен модуль
Domain: моделі, помилки й контракти на прикладі Book
Перш ніж ділити проєкт на модулі, корисно зрозуміти, які типи є центральними і мають жити в Domain. У нашому прикладі це Book, BookID, доменні помилки, а також контракти, якими домен розмовляє із зовнішнім світом, наприклад репозиторій як protocol. Тут легко помилитися і почати складати в Domain усе підряд, але нам потрібна акуратніша картина: Domain — це не «папка для всього важливого», а місце для сенсу й правил коректності.
Мінімальна доменна модель виглядає так: просто дані, без I/O і без форматів зберігання.
import Foundation
struct BookID: Hashable {
let rawValue: UUID
}
struct Book {
let id: BookID
let title: String
}
Тут Book нічого не знає про JSON, файли й мережу. Він не друкує себе в консоль і не вміє сам себе зберігати. Це нормально: доменна модель — не герой-одинак, який усе робить сам. Вона радше правильна форма даних, навколо якої будуються сценарії.
Тепер контракт репозиторію: домен формулює, що йому потрібно від зберігання, але не каже, як саме це буде реалізовано.
protocol BookRepository {
func save(_ book: Book) throws
func all() throws -> [Book]
}
А ось доменна помилка — проста, але дуже корисна для дисципліни:
enum DomainError: Error {
case emptyTitle
}
І нарешті, LibraryService — наш Application Service. Він належить до ядра застосунку: це оркестрація сценаріїв без прив’язки до введення через CLI і без прив’язки до сховища як до конкретного класу.
import Foundation
final class LibraryService {
private let repo: any BookRepository
init(repo: any BookRepository) { self.repo = repo }
func addBook(title: String) throws -> Book {
let t = title.trimmingCharacters(in: .whitespacesAndNewlines)
guard !t.isEmpty else { throw DomainError.emptyTitle }
let book = Book(id: BookID(rawValue: UUID()), title: t)
try repo.save(book)
return book
}
}
Зверніть увагу: сервіс імпортує Foundation лише тому, що ми обрали UUID. Це допустимо, але важливо усвідомлювати ціну: що більше зовнішніх залежностей має домен, то складніше його повторно використовувати. У курсі ми поки що не женемося за ідеалом: читабельність і простота важливіші за «ідеальну Clean Architecture».
Storage: реалізації та деталі, які Domain бачити не повинен
Storage — це місце, де живуть реалізації репозиторію. Сьогодні ми можемо почати з простого InMemoryBookRepository, щоб не лізти у файловий I/O. Але навіть «пам’ятне» сховище вже ілюструє важливу ідею: реалізація може бути будь-якою, а Domain спілкується з нею через контракт.
final class InMemoryBookRepository: BookRepository {
private var storage: [BookID: Book] = [:]
func save(_ book: Book) throws { storage[book.id] = book }
func all() throws -> [Book] { Array(storage.values) }
}
Сховище використовує словник, зберігає стан, змінює дані — і це нормально. Але важливо, щоб воно не починало «перевіряти title на порожнечу». Якщо репозиторій почне вирішувати доменні правила, вони розповзуться по проєкту, і одного дня ви отримаєте ситуацію: «у CLI порожня назва заборонена, а під час завантаження з файла — чомусь допускається». Доменні правила мають бути єдиними.
Сюди ж, у Storage, пізніше потраплять DTO для зберігання. Наприклад, якщо у файлі ID зберігається як рядок, а в домені — UUID, то DTO виглядатиме інакше. Домену це знати не потрібно: він не має розуміти, що таке uuidString; йому важливо розуміти, що таке BookID.
LibraryCLI: ввід/вивід і composition root
CLI-модуль — це місце, де ми нарешті «дозволяємо собі» print(). Але при цьому ми тримаємо шар тонким: жодної бізнес-логіки «між рядками». CLI отримує команду, витягує параметри, викликає сервіс і перетворює результат на людський текст. Це схоже на перекладача: він не ухвалює рішень щодо сенсу, а лише перекладає.
Найпростіший CLI-адаптер:
struct CLI {
let service: LibraryService
func runAdd(title: String) {
do { _ = try service.addBook(title: title); print("Готово") }
catch { print("Помилка:", error) }
}
}
І composition root — місце, де ми «склеюємо» реальні залежності. Зараз це може бути просто функція-фабрика або код у main.swift.
func makeCLI() -> CLI {
let repo = InMemoryBookRepository()
let service = LibraryService(repo: repo)
return CLI(service: service)
}
Тут CLI знає про конкретний InMemoryBookRepository. Це правильно, тому що CLI — зовнішня межа: вона обирає реалізацію. А ось LibraryService про неї нічого не знає — він знає лише BookRepository.
Такий підхід легко масштабується. Хочете завтра під’єднати інше сховище? Ви змінюєте composition root, а не переписуєте сервіс. Хочете тестувати сервіс? Підставляєте фейковий репозиторій. І ви не будуєте «магічний singleton», який потім неможливо замінити. А він обов’язково з’явиться, якщо не стежити.
Networking: чому місце під мережу корисне вже зараз
Здається дивним виділяти модуль Networking, коли ми ще не говорили про HTTP. Але планування — це не про те, що код уже є, а про те, що місце під код уже визначено. Це як заздалегідь звільнити полицю під спеції, перш ніж купити паприку: інакше паприка оселиться у ванній, і ви довго житимете з цим рішенням.
Networking у нашому плані відповідає за два типи речей: технічний мережевий клієнт і DTO/мапінг мережевої відповіді в доменні моделі. При цьому він не знає про CLI-команди і не друкує користувачеві «Завантажую…». Якщо потрібно повідомити про прогрес, це турбота зовнішнього шару — CLI — або сценарію, але не мережевого шару.
Поки що ми можемо залишити в Networking буквально «заглушку» інтерфейсу, щоб було зрозуміло, як він виглядатиме концептуально — без реалізації, без HTTP, без URLSession.
protocol BookRemoteSource {
func fetchBook(id: String) throws -> Book
// Реалізація зʼявиться пізніше, поки що це лише ідея контракту.
}
Цей фрагмент корисний навіть зараз: він змушує вас думати, які типи мають повертатися назовні. Відповідь проста: доменні (Book), а не DTO. DTO живуть усередині мережевого шару, бо формат відповіді мережі не повинен «просочуватися» в домен.
5. Як утримувати межі до SwiftPM
Поки ми не перейшли до кількох SwiftPM targets, компілятор не забороняє вам «взяти й імпортувати все всюди». Тому нам потрібна дисципліна, яка працює навіть у межах одного target. Це виглядає менш видовищно, ніж справжні модулі, але зате реально рятує проєкт від розповзання.
Практика на рівні організації файлів така: ви тримаєте папки Domain/, Storage/, Networking/, LibraryCLI/ і домовляєтеся, що файли з Domain не звертаються до типів зі Storage та Networking. IDE не зупинить вас, але ви бачитимете порушення вже очима: «чому доменний файл лізе в Storage?».
Ще один простий трюк — неймспейсинг через порожні enum, щоб візуально виділити межі. Це не справжній модуль, але для читання коду корисно:
enum DomainLayer {}
enum StorageLayer {}
А далі ви можете групувати типи за сенсом — наприклад, за назвами файлів і розміщенням — не ускладнюючи синтаксис. Головне — не доходити до фанатизму: наша мета — ясність, а не «побудувати власний SwiftPM усередині Swift».
6. Анонс: як це стане SwiftPM targets
Коли ми перейдемо до SwiftPM, ці логічні модулі стануть технічними: кожен target компілюватиметься в окремий модуль, а зв’язок між ними визначатиметься залежностями між targets. SwiftPM загалом мислить проєкт як набір targets і products, де targets — «будівельні блоки», а products — «те, що пакет віддає назовні». Це добре видно в моделі SwiftPM: продукт складається з root targets, а залежності targetів підтягуються автоматично, але не обов’язково «світяться» назовні.
На рівні ідеї це виглядатиме так: LibraryCLI — executable target, а Domain, Storage, Networking — library targets, які він імпортує. У світі SwiftPM точка входу живе або в main.swift, або в типі з @main.
Ми сьогодні не занурюємося в детальний синтаксис Package.swift, але корисно один раз побачити «форму думки» — як псевдокод:
// Ідея, не фінальний маніфест.
targets: [
.executableTarget(name: "LibraryCLI", dependencies: ["Domain", "Storage", "Networking"]),
.target(name: "Domain"),
.target(name: "Storage", dependencies: ["Domain"]),
.target(name: "Networking", dependencies: ["Domain"]),
]
Зверніть увагу: залежності тут рівно такі самі, як на діаграмі вище. Тобто ми не «вигадуємо нову архітектуру під SwiftPM», а просто робимо те, що вже вирішили на рівні відповідальності, — тільки тепер компілятор стежитиме за чесністю.
7. Типові помилки
Помилка № 1: вважати, що модуль — це просто папка, і на цьому все.
Часто створюють папки Domain і Storage, але продовжують спокійно викликати InMemoryBookRepository() просто з доменного сервісу, бо «ну воно ж поруч». Це руйнує ідею залежностей. Папка сама по собі не створює межу; межу створює залежність: домен повинен бачити контракт, а реалізація має підставлятися ззовні.
Помилка № 2: тягнути в Domain те, що «просто зручно».
Найчастіший приклад — форматування для CLI і print() у доменних методах. Зручно? Так. Дешево? Лише сьогодні. Завтра ви захочете тестувати домен без консолі або повторно використовувати його в іншому інтерфейсі, і раптом домен стане «балакучим» і непереносним.
Помилка № 3: зберігати DTO поруч із Domain «бо це теж Book».
DTO справді «схожі» на доменні моделі, і рука тягнеться покласти BookDTO поруч із Book. Але DTO — це про формат, а домен — про сенс. Якщо DTO опиниться в домені, домен почне підлаштовуватися під зберігання чи мережу, а це майже завжди веде до витоків відповідальності та дивних компромісів у правилах.
Помилка № 4: циклічні залежності між модулями.
Якщо Storage починає залежати від LibraryCLI — наприклад, заради гарного виведення помилок, — ви отримаєте цикл: CLI залежить від Storage, Storage залежить від CLI. В одному target це ще якийсь час може жити, але коли ви дійдете до справжніх targets, SwiftPM справедливо скаже: «так не можна». Правильне рішення — винести спільний контракт, наприклад тип помилок або protocol, у внутрішній шар, найчастіше в Domain, або тримати форматування помилок виключно в CLI.
Помилка № 5: змішувати «сервіс як сценарій» і «CLI як сценарій».
Іноді намагаються зробити CLI «розумним»: парсинг, валідація, виклики репозиторію, форматування помилок — усе в одному місці. Спочатку здається, що так швидше. Потім з’являється друга команда, третя, спільні кроки починають копіюватися, і CLI перетворюється на комбайн. Набагато надійніше тримати сценарій у сервісі, а CLI — тонким перекладачем між людиною і сервісом.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ