We’ve been discussing this topic in the ESCR meetings quite extensively and haven’t found a conclusion yet, so it might be a good thing to bring forth the cases for and against having extensible CR nodes to have a better overview over the most important risks and opportunities.
I am strongly in favor of having nodes as extensible read models to have a native way of implementing domain logic using the Neos Content Repository. A reference example would e.g. be a product catalog in the CR with product entities implemented as nodes. This brings lots of advantages like rendering using already implemented components, inline editing, translating, indexing, linking just to name the most important.
My favored way of implementing this functionality on the read side is to have an abstract node read model that Neos knows and can handle to provide said features and that additionally can have custom members and provide a specific, type-safe interface for its model.
I imagine the implementation to be like this (using PHP8.1):
(with ContentStreamIdentifiers and DSPs as flyweights and NodeAggregateClassification as enum)
abstract class Node
{
public function __construct(
public readonly ContentStreamIdentifier $contentStreamIdentifier,
public readonly NodeAggregateIdentifier $nodeAggregateIdentifier,
public readonly OriginDimensionSpacePoint $originDimensionSpacePoint,
public readonly NodeType $nodeType,
public readonly ?NodeName $nodeName,
public readonly NodeAggregateClassification $classification,
public readonly PropertyCollection $properties,
public readonly DimensionSpacePoint $dimensionSpacePoint
) {}
final public function isRoot(): bool
{
return $this->classification === NodeAggregateClassification::CLASSIFICATION_ROOT;
}
final public function isTethered(): bool
{
return $this->classification === NodeAggregateClassification::CLASSIFICATION_TETHERED;
}
final public function getProperty($propertyName): mixed
{
return $this->properties[$propertyName];
}
final public function hasProperty($propertyName): bool
{
return $this->properties->offsetExists($propertyName);
}
final public function getCacheEntryIdentifier(): string
{
return 'Node_' . $this->contentStreamIdentifier->getCacheEntryIdentifier() . '_' . $this->dimensionSpacePoint->getCacheEntryIdentifier() . '_' . $this->nodeAggregateIdentifier->getCacheEntryIdentifier();
}
public function getLabel(): string
{
return $this->nodeType->getNodeLabelGenerator()->getLabel($this);
}
final public function equals(Node $other): bool
{
return $this->contentStreamIdentifier === $other->contentStreamIdentifier
&& $this->dimensionSpacePoint === $other->dimensionSpacePoint
&& $this->nodeAggregateIdentifier->equals($other->nodeAggregateIdentifier);
}
}
All identity and other metadata are available as public readonly properties, making any previous NodeInterface obsolete. Utility methods that are essential for Neos can be implemented final and be added / removed as we see fit since they are (mostly) closed for modification. Neos, Fusion etc. can be implemented against this abstract class Node
.
The CR would then provide a base implementation Thing
that would simply extend this abstract class:
final class Thing extends Node
{
}
while in userland, I can now implement my custom read model:
final class Product extends Node
{
public readonly Offers $baseOffers;
public readonly Offers $specialOffers;
public function __construct(
public readonly ContentStreamIdentifier $contentStreamIdentifier,
public readonly NodeAggregateIdentifier $nodeAggregateIdentifier,
public readonly OriginDimensionSpacePoint $originDimensionSpacePoint,
public readonly NodeType $nodeType,
public readonly ?NodeName $nodeName,
public readonly NodeAggregateClassification $classification,
public readonly PropertyCollection $properties,
public readonly DimensionSpacePoint $dimensionSpacePoint
) {
$this->baseOffers = $properties['baseOffers'];
$this->specialOffers = $properties['specialOffers'];
parent::__construct(
$contentStreamIdentifier,
$nodeAggregateIdentifier,
$originDimensionSpacePoint,
$nodeType,
$nodeName,
$classification,
$properties,
$dimensionSpacePoint,
);
}
final public function getBestOffer(CustomerGroup $customerGroup): Offer
{
return $this->specialOffers->getBestOffer($customerGroup)
?: $this->baseOffers->getBestOffer($customerGroup);
}
}
The additional properties may be fetched from the property collection as above or directly passed to the constructor via a custom factory.
This would also enable us to use PHP8 attributes for NodeType declaration:
#[NodeTypeBaseConfiguration('Acme.Site:Document.BlogPosting')]
#[SuperTypesConfiguration(['Acme.Site:Document'], [])]
#[NodeTypeUiBaseConfiguration('Blog Posting', 'blog')]
#[InspectorGroupConfiguration('blog', 'Blog', 'start', 'blog')]
final class BlogArticle extends Node
{
#[NodePropertyInlineConfiguration(null, 'please enter abstract')]
public readonly ?string $abstract;
[...]
}
The custom methods can then be used in userland Fusion, index configuration, PHP classes etc and can rely on nodes having a certain implementation and a type-safe property interface.
In combination with constraint check plugins on the write side, we now have full control over the lifecycle of a custom entity in the CR.