JavaRush /Курси /Swift SELF /Шари: CLI → Application Service → Domain → Repository

Шари: CLI → Application Service → Domain → Repository

Swift SELF
Рівень 46 , Лекція 3
Відкрита

1. Навіщо потрібні шари навіть у малому CLI‑застосунку

Якщо ви досі писали програми на 30–80 рядків, могло здаватися, що архітектура — це щось зі світу «корпоративних драконів і діаграм на 48 сторінок». Але насправді шари потрібні навіть у маленькому CLI, тому що введення, виведення, правила предметної області та зберігання даних живуть за різними законами. Якщо їх змішати, ви отримаєте код, який важко розширювати, тестувати й пояснювати самому собі через тиждень.

Уявіть «поганий» стиль, який майже неминуче зʼявляється в новачка: увесь код в одному файлі, в одному циклі, де ми і читаємо, і перевіряємо, і зберігаємо, і друкуємо. Спочатку це здається зручним. Потім ви хочете додати команду remove, і раптом увесь while true перетворюється на лабіринт.

Ось карикатурний приклад «усе одразу в одному місці» — це антиприклад, так робити не будемо:


import Foundation

var titles: [String] = []

while true {
    let line = readLine() ?? ""
    if line == "list" {
        print(titles.joined(separator: ", ")) // логіка виведення + форматування тут
    } else if line.hasPrefix("add ") {
        let title = String(line.dropFirst(4))
        titles.append(title)                  // зберігання тут
        print("Готово")                       // UX тут
    }
}

Код короткий, але вже видно, що тут перемішано все: і «як розмовляти з користувачем», і «як зберігати», і «якими є правила сутності книги». Шари — це спосіб розкласти цей «бутерброд» по тарілках так, щоб можна було окремо змінювати хліб, начинку й соус, не переписуючи весь обід.

2. Базова схема шарів і правило залежностей

Що таке шар у межах цього курсу

Слово «шар» легко переплутати з «папкою» або «модулем». Але сьогодні ми домовимося простіше: шар — це правило відповідальності, а не структура директорій. Вам не обов’язково просто зараз створювати чотири папки й урочисто розкладати файли. Важливіша дисципліна: які функції та типи мають право знати про консоль, а які — не мають, навіть якщо технічно можуть import Foundation і викликати print().

Шар — це як зона на кухні. У зоні «мийка» ви миєте посуд, а в зоні «плита» смажите. Можна, звісно, посмажити котлету в раковині… але потім вас згадуватимуть родичі на сімейних вечорах із певним сумом. Так само і в коді: можна зробити print() де завгодно, але наслідки будуть «ароматними».

Схема буде такою:

CLI → Application Service → Domain → Repository

І ключове правило спрямування залежностей — дуже важливе! — звучить так: зовнішній шар може знати внутрішній, але внутрішній не повинен залежати від зовнішнього. Тобто домен не повинен знати, що його хтось викликає з консолі, а репозиторій не повинен друкувати «Готово, додано» — це не його робота.

Схема взаємодії шарів

Перш ніж писати код, корисно побачити «карту місцевості». Ми будемо будувати CLI для маленької бібліотеки: додаємо книги й виводимо список. Жодних файлів, жодної мережі — сьогодні нам важливо втримати межі відповідальності, а не навчитися «як зберегти JSON» (це буде пізніше).

flowchart LR
    CLI["CLI-шар (readLine()/print())"] --> SVC["Application Service (сценарії)"]
    SVC --> DOM["Domain (моделі та правила)"]
    SVC --> REPO["Repository (доступ до даних)"]
    REPO --> SVC
    DOM --> SVC
    SVC --> CLI

Щоб було простіше «тримати в голові», зафіксуємо ролі в таблиці:

Шар Головна відповідальність Що в ньому доречно Що в ньому не доречно
CLI спілкування з користувачем readLine(), print(), дружні повідомлення, мінімальна нормалізація введення зберігання даних, доменні правила, «розумні» рішення
Application Service сценарій (use case) зібрати кроки: валідувати → створити модель → викликати репозиторій → повернути результат пряме введення/виведення, деталі формату зберігання
Domain сенс і правила моделі, інваріанти, перевірки коректності print(), читання введення, знання про те, як зберігаємо
Repository межа доступу до даних контракт на операції зберігання та отримання доменні правила, повідомлення для користувача

Зверніть увагу: репозиторій — це саме межа доступу до даних. Навіть якщо дані поки що в пам’яті, логічна роль залишається тією самою. Це як розминка перед основним тренуванням: сьогодні відточуємо форму.

3. Domain: моделі та правила без консолі й без зберігання

Доменний шар — це серце програми: тут живуть сутності та правила предметної області. У нашому випадку предметна область проста: «книга» з ідентифікатором і назвою. Але навіть у простому домені корисно заздалегідь відокремлювати «сирий рядок від користувача» від «валідної назви книги». Це знижує шанс, що у вашій системі зʼявиться книга з назвою " " — три пробіли, але зате з душею.

Почнемо з доменних типів: BookID, BookTitle, Book.

import Foundation

struct BookID: Hashable {
    let rawValue: UUID

    init() {
        self.rawValue = UUID()
    }
}

BookID — маленький тип, але він робить сигнатури читабельнішими. Коли в коді зʼявляється просто UUID, легко почати передавати не те. А BookID — уже натяк: «це ідентифікатор книги, не плутай».

Тепер створимо доменну помилку й обʼєкт-значення для назви:

import Foundation

enum DomainError: Error {
    case emptyTitle
}

struct BookTitle: Hashable {
    let value: String

    init(_ raw: String) throws {
        let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
        guard !trimmed.isEmpty else { throw DomainError.emptyTitle }
        self.value = trimmed
    }
}

Зверніть увагу на стиль: домен не запитує в користувача «а введіть нормально». Він просто каже: «це невалідно» — і повертає помилку вгору. Домен не друкує, не читає, не вгадує. Він суворий, як компілятор, але трохи добріший.

І тепер сама книга:

struct Book: Hashable {
    let id: BookID
    let title: BookTitle

    init(id: BookID = BookID(), title: BookTitle) {
        self.id = id
        self.title = title
    }
}

Книга зберігає вже валідний BookTitle. Це і є доменний інваріант: якщо об’єкт Book існує, його назва не порожня. Це маленьке правило, але воно дуже впливає на якість усієї програми, тому що ви перестаєте робити перевірки «а раптом порожньо?» у десяти місцях.

4. Repository: контракт доступу до даних

Репозиторій часто звучить страшніше, ніж є насправді. На практиці це просто договір: як застосунок може зберігати й отримувати книги. Сьогодні дані будуть у пам’яті. Завтра — у файлі. Післязавтра — у базі даних. Але сценарій застосунку не повинен переписуватися щоразу, коли змінюється спосіб зберігання.

Тому репозиторій ми починаємо з protocol.

protocol BookRepository {
    func add(_ book: Book) throws
    func all() throws -> [Book]
}

Зверніть увагу, що контракт оперує доменними типами: Book, а не [String: Any] і не «CSV-рядком». Це важливо: якщо ви протягнете формат зберігання в контракт, то формат почне просочуватися у верхні шари.

Для початку реалізуємо репозиторій у пам’яті:

final class InMemoryBookRepository: BookRepository {
    private var storage: [BookID: Book] = [:]

    func add(_ book: Book) throws {
        storage[book.id] = book
    }

    func all() throws -> [Book] {
        Array(storage.values)
    }
}

Тут є storage — деталь реалізації. І це саме те, що ми хочемо сховати від решти коду. Нехай сервіс не знає, словник там чи масив, чи голубина пошта.

5. Application Service: сценарії, а не консольні трюки

Сервісний шар (Application Service) — це «режисер сцени». Він не грає роль доменної моделі й не тягне декорації зберігання. Він координує сценарій: отримує зрозумілі параметри, викликає доменні правила, просить репозиторій зберегти або прочитати дані й повертає результат — або помилку.

Створимо LibraryService, який уміє додати книгу й отримати перелік:

final class LibraryService {
    private let repo: any BookRepository

    init(repo: any BookRepository) {
        self.repo = repo
    }

    func addBook(title rawTitle: String) throws -> Book {
        let title = try BookTitle(rawTitle)     // доменна валідація
        let book = Book(title: title)
        try repo.add(book)                      // доступ до даних
        return book
    }

    func listBooks() throws -> [Book] {
        try repo.all()
    }
}

Тут одразу видно кілька корисних речей.

Сервіс не робить print("Готово"). Він повертає Book, щоб той, хто викликає сервіс, сам вирішив, що показати користувачеві. Сервіс також не використовує readLine(). Він отримує рядок як аргумент. Це робить сервіс незалежним від того, звідки прийшли дані: з консолі, з тесту, з UI, хоч від розмовного тостера.

6. CLI-шар: введення, виведення та переклад «людського» на програмний

CLI-шар — це місце, де ви розмовляєте з користувачем. Тут живуть readLine(), print(), форматування повідомлень і легка нормалізація на кшталт «обрізати пробіли по краях». Якщо домен — це «суворий закон», то CLI — це «ввічливий секретар»: він може допомогти користувачеві, підказати, але не повинен вирішувати бізнес-правила замість домену.

Створимо невеликий CLI-ранер, який підтримує команди:

  • add <назва>
  • list
  • exit

Без спроби створити справжній парсер команд — це окрема велика тема, до якої ми ще повернемося.

import Foundation

struct LibraryCLI {
    let service: LibraryService

    func run() {
        print("Команди: add <назва> | list | exit")

        while true {
            print("> ", terminator: "")
            let line = (readLine() ?? "").trimmingCharacters(in: .whitespacesAndNewlines)

            if line == "exit" { break }
            handle(line)
        }
    }

    private func handle(_ line: String) {
        // Реалізацію додамо в наступному фрагменті
    }
}

Тут CLI виконує те, що йому належить: друкує запрошення, читає рядок, трохи нормалізує. А далі — передає керування обробнику.

Додамо обробку команд. Зверніть увагу: ми не робимо «ідеальний парсинг», ми робимо передбачуваний і простий.

extension LibraryCLI {
    private func handle(_ line: String) {
        if line == "list" {
            runList()
            return
        }

        if line.hasPrefix("add ") {
            let title = String(line.dropFirst(4))
            runAdd(title: title)
            return
        }

        print("Невідома команда") // повідомлення користувачеві — тут
    }
}

Тепер runAdd і runList, де ми викликаємо сервіс і показуємо результат:

extension LibraryCLI {
    private func runAdd(title: String) {
        do {
            let book = try service.addBook(title: title)
            print("Додано: \(book.title.value)")
        } catch {
            print("Помилка: \(error)")
        }
    }

    private func runList() {
        do {
            let books = try service.listBooks()
            print("Кількість книг: \(books.count)")
        } catch {
            print("Помилка: \(error)")
        }
    }
}

Так, зараз ми виводимо error «як є». Це нормально для старту. Зрозумілі для людини помилки й правила «що показуємо користувачеві, а що залишаємо розробникові» — окрема велика тема дня, і до неї ми ще повернемося.

Невелика ремарка про точку входу: у CLI-проєктах Swift часто є файл main.swift — точка входу програми. Код у ньому виконується зверху вниз. Це базова модель CLI-застосунків, і вона добре підходить для наших навчальних прикладів.

7. Цілісне збирання: поєднуємо шари в один застосунок

Час зібрати все разом. В одному місці — у поточних прикладах просто поруч із main.swift — ми збираємо конкретну реалізацію репозиторію, сервіс і CLI. Це виглядає просто, але це важлива архітектурна ідея: внутрішні шари не створюють зовнішні, і навпаки; ми зв’язуємо їх ззовні.

let repo = InMemoryBookRepository()
let service = LibraryService(repo: repo)
let cli = LibraryCLI(service: service)

cli.run()

На цьому етапі у вас уже є працюючий застосунок: ви вводите add Війна і мир, потім list, і він показує кількість книг.

Ключовий момент: тепер ви можете замінити InMemoryBookRepository на іншу реалізацію, і сервіс майже напевно не зміниться. А якщо ви захочете зробити інший інтерфейс, наприклад не CLI, а GUI, — сервіс і домен теж залишаться тими самими.

8. Витоки шарів: як помітити проблему

Коли шари лише починають упроваджувати, найчастіша проблема — «ніби розділили, а насправді все ще перемішано». Це нормально: мозок ще не звик, що print() — не просто функція, а архітектурний маркер.

Витік шару зазвичай відчувається так: ви змінюєте щось в одному місці, наприклад формат команди add, а ламається зовсім інша частина системи, наприклад доменна модель. Або ви хочете повторно використати домен в іншому контексті, але він тягне за собою введення/виведення й половину Foundation.

Класичний витік — print() усередині домену. Здається дрібницею: «ну я ж просто для відлагодження». Але потім домен використовується в тестах, і раптом у виведенні тестів утворюється каша. Ще гірше: домен «сам» починає спілкуватися з користувачем, а CLI-шар стає зайвим.

Другий частий витік — репозиторій, який вирішує доменні правила. Наприклад: «якщо title порожній — не зберігати». Це робить поведінку системи неочевидною: тепер є два місця, де може статися відмова, і ви втрачаєте єдине джерело істини.

Третій витік — сервіс, який робить readLine() або друкує підказки. Це вбиває повторне використання сервісу: його не можна нормально викликати з іншого інтерфейсу, бо він раптом сам поліз у консоль.

Добра новина: витоки чудово лікуються одним питанням: «Хто має нести відповідальність за це рішення?» Якщо відповідь — «користувацький інтерфейс», значить CLI. Якщо відповідь — «сценарій», значить сервіс. Якщо відповідь — «правило предметної області», значить домен. Якщо відповідь — «як зберігаємо або дістаємо дані», значить репозиторій.

9. Типові помилки

Помилка № 1: print() у Domain або Repository «для зручності».
Майже неминуче хочеться всередині BookTitle.init написати print("Порожня назва"). Це здається нешкідливим, але потім перетворюється на шум і залежність домену від інтерфейсу. Домен має повідомляти про проблему через помилку, а рішення «як це показати людині» повинно прийматися в CLI-шарі.

Помилка № 2: CLI-шар починає валідувати «по-дорослому» і дублює правила домену.
Можна захопитися й написати в CLI: «якщо рядок порожній — не викликай сервіс». А потім точно таку саму перевірку залишити в домені. У підсумку правило дублюється, а при зміні вимог ви забудете оновити одну з копій. CLI може зробити мінімальну гігієну — обрізати пробіли, — але змістові правила мають одного власника: домен.

Помилка № 3: Repository повертає готові рядки для друку, а не моделі.
Новачкам зручно зробити func allAsText() -> String, але це одразу зв’язує репозиторій із форматом виведення. Щойно ви захочете інший формат — таблицю, JSON або гарний список, — вам доведеться ламати репозиторій. Нехай репозиторій повертає [Book], а CLI вирішує, як їх показати.

Помилка № 4: Application Service перетворюється на клас на всі випадки.
Якщо сервіс починає і парсити команди, і друкувати довідку, і зберігати масив книг усередині, то шар сценаріїв зникає, а код знову перетворюється на «антиприклад із початку лекції», тільки тепер він розмазаний по файлах. Сервіс повинен бути вузьким: він відповідає за кроки сценарію та зв’язки між доменом і даними.

Помилка № 5: Змішування типів рівня «формат» із доменними типами.
Навіть сьогодні, коли ми зберігаємо все в пам’яті, може виникнути спокуса зберігати книги як String і вважати це «моделлю». Але домен виграє від того, що модель — це окремий тип (Book), а не просто рядок. Інакше завтра ви захочете додати id, і виявиться, що всю систему треба переписати.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ