##// END OF EJS Templates
WIP variant artifacts DSL, FilePaths traits
WIP variant artifacts DSL, FilePaths traits

File last commit:

r40:924d9107c025 default
r50:ca3982e55d9e default
Show More
identity-first-model.md
346 lines | 8.3 KiB | text/x-minidsrc | MarkdownLexer
/ identity-first-model.md

Identity-First Model: Preserving Lazy Semantics Without Custom Events

In build and configuration systems, the same tension appears again and again:

  • you want an observable model that consumers can subscribe to using something like all(...)
  • you do not want that subscription to destroy laziness
  • and you would prefer to avoid introducing a custom event bus, event ordering, and a separate lifecycle model

A practical way to solve this is to separate identity from computed state.

Core idea

The idea is simple:

  • one object is responsible only for identity and minimal selection metadata
  • the actual aggregate content is obtained through separate API calls
  • those API calls may be lazy, expensive, cached, provider-based, or computed on demand

So instead of one “fat” object, we get two layers.

1. Identity layer

Lightweight objects that:

  • are cheap to create
  • are effectively immutable
  • are safe to observe eagerly
  • work well as keys and subscription points

2. State / aggregate access layer

Separate APIs that:

  • resolve or compute content from identity
  • do heavy work only when needed
  • preserve lazy semantics where that actually matters

This is especially useful when a collection must be replayable, but should not drag expensive materialization along with it.


The problem this solves

Consider a typical Gradle-like scenario.

An adapter wants to subscribe to a collection:

projections.getProjections().all(projection -> {
    ...
});

If projection is a heavy object that already contains:

  • computed bindings
  • providers of source sets
  • derived state
  • partially materialized objects

then all(...) starts forcing things that were supposed to stay lazy.

Typical symptoms:

  • lazy semantics are weakened or broken
  • coupling increases
  • plugin application order starts to matter
  • custom events begin to look tempting: onCreated, onResolved, onMaterialized

The problem is not that all(...) is bad. The problem is that too much meaning has been packed into the observable object.


The solution: observe identity, not state

If the observable collection contains only identity objects, the picture changes.

For example:

public interface SourceSetProjection extends Named {
}

This object contains only identity:

  • name
  • perhaps a small amount of selection metadata
  • but not heavy aggregate state

Now this subscription:

projections.getProjections().all(projection -> {
    ...
});

is no longer dangerous. It eagerly materializes only cheap keys, not the full aggregate graph.

The actual content is requested separately:

Set<VariantLayerBinding> bindings = projections.getBindings(projection);
NamedDomainObjectProvider<GenericSourceSet> sourceSet =
    materializer.getSourceSet(projection.getName());

Those calls may remain:

  • lazy
  • computed
  • cached
  • tied to runtime lifecycle
  • delegated to a dedicated materializer

Example

A problematic design

Suppose we define projection like this:

public interface SourceSetProjection extends Named {
    Set<VariantLayerBinding> getBindings();
    NamedDomainObjectProvider<GenericSourceSet> getSourceSet();
}

This is problematic because SourceSetProjection is no longer just identity. It is already close to an aggregate.

It mixes:

  • symbolic identity
  • relation data
  • runtime references into a foreign domain

Subscribing via all(...) now risks pulling in much more than intended.

The type says “projection”, but internally it already carries half the system.


A cleaner design

Split responsibilities instead:

public interface SourceSetProjection extends Named {
}
public interface SourceSetProjections {
    NamedDomainObjectCollection<SourceSetProjection> getProjections();
    Set<VariantLayerBinding> getBindings(String sourceSetName);
}
public interface SourceSetMaterializer {
    NamedDomainObjectProvider<GenericSourceSet> getSourceSet(String sourceSetName);
}

Now the adapter flow looks like this:

projections.getProjections().all(projection -> {
    Set<VariantLayerBinding> bindings =
        projections.getBindings(projection.getName());

    NamedDomainObjectProvider<GenericSourceSet> sourceSet =
        materializer.getSourceSet(projection.getName());

    // apply adapter-specific policy
});

What changed:

  • replayable subscription is preserved
  • eager observation is acceptable because SourceSetProjection is cheap
  • expensive and computed state has moved to separate APIs
  • materialization remains under the control of a single owner

Why this is often better than events

When identity and state are mixed together, people quickly start inventing events:

  • projectionCreated
  • projectionResolved
  • sourceSetAvailable
  • sourceSetMaterialized

That usually happens because it becomes important to know when exactly an object is “ready enough”.

If the observable object contains only identity, and heavy state is obtained separately, then many of those events become unnecessary.

The architecture becomes calmer:

  • an identity registry
  • a lookup API for relations
  • a lazy materialization API for heavy objects

Instead of saying:

“When this object becomes sufficiently ready, I will react.”

you can say:

“I can observe identity immediately, and ask for the expensive state only when I actually need it.”

This is easier to reason about, easier to test, and usually easier to evolve.


What should live inside an identity object

An identity object does not have to be completely empty. It may carry selection metadata, as long as that metadata is:

  • cheap
  • stable
  • not expensive to initialize
  • not turning the object into an aggregate

Typical examples:

  • id
  • name
  • kind
  • type
  • domain key

What should usually stay out:

  • computed aggregate content
  • runtime references to foreign domains
  • lazy providers of heavy objects
  • derived state that can trigger premature materialization

A useful rule of thumb:

An identity object contains selection metadata; aggregate content is obtained separately.


Why this works well with all(...)

all(...) weakens laziness only if the observed objects are themselves heavy or stateful.

If the observed objects are:

  • cheap
  • identity-only
  • effectively immutable

then eager observation is usually acceptable.

So the real principle is:

Eager observation of identity is often harmless. Eager observation of computed state is not.

That is why all(...) can be perfectly fine for collections of:

  • Variant
  • Layer
  • Role
  • SourceSetProjection

as long as those objects stay on the identity side of the boundary.


Where this principle is especially useful

This approach is particularly effective when:

  • there is replayable observation via all(...)
  • identity objects are cheap and stable
  • aggregate content may be expensive
  • symbolic model and runtime model should remain separate
  • you want to avoid building a custom event system

For Gradle-like models, this is often a very natural fit.


When it is unnecessary

If an object is:

  • small
  • cheap
  • and already fully represents its useful content

then splitting identity and state may be overengineering.

So this is not a universal rule. It is a tool to use when there is real tension between:

  • key
  • state
  • computation
  • runtime reference

Practical conclusion

The principle can be summarized like this:

  1. Identity objects hold only identity and cheap selection metadata.
  2. Aggregate content is not stored inside them, but retrieved through separate API calls.
  3. Those API calls may perform:
  • lazy resolution
  • caching
  • heavy computation
  • materialization on demand
  • This makes it possible to use replayable mechanisms such as all(...) without destroying laziness where laziness actually matters.

This is how you can combine:

  • simple observation
  • a clean model
  • no custom event bus
  • lazy materialization of heavy state

Short design note version

A concise version of the same principle:

Use identity objects as cheap, observable keys. Keep expensive or computed aggregate content out of them. Resolve that content through separate APIs on demand. This allows replayable observation (all(...)) without forcing premature materialization, and often removes the need for a custom event model.