RFC: The case for CR nodes as a planned extension point

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.

1 Like

I never actually used custom node class implementations myself but it might be a nice pattern when a Node is a placeholder for a thing where the single source of truce lives outside of Neos.

However one could always implement getThingForNode Helper instead. Actually when confronted with a project from $someone i would get this much faster since custom node implementations are very uncommon.

Maybe it is a good idea to not open this extension vector right now but be prepared that this may happen later.

1 Like

Thanks for bringing this up here!

You know my POV since we discussed this before, but I want to share it here as well.

I really favor composition over inheritance, especially when it comes to domain models and it has served my quite well to avoid abstract classes (or even the extends keyword) in core domains.

My issues with an extensible Node model:

  • It makes the constructor and all fields of the model part of the API => it will be much harder for us to tweak it in the future
  • Methods can be overridden which might lead to weird or faulty behavior – or they need to be final (as in your example) – which to me is a sign that those belong to a different context
  • Maybe not a real issue, but I can already see code with a lot of instanceof runtime checks

As to your examples, I find it rather weird that a Product or BlogArticle has methods like isTethered().

I like your idea with the attributes, but they could totally be used without a common base class (and that would leave you with a cleaner separation IMO).

Personally I would always treat the Content Repository like a, well…, repository in the sense that persistence is separated from the domain logic:

final class ProductService {
    public function getProduct(ProductId $id): Product
    {
        $productNode = $this->contentSubGraph->findNodeByNodeAggregateIdentifier(NodeAggregateIdentifier::fromString($id->toString());
        return $this->productFactory->createFromNode($productNode);
    }
}

Or, in Fusion:

@context.product = ${Product.fromNode(productNode)}
product.specialOffers ...

Summarizing, this could turn into a “tabs vs spaces” like discussion and I would not insist on my POV since I could still do everything above with an abstract base class.

However, I strongly agree to Martins comment:

…since we could always go from final to abstract but not the other way around.

1 Like

I have used custom nodes extensively in projects purely as an implementation detail and not so much as a necessity. Although one case was really elegantly solved that otherwise would’ve been awfully complex. Overwriting isVisible() for products nodes that needed to check the global availability internally (which was not exactly just a dimension value).
I campaigned before to keep the feature, but ultimately I think it’s perfectly fine to not have it as most if not all cases can be solved differently.

I guess the helper/service option that bastian suggests, is the way to go.

1 Like