DEV Community

Cover image for Umbraco 2-way relations - Umbraco Features You Didn't Know You Needed
Bernadet Goey
Bernadet Goey

Posted on AI-assisted

Umbraco 2-way relations - Umbraco Features You Didn't Know You Needed

Relations in Umbraco: asking the question your picker can't answer

You've probably used a Content Picker (or a Multinode Treepicker) to wire content together. A page picks a banner, a landing page picks a set of related articles, a policy picks the older policy it replaces. That gives you one direction for free: open the page, and you can see exactly what it points at.

But pickers only answer one question. "What does this page use?" is easy. "What uses this?" is not. The picker property lives on the node that did the picking, not on the node that got picked. If you want to go the other way, your only option is make the pages hierarchical or to loop over every piece of content in the tree and check whether its picker field happens to mention the node you care about.

That's exactly the gap Umbraco's Relations are for. A Relation is a first-class, queryable link between two entities that you can walk in either direction via IRelationService: GetByParentId for "what does this point at", GetByChildId for "what points at this". This post walks through a small example project that uses a Relation to answer a question a picker alone can't.

The scenario: policies that supersede each other

Imagine a "Policy Page" doctype. Editors write a new policy, and pick (via a normal Multinode Treepicker) which older policy or policies it supersedes. That's a completely standard one-way relationship: new policy → picks → old policy.

The picker answers "which policies does this one supersede" perfectly well. What it can't answer is the question an editor actually asks when they land on an old policy page: "Has this been superseded, and by what?" Nothing on that old policy's own data points back to the newer one — the pick lives entirely on the other node. Short of scanning every Policy Page in the site for one whose supersedes field happens to mention this node's ID, there's no way to answer it.

Why not just use Umbraco's built-in "Related Document" relation?

Umbraco ships a built-in relation type for exactly this kind of document-to-document link, aliased umbDocument. It would technically work — except it's a single shared, undifferentiated bucket. Anything in Umbraco (media picker usages, link pickers, whatever else) can write a generic document-to-document reference into it, so a reverse lookup on umbDocument can't reliably tell you "which policy superseded this one" versus any other unrelated reference someone else recorded into the same bucket.

The fix is a relation type of your own policySupersedes so a reverse lookup on that alias only ever returns the thing you actually care about.

The implementation

The example lives in four files.

1. The doctype and the picker. Add a "Policy Page" content type with a bodyText richtext property and a supersedes Multinode Treepicker, filtered so it can only pick other Policy Pages.

This is the plain, one-way picker. Nothing special yet.

2. Syncing the picker into a Relation. PolicySupersedesRelationSyncHandler listens for ContentSavedNotification. Every time a Policy Page is saved, it wipes out whatever relations already exist for that node under the policySupersedes alias and rebuilds them from whatever's currently picked:

    public void Handle(ContentSavedNotification notification)
    {
        foreach (IContent content in notification.SavedEntities)
        {
            if (content.ContentType.Alias == PolicyPageContentTypeAlias)
            {
                SyncSupersedesRelations(content);
            }
        }
    }

    private void SyncSupersedesRelations(IContent newPolicy)
    {
        IRelationType relationType = _relationService.GetRelationTypeByAlias(ExampleConstants.RelationTypeAlias)
            ?? CreateRelationType();

        foreach (IRelation existingRelation in _relationService.GetByParentId(newPolicy.Id, ExampleConstants.RelationTypeAlias))
        {
            _relationService.Delete(existingRelation);
        }

        foreach (int supersededPolicyId in GetPickedContentIds(newPolicy))
        {
            var relation = new Relation(newPolicy.Id, supersededPolicyId, relationType)
            {
                Comment = $"Marked as superseded on {DateTime.UtcNow:u}",
            };
            _relationService.Save(relation);
        }
    }
Enter fullscreen mode Exit fullscreen mode

As shown above, you can also add a comment to a relation, which we use to store additional information on when the policy was superseded in our example, which can then be shown again on the Policy page.

The relation type itself is created on demand as non-bidirectional, Document-to-Document, and importantly isDependency: true:

    private IRelationType CreateRelationType()
    {
        var relationType = new RelationType(
            "Policy Supersedes",
            ExampleConstants.RelationTypeAlias,
            isBidrectional: false,
            parentObjectType: Constants.ObjectTypes.Document,
            childObjectType: Constants.ObjectTypes.Document,
            isDependency: true,
            key: null);
        _relationService.Save(relationType);
        return relationType;
    }
Enter fullscreen mode Exit fullscreen mode

3. Wiring it up. Add the notification handler with the standard IComposer boilerplate, nothing Relations-specific.

4. Reading both directions in the view. This is where the payoff shows up. policyPage.cshtml reads the picker property directly for the forward direction, and calls RelationService.GetByChildId for the reverse direction, the direction the picker alone could never give you:

var supersededByRelations = RelationService.GetByChildId(Model.Id, ExampleConstants.RelationTypeAlias);
Enter fullscreen mode Exit fullscreen mode
@if (supersededByRelations.Any())
{
    <div style="border: 2px solid red; padding: 1em; margin-bottom: 1em;">
        <strong>This policy has been superseded.</strong>
        <ul>
            @foreach (var relation in supersededByRelations)
            {
                var newerPolicy = ContentQuery.Content(relation.ParentId);
                if (newerPolicy is not null)
                    {
                        <li>
                            Replaced by <a href="@newerPolicy.Url()">@newerPolicy.Name</a>
                            <br />
                            <small>@relation.Comment</small>
                        </li>
                    }
            }
        </ul>
    </div>
}
Enter fullscreen mode Exit fullscreen mode

Open an old policy that's been superseded, and the page now knows it without ever scanning the rest of the tree.

Old policy page superseded

The other payoff: you can't delete a still-referenced policy without a warning

Setting IsDependency = true on the relation type isn't just documentation, it plugs straight into Umbraco's own dependency checks. Try to delete or unpublish a policy that's still marked as superseded by another one, and the backoffice will warn you it's in use, the same way it would for a media item still referenced by a Content Picker elsewhere.

This is the practical version of "what if you need to delete a shared component, but three pages still reference it": with a plain picker, deleting the referenced item is silent and the reference just breaks. With a proper Relation, Umbraco already knows something depends on it and stops you.

When to reach for this

  • A picker is enough when you only ever need to ask "what does this content point at", the normal, forward direction.
  • The built-in "Related Document" relation is fine for generic, low-stakes cross-references where you don't care about precision on the reverse lookup.
  • A custom relation type is worth the extra notification-handler code when you need a reverse lookup you can trust "what points at me" and/or you want deletion/unpublish safety for content that's still depended on.

Pickers tell you what a page uses. Relations are what let you ask the question in the other direction.

Top comments (0)