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-подобной модели.
Есть коллекция объектов, на которую адаптер хочет подписаться:
projections.getProjections().all(projection -> { ... });
Если projection — это тяжёлый объект, внутри которого уже лежат:
- вычисленные bindings
- провайдеры на source sets
- derived state
- полу-материализованные сущности
то all(...) начинает рано раскрывать то, что хотелось оставить ленивым.
Появляются симптомы:
- ломается lazy semantics
- растёт связность
- становится важен порядок применения плагинов
- хочется заводить собственные события:
onCreated,onResolved,onMaterialized
Архитектура начинает скрипеть не потому, что all(...) плох, а потому что слишком много смысла засунуто в наблюдаемый объект.
Решение: наблюдать identity, а не состояние
Если сделать наблюдаемыми только identity objects, картина меняется.
Например:
public interface SourceSetProjection extends Named { }
Такой объект содержит только identity:
name- возможно, ещё пару метаданных выбора
- но не тяжёлое содержимое
Тогда подписка:
projections.getProjections().all(projection -> { ... });
больше не страшна. Она eagerly materializes только дешёвые ключи, а не весь агрегатный граф.
Содержимое запрашивается отдельно:
Set<VariantLayerBinding> bindings = projections.getBindings(projection); NamedDomainObjectProvider<GenericSourceSet> sourceSet = materializer.getSourceSet(projection.getName());
И вот эти вызовы уже могут быть:
- lazy
- вычисляемыми
- кэшируемыми
- привязанными к runtime lifecycle
Пример
Неудачный вариант
Представим такой интерфейс:
public interface SourceSetProjection extends Named { Set<VariantLayerBinding> getBindings(); NamedDomainObjectProvider<GenericSourceSet> getSourceSet(); }
Проблемы тут сразу видны:
SourceSetProjectionуже не identity object, а почти агрегат-
внутри смешаны:
-
symbolic identity
- relation data
- runtime reference в чужой домен
- подписка через
all(...)начинает тащить за собой больше, чем хотелось бы
На словах объект называется “projection”, а по факту внутри у него уже полсистемы.
Более удачный вариант
Разделяем ответственность:
public interface SourceSetProjection extends Named { }
public interface SourceSetProjections { NamedDomainObjectCollection<SourceSetProjection> getProjections(); Set<VariantLayerBinding> getBindings(String sourceSetName); }
public interface SourceSetMaterializer { NamedDomainObjectProvider<GenericSourceSet> getSourceSet(String sourceSetName); }
Теперь сценарий адаптера выглядит так:
projections.getProjections().all(projection -> { Set<VariantLayerBinding> bindings = projections.getBindings(projection.getName()); NamedDomainObjectProvider<GenericSourceSet> sourceSet = materializer.getSourceSet(projection.getName()); // apply adapter-specific policy });
Что изменилось:
- replayable подписка сохранилась
- eager materialization допустима, потому что
SourceSetProjectionдешёвый - дорогое и вычисляемое состояние ушло в отдельные API
- materialization остаётся под контролем одного владельца
Почему это лучше событий
Когда в модели смешаны identity и состояние, очень быстро хочется изобретать события:
projectionCreatedprojectionResolvedsourceSetAvailablesourceSetMaterialized
Потому что в какой-то момент становится важно, когда именно объект уже “достаточно готов”.
Если же наблюдаемый объект содержит только identity, а всё тяжёлое получается отдельными вызовами, событийная модель часто вообще не нужна.
Получается более спокойная схема:
- есть identity registry
- есть lookup API для связей
- есть lazy materialization API для тяжёлых сущностей
То есть вместо событий:
“когда объект станет достаточно готов, я что-то сделаю”
получается обычный и понятный flow:
“я вижу identity, а нужное состояние спрошу отдельно, когда оно мне действительно понадобится”
Это проще и для reasoning, и для тестирования, и для эволюции API.
Что именно должно жить в identity object
Identity object не обязан быть “абсолютно пустым”. Он может содержать метаданные выбора, если они:
- дешевы
- стабильны
- не требуют тяжёлой инициализации
- не превращают объект в агрегат
Например:
idnamekinddomain 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
Практический итог
Если сформулировать коротко, то принцип такой:
- Identity objects содержат только identity и дешёвые метаданные выбора.
- Агрегатное содержимое не хранится внутри них, а получается отдельными API-вызовами.
- Эти API уже могут выполнять:
- lazy resolution
- кэширование
- heavy computation
- materialization
- Благодаря этому можно безопасно использовать replayable механизмы вроде
all(...), не разрушая ленивую семантику там, где она действительно важна.
Именно так удаётся совместить:
- простое наблюдение
- чистую модель
- отсутствие собственных событий
- ленивую материализацию тяжёлого состояния
