1. Почему не стоит смешивать всё в одном load()
Когда пишешь первый файловый репозиторий, очень хочется сделать так: в load() прочитать файл, тут же декодировать, тут же превратить в доменные типы, тут же что-нибудь поправить, и заодно вывести print("Loaded!") для настроения. Это ощущается как «я молодец, оно работает», но дальше начинается взрослая жизнь: формат JSON меняется, путь к файлу становится настраиваемым, появляются новые поля, а ошибки нужно нормально показывать пользователю.
Проблема тут не в том, что «так нельзя по правилам чистого кода». Проблема в том, что вы сами себе усложняете жизнь: один метод начинает знать слишком много. Он знает про файловую систему, про JSON, про структуру DTO, про доменные правила и про то, как реагировать на ошибки. И каждый следующий шаг (например, поменять форматирование JSON или заменить способ записи) превращается в мини-операцию на мозге без наркоза.
Давайте посмотрим на типичный «монолитный» антипример (он компилируется, но в долгую — вредный):
import Foundation
final class BadRepository {
private let fileURL: URL
init(fileURL: URL) {
self.fileURL = fileURL
}
func loadTitles() throws -> [String] {
let data = try Data(contentsOf: fileURL)
let dto = try JSONDecoder().decode([String].self, from: data)
return dto
}
}
Он даже выглядит прилично. Но как только вместо [String] у вас появится LibraryFileDTO, а потом ещё schemaVersion, а потом маппинг в Book, а потом нормальные ошибки — метод раздуется, и вы начнёте бояться его трогать (а это плохой признак: код, которого боятся, обычно мстит).
2. Конвейер преобразований: URL ↔ Data ↔ DTO ↔ Domain
Чтобы перестать смешивать обязанности, полезно мыслить не «репозиторий читает JSON», а «репозиторий запускает конвейер преобразований». На входе конвейера у нас лежит URL файла, на выходе — доменные сущности. Где-то посередине обязательно будет Data, а рядом с ним — Codable-DTO, потому что JSON сам по себе в домен обычно тащить не хочется.
В Swift-сообществе давно укоренилась идея разделять шаги сериализации на отдельные этапы: сначала получить представление, удобное для кодирования, потом превратить его в байты, потом обратно байты в валидированное представление и лишь затем — в доменную сущность.
Давайте зафиксируем схему. Её удобно держать в голове как «трубу»:
flowchart LR
A[URL файла] --> B[Data]
B --> C[DTO: Codable]
C --> D[Domain-модель]
И отдельно два направления:
flowchart TD
subgraph LOAD["load()"]
L1[URL] --> L2[read Data] --> L3[decode DTO] --> L4[map to Domain]
end
flowchart TD
subgraph SAVE["save()"]
S1[Domain] --> S2[map to DTO] --> S3[encode Data] --> S4[write URL]
end
Главная мысль сегодняшней лекции: шаг “decode/encode” не должен быть внутри шага “read/write”. Это разные задачи, и им полезно жить в разных типах.
3. Домен и DTO формата файла
Мини-домен: Book и BookID
Перед тем как делить обязанности, нам нужно договориться о минимальных типах, вокруг которых будет крутиться пример. Мы продолжаем учебный CLI-проект «библиотека»: храним книги и их названия. Модель будет нарочно простой — сегодня мы тренируем архитектуру, а не богатство предметной области.
import Foundation
struct BookID: Hashable, Codable {
let rawValue: UUID
}
struct Book: Codable {
let id: BookID
var title: String
}
Обратите внимание на деталь: хотя Book и Codable, мы всё равно будем кодировать не его напрямую, а DTO формата файла. Это кажется странным до тех пор, пока вы не попробуете поменять формат хранения хоть раз. После первой же миграции вы начнёте уважать DTO как уважали бы огнетушитель: пока не нужен — кажется лишним, когда нужен — поздно спорить.
DTO формата файла: «как лежит на диске»
DTO — это договор с файлом. Доменные типы — это договор с предметной областью. И эти два договора не обязаны совпадать. В файле удобно хранить UUID как обычную строку/UUID, в домене удобно хранить BookID. В файле нам нужен schemaVersion, в домене он обычно не нужен как часть Book.
Поэтому делаем два DTO: один для книги, второй — корневой контейнер файла.
import Foundation
struct BookDTO: Codable {
var id: UUID
var title: String
}
struct LibraryFileDTO: Codable {
var schemaVersion: Int
var items: [BookDTO]
}
И вот здесь важный момент по теме лекции: DTO не должен знать ничего про пути, файлы, директории и где это хранится. DTO — это просто структура данных, которую удобно кодировать и декодировать.
Маппинг DTO ↔ Domain: отдельный «переводчик»
Когда вы впервые слышите «маппинг», хочется закатить глаза: «ну это же одна строчка, зачем отдельная сущность». Но здесь та же история, что с ремнями безопасности: один раз пристегнулся — кажется неудобно, один раз не пристегнулся — неудобно становится в другом смысле.
Мы делаем маппинг как расширение BookDTO. Это удобно: DTO остаётся DTO, а преобразование — рядом, но не смешивается с чтением/записью.
import Foundation
extension BookDTO {
init(domain: Book) {
self.id = domain.id.rawValue
self.title = domain.title
}
func toDomain() -> Book {
Book(id: BookID(rawValue: id), title: title)
}
}
На этом шаге мы всё ещё не трогаем JSON и не трогаем файловую систему. Мы просто умеем сказать: «в домене у нас так, а в файле — так».
4. Два компонента вместо одного: кодек и файловый слой
JSON-кодек: DTO ↔ Data, без URL и папок
Теперь — ключевой герой лекции: кодек. Его задача — превращать DTO в байты (Data) и обратно. Всё. Он не должен знать, где лежит файл, существует ли директория, какие права у пользователя и как называется ваш проект. Он живёт в мире «вот тебе структура — вот тебе JSON-байты».
Сделаем LibraryJSONCodec.
import Foundation
struct LibraryJSONCodec {
private let encoder: JSONEncoder
private let decoder: JSONDecoder
init() {
let e = JSONEncoder()
e.outputFormatting = [.prettyPrinted, .sortedKeys]
self.encoder = e
self.decoder = JSONDecoder()
}
func encode(_ dto: LibraryFileDTO) throws -> Data {
try encoder.encode(dto)
}
func decode(_ data: Data) throws -> LibraryFileDTO {
try decoder.decode(LibraryFileDTO.self, from: data)
}
}
Почему это хорошо?
- Кодек можно тестировать без файлов вообще: дали DTO → получили Data → проверили, что декодируется обратно.
- И наоборот, можно тестировать файловый слой без JSON: дали Data → записали → прочитали обратно.
Это и есть практический смысл разделения ответственности, а не «красивые слова».
FileStore: URL ↔ Data, без Codable и JSONDecoder
Вторая половина разделения — файловый ввод/вывод. И тут мы делаем симметричный ход: файловый слой умеет читать и писать байты, но не знает ничего про LibraryFileDTO.
Самый простой вариант выглядит так:
import Foundation
struct FileStore {
func read(from url: URL) throws -> Data {
try Data(contentsOf: url)
}
func write(_ data: Data, to url: URL) throws {
try data.write(to: url)
}
}
Да, мы уже обсуждали, что для надёжной записи позже мы захотим «temp → replace» и, возможно, backup. Но обратите внимание: это всё равно останется обязанностью файлового слоя, а не кодека. Кодек вообще не должен подозревать, что его байты куда-то записывают — он «в вакууме» делает JSON.
И это очень освобождает мозг: если вам нужно улучшить стратегию записи, вы не трогаете JSON-код и DTO. Вы улучшаете только FileStore.
5. Репозиторий-оркестратор: связываем шаги в load/save
Каркас репозитория и зависимости
Теперь собираем всё вместе. Репозиторий — это тот, кто знает, в каком порядке запускать шаги, и где источник правды в памяти. Но он не должен сам «уметь читать файлы» и «уметь кодировать JSON». Он должен использовать FileStore и LibraryJSONCodec.
Сделаем репозиторий, который хранит книги в памяти как словарь. Сегодня мы не реализуем оптимизации поиска и индекс — нам важно, чтобы жизненный цикл load/save был чистым и понятным.
import Foundation
enum RepositoryError: Error {
case unsupportedSchemaVersion(Int)
}
final class JSONFileBookRepository {
private let fileURL: URL
private let store: FileStore
private let codec: LibraryJSONCodec
private var booksByID: [BookID: Book] = [:]
init(fileURL: URL, store: FileStore, codec: LibraryJSONCodec) {
self.fileURL = fileURL
self.store = store
self.codec = codec
}
}
Заметьте приятную вещь: зависимости передаются через init. Это не «сложная DI-архитектура», это просто честный способ сказать: репозиторий не обязан создавать кодек и файловый слой сам.
load(): read Data → decode DTO → map to Domain
Сейчас мы напишем load() как последовательность простых шагов. Каждый шаг маленький, и поэтому ошибки легче понимать: если упали на чтении — проблема с файлом, если упали на декодировании — проблема с JSON, если упали на версии схемы — проблема совместимости.
import Foundation
extension JSONFileBookRepository {
func load() throws {
let data = try store.read(from: fileURL)
let dto = try codec.decode(data)
guard dto.schemaVersion == 1 else {
throw RepositoryError.unsupportedSchemaVersion(dto.schemaVersion)
}
let books = dto.items.map { $0.toDomain() }
self.booksByID = Dictionary(uniqueKeysWithValues: books.map { ($0.id, $0) })
}
}
Да, тут есть одна «плотная» строка со словарём. Если она выглядит страшно — это нормально. Страх проходит, когда вы понимаете идею: мы хотим быстро построить booksByID, потому что это удобно как источник правды.
Если хотите сделать более «пошагово» (иногда так читается легче), можно так:
import Foundation
extension JSONFileBookRepository {
func replaceState(with books: [Book]) {
var dict: [BookID: Book] = [:]
for book in books {
dict[book.id] = book
}
self.booksByID = dict
}
}
И тогда в load() будет чуть меньше «магии».
save(): map to DTO → encode Data → write URL
С save() логика зеркальная. Важно: репозиторий сохраняет текущее in-memory состояние. Он не обязан «угадывать», что изменилось, и не обязан смешивать сохранение с командами add/update/remove. Мы отдельно обсуждали, что семантика «изменили в памяти» и «сохранили на диск» должна быть явной.
import Foundation
extension JSONFileBookRepository {
func save() throws {
let items = booksByID.values
.map { BookDTO(domain: $0) }
.sorted { $0.title < $1.title }
let dto = LibraryFileDTO(schemaVersion: 1, items: items)
let data = try codec.encode(dto)
try store.write(data, to: fileURL)
}
}
Почему сортировка тут допустима? Потому что это не «доменные правила», а удобство формата файла: человеку проще смотреть на JSON, где книги идут в стабильном порядке. И, главное, сортировка не нарушает доменную модель. Если вам это кажется лишним — можно убрать. Я не обижусь. Компилятор тоже.
Мини-демо: добавили → save() → load()
Чтобы ощутить, что всё связалось, добавим мини-методы для работы с памятью. Здесь мы не углубляемся в полный контракт репозитория (он был раньше), а просто сделаем пару операций, чтобы можно было «пощупать» цикл.
import Foundation
extension JSONFileBookRepository {
func add(title: String) {
let id = BookID(rawValue: UUID())
booksByID[id] = Book(id: id, title: title)
}
func allBooks() -> [Book] {
Array(booksByID.values)
}
}
И маленький top-level пример (в CLI-утилите это временно, чисто для проверки руками):
import Foundation
let url = URL(fileURLWithPath: "library.json")
let repo = JSONFileBookRepository(fileURL: url, store: FileStore(), codec: LibraryJSONCodec())
repo.add(title: "Swift для тех, кто устал")
try repo.save()
try repo.load()
print(repo.allBooks().count) // 1
Если вы запустите это в окружении, где рядом можно писать файл, вы увидите 1. И это самое приятное «1» в мире: оно означает, что разделение слоёв не сломало нам жизнь, а наоборот — сделало её аккуратнее.
6. Что мы выиграли: тестируемость и локальные изменения
Сейчас будет важный момент, который новички часто недооценивают. Разделение ответственности — это не про красоту. Это про то, что изменения становятся локальными.
- Если вам нужно поменять форматирование JSON (например, убрать prettyPrinted, потому что файл огромный), вы трогаете только LibraryJSONCodec. Репозиторий вообще не заметит.
- Если вам нужно поменять стратегию записи (например, записывать через временный файл и замену), вы трогаете только FileStore. Кодек вообще не заметит.
- Если вам нужно поменять доменную модель (например, добавить author), вы меняете доменные типы и маппинг DTO↔Domain. И, что важно, вы решаете отдельно: меняется ли формат файла. Если да — меняете DTO. Если нет — DTO можно оставить.
Это похоже на кухню: нож режет, плита нагревает, а повар решает последовательность. Можно, конечно, пытаться резать ножом и одновременно жарить на нём котлету, но тогда вы получите очень странный нож и ещё более странную котлету.
7. Типичные ошибки
Ошибка №1: кодек начинает «знать про файлы».
Очень соблазнительно добавить в LibraryJSONCodec метод decode(from url: URL) и внутри сделать Data(contentsOf:). Кажется, что это «удобно», но вы моментально склеиваете два слоя в один, и потом не можете протестировать JSON-декодирование без файлов. Держите железное правило: кодек работает только с Data.
Ошибка №2: файловый слой начинает «знать про DTO».
Иногда делают FileStore.save(dto: LibraryFileDTO, to: URL) и внутри кодируют JSON. Это симметричная ошибка: вы больше не можете улучшать файловую запись независимо от JSON. Файловый слой должен быть туповатым (в хорошем смысле): прочитал байты, записал байты.
Ошибка №3: репозиторий превращается в «бог-объект».
Даже при наличии FileStore и кодека можно написать load() на 80 строк, где репозиторий ещё и «чинит битые данные», и «делает миграцию», и «строит индекс», и «логирует», и «печатает пользователю подсказку». Проблема не в количестве строк, а в количестве обязанностей. Если вы чувствуете, что load() становится страшным, обычно это сигнал: вы смешали уровни абстракции.
Ошибка №4: print() внутри хранения.
Это особенно коварно: кажется, что один print("Saved") никому не мешает. Но потом вы захотите использовать репозиторий в другом режиме (например, в тестах или в другом CLI-выводе), и внезапно репозиторий начнёт «болтать» в консоль там, где вы этого не просили. Репозиторий возвращает ошибки и данные; общение с пользователем живёт уровнем выше.
Ошибка №5: «раз у меня Domain тоже Codable, значит DTO не нужно».
Это рабочая стратегия ровно до первой серьёзной правки модели. Потом вам нужно либо поддерживать старые файлы, либо мигрировать, либо хранить schemaVersion. DTO — это ваш способ сохранить свободу: менять домен, не ломая формат файла, или менять файл, не таща это напрямую в домен.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ