Composition Over Inheritance: Why Your Content Contract Is Not a Base Class
Inside the structural conformance pattern in SaaS CMS — from server-side inheritance to pipeline governance.
A contract in Optimizely SaaS CMS is rarely what developers expect on day one.
Create a contract called Categorizable with Category and Tags properties, apply it to a content type, and open that type's property configuration. In conventional class-based inheritance, you would expect an inherited property to be an immutable field handed down from a parent class. Instead, the fields sit directly on the content type, with their own display names, help text, and local configurations.
Think of it like a job description rather than a base class. A job description specifies the qualifications a candidate must bring; it does not hand them those qualifications. The candidate arrives already qualified, and the job description simply verifies the match.
Contracts operate the same way. They do not supply properties to your content types; your content types declare their own properties, and the contract validates that they conform to the required shape.
Two quick checks confirm this:
-
The official documentation notes that properties must exist on the content type before applying the contract.
-
The content type REST payload keeps isContract, contracts, and properties as completely separate fields.
That single distinction — validator, not supplier — changes how you design your content model, how your team coordinates deployments, and what your API can deliver.
The compiler was your guardian
Historically, enterprise content modeling in server-side content management systems followed a class-based inheritance pattern:
[ContentType(DisplayName = "Article Page")]
public class ArticlePage : SitePageData
{
public virtual string Category { get; set; }
public virtual IList<string> Tags { get; set; }
}
This model had genuine elegance. You declared a hierarchy, and the platform did the rest. At application startup, the framework walked your class tree through reflection, synchronised it against the database, and refused to proceed if something did not line up. Change a property type where data already existed and you were told immediately — before a single editor saw a broken field.
The enforcement was automatic, unconditional, and happened before content existed. You did not have to remember to check. A compiler and a startup sync did the remembering for you.
The trade-offs of deep inheritance hierarchies are well known: fragile base classes cause parent changes to ripple unpredictably into descendants, single-inheritance limits composition across multiple domains, and deep template trees become difficult to reason about and re-index.
Yet class inheritance solved a genuine governance problem, and it solved it automatically before content existed. That is the property worth remembering as we examine what replaced it.
There was one condition attached to all of it: it worked because a single runtime owned the entire model. That condition is the thing that changed.
Two ways to satisfy GraphQL, and where each one puts the inheritance
Something shifted underneath the model, and it is worth separating two layers that often get discussed as one.
The runtime left
Optimizely SaaS CMS operates as a multi-tenant cloud architecture. Rather than executing custom server-side binaries or reflection passes during application startup, content types are declared as structured JSON schemas over a REST API. Declarative schemas replace compiled application code.
Remove the server-side runtime and you remove the mechanism that made compiled class inheritance enforceable.
GraphQL has no inheritance, and that constrains every platform equally
The GraphQL specification has no concept of class inheritance. It offers exactly two tools for polymorphism: interface and union. A GraphQL interface works structurally — every concrete type implementing an interface must itself declare every field that interface defines. The interface does not hand fields down. It describes a shape and checks that implementers match it.
This is a property of the specification, not a platform decision. Any content platform delivering through GraphQL has to project its content model into structural interfaces at the API boundary, because that is the only shape the query layer can execute.
But that constraint applies to delivery, not to authoring
Here is the part worth being precise about, because it is easy to over-generalise.
The GraphQL constraint governs what the delivery schema looks like. It says nothing about how a platform models content internally. That leaves two coherent designs, and the wider content management category contains working examples of both.
Design one: keep inheritance in the authoring model, project it at the edge. The CMS retains a template or class hierarchy. Fields are declared once on a parent and cascade to descendants. A pipeline then flattens that hierarchy into structural GraphQL interfaces when content is indexed for delivery. Authors and developers get single-point field definition and automatic propagation. The cost is a translation step between two different schema representations, and inheritance coupling in the authoring model.
Design two: model structurally from the start. Content types are flat. Each declares its own properties. Shared shapes are expressed as contracts that validate conformance rather than supply fields. The authoring schema and the delivery schema are the same shape, so nothing needs translating. The cost is that shared definitions repeat across implementers, and propagation becomes an explicit, coordinated act rather than an automatic one.
Neither is the modern one and neither is the dated one. They optimise for different things — the first for authoring convenience and single-point definition, the second for schema alignment and reach across sources.
Structural contracts in SaaS CMS
Optimizely SaaS CMS operates on this structural model. Content types declare their own properties directly, and contracts validate their conformance for Optimizely Graph.
Because the content type schema and the delivery schema share the same structural shape, there is no translation layer between CMS models and GraphQL interfaces. A direct consequence of this architectural alignment is that the exact same contract can govern content across boundaries — whether it originates natively in the CMS or is connected from an external source.
A contract is not a base class. Here is what it actually is.
A contract is a shape validator and a Graph schema generator. It does not supply properties to your content type. Your content type supplies its own, and the contract validates that they match.
Side by side, the two models look like this:
CLASS INHERITANCE (Runtime-Enforced Base Class)
Vertical hierarchy: Subclasses descend from a rigid base class
+------------------------+
| SitePageData | <-- Base class defines Category, Tags
+-----------+------------+
| Inherits (Framework reflection at startup)
+---------+---------+
| |
v v
+--------------+ +--------------+
| ArticlePage | | EventPage | <-- Bound to single application runtime
| + Excerpt | | + StartDate | Cannot span across independent systems
+--------------+ +--------------+
STRUCTURAL COMPOSITION (CMS SaaS / Optimizely Graph)
Horizontal conformance: Independent traits validate flat, multi-source entities
+--------------------------------------------------------------+
| Categorizable Contract |
| (Defines required shape in Graph) |
+---------------+-------------------------------+--------------+
| |
Validates | Shape Validates | Shape
v v
+------------------------------+ +------------------------------+
| ArticlePage | | ExternalAsset |
| | | |
| * Category (Editorial Topic) | | * Category (Brand Theme) |
| * Tags | | * Tags |
| * Excerpt | | * AssetUrl |
+------------------------------+ +------------------------------+
[Native CMS Content] [External Connected Source]
In this model, content types remain flat entities. Contracts act as shape validators rather than parents, and conformance can span across native CMS pages and external connected assets alike.
In Optimizely Graph, each contract registers as a native GraphQL interface. Implementing content types satisfy that interface by declaring the required fields on their own schema, while remaining completely independent entities.
What the structural model genuinely unlocks
It would be a shame to treat this purely as a constraint to work around. The structural model enables several things that classical inheritance could not, and a few of them are genuinely significant.
One query across many types
Because the contract becomes a real GraphQL interface, you can query across every implementing type in a single request, and reach for type-specific fields where you need them:
query {
Categorizable(where: { Category: { eq: "Meetup" } }) {
items {
_itemMetadata {
key
displayName
}
Category
Tags
... on ArticlePage {
Excerpt
}
}
}
}
No merging results from three separate queries. No client-side reconciliation.
Components that do not care what page they are on
A front-end component can bind to the contract rather than to any concrete type. A category pill, a tag list, a card renderer — each written once against Categorizable, working unchanged when a fourth and fifth content type start implementing it later.
Horizontal growth
New content types do not disturb existing ones. There is no parent to modify, so there is no cascade to manage. The model grows sideways rather than downwards.
Composing several traits at once
Single-inheritance models force a choice. A component that needs headline behaviour, section placement rules, and tab display options has one parent slot to work with, so teams either build a deep chain where each layer adds a concern, or pick one parent and re-declare the rest by hand.
Structural conformance removes the choice. A content type lists every contract it satisfies, and the platform validates each independently. Nothing competes for a parent slot, so there is no diamond problem to resolve and no hierarchy depth to reason about — the same component simply appears under several contracts at once.
Contracts that span sources — the part worth pausing on
This is one of the most compelling capabilities of the structural model, yet one of the least discussed.
Class inheritance cannot cross a source boundary. A base class defined within one application cannot be directly inherited by an external DAM API response or a commerce catalog item, because independent systems share no common runtime or compiler. Whatever elegance an inheritance hierarchy provides, it stops at the boundary of that specific application runtime.
Structural conformance has no such limit. Conformance only asks about shape — it is indifferent to where the data originated. Optimizely Graph registers global contracts, prefixed with an underscore, that serve as parent types across multiple sources rather than CMS content alone.
The practical consequence is compelling: an externally sourced asset and a native CMS item can align to the same contract shape, making unified polymorphic querying possible across system boundaries. For teams working with an external DAM, a PIM, or a commerce catalog alongside CMS content, that opens up a class of queries that an inheritance model could rarely express without custom translation or synchronization pipelines.
Editorial context without API fragmentation
Two teams sharing a field rarely want to label it identically. One needs Editorial Topic grouped with metadata; the other needs Event Series grouped with scheduling. In a structural model, presentation and structure are separable: editorial labels adapt to each team's context while the underlying GraphQL interface remains a single, stable contract.
What the platform locks and what it leaves to you
Because properties live directly on each content type, teams naturally ask: what is genuinely bound by the contract, and what can be customized for content authors?
When you inspect a contract-implemented property in the CMS editing interface or examine the CLI validation rules, the boundary splits cleanly into two distinct buckets:
Bucket 1: Field identity (Bound by contract)
These are the structural invariants that define what the property fundamentally is in Optimizely Graph. In the CMS editing interface, these settings appear disabled with a grey background. If you attempt to modify them on an implementing type via code or the CLI push command, the platform halts with a validation error:
-
Property key / name: The programmatic identifier (e.g., category, heading).
-
Underlying data type: The scalar or complex type definition (e.g., Choice, String, ContentReference).
-
Format: The technical display and storage format (e.g., Drop-down list for a choice property).
-
Property indexing type: How Optimizely Graph indexes the field for search and filtering (e.g., Default, Searchable).
-
Mandatory entry (Value must be entered): Whether the field is strictly required across all content instances.
The platform locks these down because Optimizely Graph generates a single, unified GraphQL interface for the contract. The API must guarantee that every implementing content type resolves the exact same scalar or object shape under that property name.
Bucket 2: Authoring experience and workflow configuration (Tailored in the CMS UI)
Everything outside that locked identity is an authoring-time configuration decision. These settings remain fully editable on the implementing content type, allowing teams to tune the editorial experience for each specific content model without altering the GraphQL schema:
-
Dropdown choices and options: A choice property defined on a contract establishes that a scalar string is returned. Individual content types can freely define, add, or refine the specific list of selectable choices (e.g., a blog article offering editorial topics while an event page offers conference tracks). In Optimizely Graph, the value is stored and queried as a valid scalar string regardless of which option the editor selects.
-
Localization (Unique value per language): Whether a field must be translated independently per language branch or shared across all locales can be toggled per content type based on editorial requirements.
-
Allowed and restricted reference types: Content reference constraints can be tuned to fit the exact nesting rules of that specific component or page.
-
Input limits: Character bounds (maxLength, minLength) and array counts (minItems, maxItems) that guide editorial entry.
-
Default values: Whether the field starts empty, inherits a default, or pre-populates a custom value.
-
Editorial presentation: The display name, explanatory help text, property group tab, and display order seen by editors in the CMS editing view.
Why this division matters
Separating field identity from authoring configuration solves a classic enterprise dilemma: it provides API uniformity without forcing editorial rigidity.
Optimizely Graph receives a consistent, queryable property across all types implementing the contract. At the same time, content authors receive domain-specific labels, tailored dropdown choices, and relevant help text that match their daily workflow. The contract secures the data architecture; the CMS UI empowers the authoring experience.
The trade-offs worth knowing before you go further
No pattern is free, and this one has trade-offs worth naming clearly.
Definitions repeat. Each implementing type declares its own properties. "Composition" here does not mean one definition consumed in many places — it means many declarations validated against one shape. If that feels like duplication, that is a fair reading. It is the price of a model that maps one-to-one onto a GraphQL interface.
Contract changes fan out. Add a property to a contract and every implementing type needs updating. The platform validates conformance; it does not perform the change on your behalf. For a contract with fifteen implementers, that is fifteen declarations to keep aligned.
Branch and release coordination becomes yours. In teams with multiple developers working across separate feature branches, contracts represent a shared dependency. The CLI is vigilant here: when a contract change creates an incompatibility with existing content types, the push command halts with a breaking change warning and prompts whether you want to proceed with the --force flag. But in an automated CI/CD pipeline — and in any higher environment (QA, Staging, or Production) where --force is rightly restricted to protect valuable content — an uncoordinated push simply stops the deployment. If Developer A updates a contract for one type, but other implementers across active branches have not yet caught up, the pipeline halts until the entire contract family is reconciled.
Granularity is entirely your call. One broad contract with twenty properties forces implementers to carry fields they do not need. Fifteen micro-contracts produce query fatigue and cognitive overhead. There is no platform guidance that decides this for you, and no automated signal that you have drawn the line badly. Modelling around functional capabilities — routable, categorizable, SEO-trackable — provides a proven heuristic to keep contracts cohesive and reusable.
Inheritance instincts assume automatic propagation. Because contracts standardize shared fields, it is natural to assume that modifying a contract will automatically propagate changes down to all implementing types. Recognizing that contracts are validation interfaces rather than base classes is an essential mental model to establish across the team early.
Structure can be validated; intent cannot. A property can match a contract's shape perfectly and still drift semantically — same type, same key, quietly different meaning across two implementers. No schema check catches that. It is a modelling discipline question, and it stays a human one.
The governance the platform will not build for you
A clear conceptual framing helps teams navigate this transition:
The compiler did not disappear. It moved.
Previously, a server-side compiler and startup routine verified model integrity. In a decoupled SaaS architecture, that responsibility naturally shifts to your deployment pipeline and engineering conventions. That is not a limitation — it is the natural consequence of moving to a declarative, multi-tenant platform. But it does mean the discipline must be deliberate rather than automatic.
Three practices keep this model robust at scale.
Shared definitions in code
Rather than hand-maintaining the same property definition across implementers, define the shared shape once and compose it:
export const categorizable = {
Category: { type: 'string' },
Tags: { type: 'array', items: { type: 'string' } },
} as const;
import { categorizable } from '../contracts/categorizable';
export const articlePage = {
key: 'ArticlePage',
baseType: '_page',
contracts: ['Categorizable'],
properties: {
...categorizable,
Excerpt: { type: 'string' },
},
};
The structural definition has one home. Per-type presentation settings layer on top of the spread. If you want to go further, a TypeScript helper that accepts only presentation keys as overrides will let the compiler block structural divergence before it reaches a push — which is, pleasingly, the compiler coming back to help in a slightly different form.
A contract change protocol
Treat a contract change as a coordinated unit of work rather than an individual edit: identify every implementing type, update them together, deploy them together, then verify the generated schema reflects what you expected. Writing this down as a short runbook takes twenty minutes and prevents branches or environments from falling out of sync.
Contract ownership
Decide who can create a contract and what justifies one. A simple rule that has served well: do not create a contract until a genuine second implementer exists. Shared shapes should emerge from observed reuse rather than from anticipated reuse.
Where schema governance moves next
With the release of the Model Context Protocol (MCP) server for CMS SaaS, AI tooling can now access live CMS schema context directly. Rather than treating AI as a content generation tool alone, this capability opens up a natural extension for architectural governance.
The decisions that remain challenging in a structural model are not structural invariants — the platform validates those on push. They are architectural judgements: should a capability be one contract or two? Has a contract update been coordinated across all implementers? Has a field drifted in meaning while remaining structurally valid?
These are pattern-matching evaluations against an established schema framework. An AI assistant with live schema context can serve as an effective decision accelerator: surfacing implementers across active branches, identifying uncoordinated contract updates, or evaluating whether a proposed property belongs on a shared contract or directly on a specific content type.
The objective here is decision acceleration, not decision delegation. Content modeling choices fundamentally require human domain understanding. Yet providing architects and development teams with an automated, context-aware first pass on schema alignment is a compelling architectural evolution.
The compiler was once a server-side runtime, then it became a CI/CD pipeline, and it is increasingly becoming an intelligent assistant.
Closing thought
The transition from base classes to contracts can initially feel like a loss — moving away from something a server-side runtime previously handled automatically.
Yet in a composable architecture — where content flows across a CMS, an external DAM, a commerce catalog, and connected sources simultaneously — structural conformance addresses a reality that a single application runtime was never designed to span.
The enforcement did not vanish when the server-side runtime left; it relocated to your deployment pipeline, your code conventions, and your team's shared understanding of the model. Designing for that relocation from day one is what turns a potential governance challenge into a durable, scalable architecture.
This article is grounded in the Optimizely Graph schema specifications, official CMS SaaS documentation, and real-world CLI deployment workflows.
Documentation references: Create contracts, Define content types, Create content type API reference, Content base types, CMS (SaaS) MCP server overview.
Comments