# Identity-first model: как сохранить lazy semantics без своих событий В системах конфигурации и сборки почти всегда возникает одно и то же напряжение: * хочется иметь **наблюдаемую модель**, на которую можно подписаться через что-то вроде `all(...)` * но не хочется, чтобы такая подписка **ломала ленивость** * ещё меньше хочется тащить в архитектуру собственную шину событий, порядок подписки и отдельный lifecycle Один из рабочих способов решить это — **разделить identity и вычисляемое состояние**. ## Суть принципа Идея простая: * отдельный объект отвечает только за **identity** и минимальные метаданные выбора * всё содержательное состояние агрегата получается **отдельными вызовами API** * эти вызовы уже могут быть lazy, дорогими, вычисляемыми, кэшируемыми, провайдерными — какими угодно То есть вместо одного “толстого” объекта мы получаем два слоя: ### 1. Identity layer Лёгкие объекты, которые: * дёшево создаются * не мутируют * безопасно наблюдаются eagerly * годятся как ключи и точки подписки ### 2. State / aggregate access layer Отдельные API, которые: * по identity находят или вычисляют содержимое * делают heavy work только по требованию * могут сохранять ленивую семантику Это особенно полезно там, где коллекция должна быть replayable, но при этом не должна тащить за собой дорогую материализацию. --- ## Проблема, которую это решает Рассмотрим типичную ситуацию в Gradle-подобной модели. Есть коллекция объектов, на которую адаптер хочет подписаться: ```java projections.getProjections().all(projection -> { ... }); ``` Если `projection` — это тяжёлый объект, внутри которого уже лежат: * вычисленные bindings * провайдеры на source sets * derived state * полу-материализованные сущности то `all(...)` начинает рано раскрывать то, что хотелось оставить ленивым. Появляются симптомы: * ломается lazy semantics * растёт связность * становится важен порядок применения плагинов * хочется заводить собственные события: `onCreated`, `onResolved`, `onMaterialized` Архитектура начинает скрипеть не потому, что `all(...)` плох, а потому что **слишком много смысла засунуто в наблюдаемый объект**. --- ## Решение: наблюдать identity, а не состояние Если сделать наблюдаемыми только identity objects, картина меняется. Например: ```java public interface SourceSetProjection extends Named { } ``` Такой объект содержит только identity: * `name` * возможно, ещё пару метаданных выбора * но не тяжёлое содержимое Тогда подписка: ```java projections.getProjections().all(projection -> { ... }); ``` больше не страшна. Она eagerly materializes только дешёвые ключи, а не весь агрегатный граф. Содержимое запрашивается отдельно: ```java Set bindings = projections.getBindings(projection); NamedDomainObjectProvider sourceSet = materializer.getSourceSet(projection.getName()); ``` И вот эти вызовы уже могут быть: * lazy * вычисляемыми * кэшируемыми * привязанными к runtime lifecycle --- ## Пример ### Неудачный вариант Представим такой интерфейс: ```java public interface SourceSetProjection extends Named { Set getBindings(); NamedDomainObjectProvider getSourceSet(); } ``` Проблемы тут сразу видны: * `SourceSetProjection` уже не identity object, а почти агрегат * внутри смешаны: * symbolic identity * relation data * runtime reference в чужой домен * подписка через `all(...)` начинает тащить за собой больше, чем хотелось бы На словах объект называется “projection”, а по факту внутри у него уже полсистемы. --- ### Более удачный вариант Разделяем ответственность: ```java public interface SourceSetProjection extends Named { } ``` ```java public interface SourceSetProjections { NamedDomainObjectCollection getProjections(); Set getBindings(String sourceSetName); } ``` ```java public interface SourceSetMaterializer { NamedDomainObjectProvider getSourceSet(String sourceSetName); } ``` Теперь сценарий адаптера выглядит так: ```java projections.getProjections().all(projection -> { Set bindings = projections.getBindings(projection.getName()); NamedDomainObjectProvider sourceSet = materializer.getSourceSet(projection.getName()); // apply adapter-specific policy }); ``` Что изменилось: * replayable подписка сохранилась * eager materialization допустима, потому что `SourceSetProjection` дешёвый * дорогое и вычисляемое состояние ушло в отдельные API * materialization остаётся под контролем одного владельца --- ## Почему это лучше событий Когда в модели смешаны identity и состояние, очень быстро хочется изобретать события: * `projectionCreated` * `projectionResolved` * `sourceSetAvailable` * `sourceSetMaterialized` Потому что в какой-то момент становится важно, **когда именно** объект уже “достаточно готов”. Если же наблюдаемый объект содержит только identity, а всё тяжёлое получается отдельными вызовами, событийная модель часто вообще не нужна. Получается более спокойная схема: * есть **identity registry** * есть **lookup API** для связей * есть **lazy materialization API** для тяжёлых сущностей То есть вместо событий: > “когда объект станет достаточно готов, я что-то сделаю” получается обычный и понятный flow: > “я вижу identity, а нужное состояние спрошу отдельно, когда оно мне действительно понадобится” Это проще и для reasoning, и для тестирования, и для эволюции API. --- ## Что именно должно жить в identity object Identity object не обязан быть “абсолютно пустым”. Он может содержать **метаданные выбора**, если они: * дешевы * стабильны * не требуют тяжёлой инициализации * не превращают объект в агрегат Например: * `id` * `name` * `kind` * `domain key` * maybe `type` Но он не должен содержать: * вычисляемое содержимое агрегата * ссылки времени выполнения на чужие домены * lazy providers на heavy objects * derived state, который может провоцировать раннюю материализацию Хорошая практическая формула: **identity object contains selection metadata; aggregate content is obtained separately.** --- ## Когда этот принцип особенно полезен Он особенно хорош, если: * есть replayable наблюдение через `all(...)` * identity-объекты дешёвые и почти immutable * содержимое агрегата может быть дорогим * нужен clean split между symbolic model и runtime model * хочется избежать собственной событийной шины Для Gradle-подобных моделей это вообще очень естественный приём. --- ## Когда он не нужен Если объект: * маленький * дешёвый * уже сам по себе и есть всё его содержимое то разделение на identity и отдельные lookup API может быть лишним. То есть этот принцип полезен не как догма, а как инструмент. Его стоит применять там, где реально есть риск смешения: * ключа * состояния * вычисления * runtime reference --- ## Практический итог Если сформулировать коротко, то принцип такой: 1. **Identity objects** содержат только identity и дешёвые метаданные выбора. 2. **Агрегатное содержимое** не хранится внутри них, а получается отдельными API-вызовами. 3. Эти API уже могут выполнять: * lazy resolution * кэширование * heavy computation * materialization 4. Благодаря этому можно безопасно использовать replayable механизмы вроде `all(...)`, не разрушая ленивую семантику там, где она действительно важна. Именно так удаётся совместить: * простое наблюдение * чистую модель * отсутствие собственных событий * ленивую материализацию тяжёлого состояния