Computed Fields in CMS 13 Content Contracts with Optimizely Graph
Content Contracts in Optimizely CMS 13 allow different content types to share a common structure when querying Optimizely Graph.
But what if the shared field doesn't exist as a stored CMS property, and its value needs to be calculated differently depending on the content type?
For example, imagine a search feature that includes articles, events, and images:
- Articles use Heading as their search title.
- Events use EventName.
- Images use ImageTitle, falling back to their name.
We want all three to expose a common SearchTitle field in Graph, without adding another persisted property to each content type.
Here's a working approach tested with Optimizely CMS 13.3.0.
1. Define a Content Contract
First, define a Content Contract with a method that each implementing content type uses to provide its search title.
[ContentType]
public interface ISearchableContent : IContentData
{
string GetSearchTitle();
}
Each content type implements the same contract but calculates the search title using its own properties. For example, an article page uses Heading, while an event page uses EventName.
public class ArticlePage : PageData, ISearchableContent
{
public virtual string Heading { get; set; }
public string GetSearchTitle() =>
string.IsNullOrWhiteSpace(Heading) ? Name : Heading;
}
public class EventPage : PageData, ISearchableContent
{
public virtual string EventName { get; set; }
public string GetSearchTitle() =>
string.IsNullOrWhiteSpace(EventName) ? Name : EventName;
}
Both content types implement the same contract but calculate SearchTitle differently. The same approach works for media types, such as images, using their own properties.
Make sure the relevant content types implement or are associated with the Content Contract in CMS so they can be queried together in Graph.
This keeps the calculation close to each content type and avoids maintaining a central switch statement.
2. Expose the Computed Field in Graph
Next, create an extension method and register it using Graph indexing conventions.
public static class SearchExtensions
{
public static string SearchTitle(this ISearchableContent content)
=> content.GetSearchTitle();
}
Register the computed field:
services.AddContentGraph(configureConventions: conventions =>
{
conventions.ForInstancesOf<ISearchableContent>()
.IncludeField(
x => x.SearchTitle(),
IndexingType.Searchable);
});
The important detail is that SearchTitle is computed during indexing. We don't need to add a persisted SearchTitle property to our CMS models.
The resulting Graph schema exposes the field through ISearchableContent, regardless of the underlying content type.
3. Query It with the Graph C# SDK
The interesting part is that the computed field can also be used in strongly typed SDK queries.
First, define a lightweight result model:
public class SearchResult
{
public string SearchTitle { get; set; }
}
Then query the Content Contract:
var query = graphClient
.QueryContent<ISearchableContent>()
.Where(x => x.SearchTitle().Contains("Optimizely"))
.Limit(20)
.IncludeTotal()
.Fields<SearchResult>(x => x.SearchTitle);
var result = await query.GetAsync();
In our test, the SDK translated the filtering expression into GraphQL:
query ISearchableContentQuery {
ISearchableContent(
where: { SearchTitle: { contains: "Optimizely" } }
limit: 20
) {
items {
SearchTitle
}
total(all: true)
}
}
The query returned one matching event with SearchTitle equal to Optimizely Developer Webinar.
Here's the interesting detail: SearchTitle() is a C# extension method, not a stored CMS property. Graph indexing conventions expose it as a searchable field, and the Graph C# SDK translates the expression into a server-side GraphQL filter rather than filtering an in-memory collection.
We also verified sorting by the computed field, language-specific values, and recalculation after updating and publishing the underlying content.
Key Takeaway
By combining CMS 13 Content Contracts, Graph indexing conventions, and the Graph C# SDK, we can expose a shared searchable field across different content types while keeping the calculation specific to each type.
No additional persisted CMS properties are required.
Keep computed field calculations lightweight and deterministic, since they run as part of the indexing process.
It also works well having a body on the interface method making it a default interface method:
string GetSearchTitle() { return "x"; }Quite nice to not have to implement an actual method everywhere the interface is added...