Table of Contents

Extending Core Components

This page is the reference for extending a built-in (Core) component — List, Form, SimpleRelatedList, OrderedRelatedList, ContentCriteria — by overriding a small seam instead of reimplementing the whole component.

When do I need this? You want one of the built-in components, but with a minor backend difference: a computed column, an extra filter, a save side-effect (Kafka, an external API), a custom permission rule, a hidden panel. Historically that meant copying a 150–520 line component class. With the seams below it is a ~10–60 line override.

Related docs. Authoring Guide — how the BFF picks and runs your C# class (read this first if you are new to custom components). Samples & Use Cases — complete, realistic components (often combining seams) with wiring + tests, plus a broad "I want to…" use-case catalogue. Seam Reference — source-verified signatures, the type glossary, enums, and schema shapes (the authoritative contract for you and for codegen/LLMs). Extension Building Blocks — the reusable helpers (reconciler, schema builders, JSON, validation, resilience) your overrides call. Calling an External API — how a seam gets an HttpClient that logs api-call and propagates X-Request-ID, and how a component the loader doesn't construct directly reaches the container. Troubleshooting — symptom → cause → fix when an override doesn't behave as expected. Migration Cookbook — real customer components rewritten as a Core subclass + a seam. List · Form · Simple Related List · Ordered Related List · Content Criteria — the per-type Schema/Data shapes the seams operate on.

Quick start — your first seam in three steps

A seam customization is a tiny subclass plus a config row. End to end:

1. Subclass the Core component and override one seam. (Add the helper DLL as a dependency; see the Authoring Guide.)

namespace Acme.Cms.Components;

public sealed class ActiveMoviesListComponent : ListComponent   // a Core base
{
    // Server-side filter: this list only ever shows ACTIVE rows.
    protected override void OnSearchCriteriaBuilt(SearchCriteria criteria)
        => criteria.GlobalFilter = string.IsNullOrEmpty(criteria.GlobalFilter)
            ? "STATUS=ACTIVE"
            : $"({criteria.GlobalFilter}) AND STATUS=ACTIVE";
}

2. Point the template-component row at your class — keep the Core type-key. The ASSEMBLY_NAME / CLASS_NAME config keys select your C# class; the type-key stays the stock list so the standard React widget renders it. (Exact SQL is in the Authoring Guide → Database wiring.)

Config key Value
ASSEMBLY_NAME Acme.Cms.Components
CLASS_NAME Acme.Cms.Components.ActiveMoviesListComponent
(type-key) list (unchanged — stock widget)

3. Deploy the DLL and verify. Ship the assembly with the BFF (deployment); the page renders through the stock list widget, now filtered. Nothing else changed — fetch, schema, paging, permissions are all inherited.

That's the whole loop: subclass → override one seam → wire the row. Pick the seam from the catalogue or the Which seam do I need? table; copy its exact signature from the Seam Reference.

The customization cliff this removes

A Core component can be customized at three tiers, with a cliff between the second and the third:

Tier Mechanism Reach
1 — Config keys INCLUDEFIELDS, FILTER, READONLY_FIELDS, FORMGROUPING, CUSTOM_FIELD_SCHEMA, ASIDE_* Render tweaks after the core fetch. Cannot change the data source, add a column from elsewhere, or touch save.
2 — Business-rule / validator plugins BUSINESSRULE_ASSEMBLY_NAME / CLASS_NAME, VALIDATION_ASSEMBLY_NAME / CLASS_NAME Form + RelatedList only; save/delete validation only. See Custom Business Rule / Custom Validation.
3 — Full reimplementation A brand-new MibComponentWrapper<T> class overriding MapData / MapSchema / Save / OnAfterSave Anything else. ~50–60% of every such class is identical plumbing.

The extension seams are a new tier between 2 and 3: override one small, named method and keep the entire Core pipeline (fetch, schema, save reconciliation, permissions, response shaping) intact.

Two backend customization models

There are two ways to write a custom C# backend for a component. Knowing which one you are in tells you whether the seams on this page apply.

Core subclass + seam (this page) MibComponent wrapper SDK (legacy)
Base class ListComponent, FormComponent, SimpleRelatedListComponent, OrderedRelatedListComponent, ContentCriteriaComponent MibComponent (wrapped by MibComponentWrapper<T>)
You override one protected virtual seam (default no-op) MapData(object)→object, MapSchema, Save(IMibRequest, ISaveManager) — the whole pipeline
Save model IPersistenceSaveData (built by the Core) ISaveManager.Save(mediaType, anonymous)
Boilerplate none — you inherit fetch/schema/save ~50–60% per class
Best for new components, and Core-shaped behavior with a small twist bespoke widgets the Core shape can't express

Most existing GVP/CMSE custom components were written against the wrapper SDK because the Core components did not offer seams. The seams are the modern path: prefer a Core subclass + seam for new work, and re-platform wrapper classes opportunistically (see the Migration Cookbook). This page documents the Core-subclass model only.

Which model do I need? (decision flow)

flowchart TD
    A[Change a built-in component] --> B{Render-only tweak<br/>after the core fetch?}
    B -- yes --> C[Tier 1 — config keys<br/>INCLUDEFIELDS / FILTER / READONLY_FIELDS / …]
    B -- no --> D{Save/delete validation<br/>on Form or RelatedList?}
    D -- yes --> E[Tier 2 — business-rule /<br/>validator plugin]
    D -- no --> F{Does a named seam cover it?<br/>data · schema · visibility · permission ·<br/>save/delete lifecycle · fetch · data-source}
    F -- yes --> G([Tier 2.5 — Core subclass + seam<br/>★ THIS PAGE])
    F -- no --> H[Tier 3 — full MibComponent wrapper SDK]

How a seam works — the three guarantees

Every seam follows the same contract, and that contract is the whole point:

  1. It is a protected virtual method with a no-op / identity default. The Core component calls it at a well-defined point — but the default body does nothing. If you do not override it, the component behaves exactly as it did before. Byte-identical by default.
  2. It is additive and V2-only. Seams fire on the V2/React render and save path (MapData / MapSchema / Save / OnAfterSave / Delete / OnAfterDelete / GetPermissions). The legacy Razor Index() / GetRenderData viewers are not touched.
  3. You override the seam, not the pipeline. You receive the model the Core already built (the V2 data, the schema, the reconciled save set, the resolved permission, the response) and adjust it in place. The framework keeps owning the orchestration.

Where the seams fire

sequenceDiagram
    participant BFF as ComponentEvaluator
    participant Cmp as Your subclass<br/>(overrides one seam)

    Note over BFF,Cmp: RENDER (read)
    BFF->>Cmp: ShouldHideComponent(ctx, ct)
    Note right of Cmp: visibility
    BFF->>Cmp: GetSecondaryInformationRequests(...)
    Note right of Cmp: OnSearchCriteriaBuilt(criteria) — List
    BFF->>Cmp: MapSchema(viewData, ct)
    Note right of Cmp: ContributeSchema(schema)
    BFF->>Cmp: MapData(viewData, ct)
    Note right of Cmp: TransformData(v2Data, raw)
    BFF->>Cmp: GetPermissions(responses)
    Note right of Cmp: OnPermissionResolved(perm, ...)

    Note over BFF,Cmp: WRITE (save / delete)
    BFF->>Cmp: Save(request, saveList, ct)
    Note right of Cmp: On*SaveBuilt / OnRelatedSaveReconciled
    BFF->>Cmp: OnAfterSave(results)
    Note right of Cmp: OnAfterSaveResponseBuilt(response, results)
    BFF->>Cmp: Delete(request, deleteList)
    Note right of Cmp: OnDeleteBuilt(deleteData, request)
    BFF->>Cmp: OnAfterDelete(results)
    Note right of Cmp: OnAfterDeleteResponseBuilt(response, results)

Reaching the framework collaborators

The bases expose their collaborators as protected so an override can use them without re-resolving anything:

protected IMibApiClientLibrary ApiClient { get; }     // the MIB API client
protected IWorkflowFactory WorkflowFactory { get; }   // workflows (list, form, meta, preference…)
public   IComponentConfiguration Configuration { get; }
public   ContextViewData Context { get; }             // IDs, User, CopiedFrom, RestoreState…

The seam catalogue

Each seam below lists its signature, what you can touch (the shape of the model handed to you), when it fires, what happens if it throws, a use case, an example, and gotchas. Examples subclass a Core component; wire the subclass to a template-component row exactly as in the Authoring Guide (ASSEMBLY_NAME + CLASS_NAME, and keep the type-key resolving to the Core widget so the stock React renderer is used).

1. Render data — TransformData

// List / SimpleRelatedList / OrderedRelatedList
protected virtual void TransformData(ListV2ViewData data, ListViewData viewData) { }
// Form
protected virtual void TransformData(FormV2ViewData data, FormViewData viewData) { }
// ContentCriteria
protected virtual void TransformData(ContentCriteriaV2ViewData data, ContentCriteriaViewData viewData) { }
  • What you can touch: data is the V2 payload the React shell receives.
    • ListV2ViewDataItems (List<ItemV2ViewData>), Total, Charts, AddedIds.
    • ItemV2ViewDataId, Fields (Dictionary<string, object>), Permissions, Etag, Title.
    • FormV2ViewData → the form's Fields (+ CreatedAt).
    • ContentCriteriaV2ViewDataGroups, Order, Highlights.
    • viewData is the raw Core view data (fetched rows/fields) — read it to derive values.
  • Fires: inside MapData, after the Core builds the V2 payload and before it is returned.
  • If it throws: the component is caught by ComponentEvaluator and rendered as Errored — the page survives (see Error & resilience contract).
  • Use case: a computed column, a provider-data overlay, masking a value, enriching rows from a second source.
public sealed class MovieListComponent : ListComponent
{
    // Add a derived "Availability" value to each row from data already fetched.
    protected override void TransformData(ListV2ViewData data, ListViewData viewData)
    {
        foreach (var item in data.Items)
        {
            var start = item.Fields.GetValue<DateTime?>("AVAILABLE_FROM");
            var end   = item.Fields.GetValue<DateTime?>("AVAILABLE_TO");
            item.Fields["AVAILABILITY"] = Availability(start, end); // computed, not stored
        }
    }
}

Gotcha. item.Fields is the same dictionary the React widget reads by column DataIndex. To display a value you add here, declare the column in ContributeSchema (next). A value with no column is carried but not shown.

2. Schema — ContributeSchema

protected virtual void ContributeSchema(ListSchema schema) { }            // List
protected virtual void ContributeSchema(RelatedListSchema schema) { }     // SimpleRelatedList / OrderedRelatedList
protected virtual void ContributeSchema(FormSchema schema) { }            // Form
protected virtual void ContributeSchema(ContentCriteriaSchema schema) { } // ContentCriteria
  • What you can touch:
    • ListSchema / RelatedListSchemaColumns (List<ColumnV2ViewData>), Filters, FormPageKey, ParentField (related).
    • ColumnV2ViewDataDataIndex, Title, Hidden, DefaultHidden, DefaultOrder, DisabledHide, Sorter, Width, Field (inline edition), RenderConfig.
    • FormSchemaFields (List<FormFieldSchema>), Title, Configuration.
    • FormFieldSchemaType (the React variant), Config, Props, Behaviors.
  • Fires: inside MapSchema, after the Core assembles the schema and before it is returned.
  • If it throws: caught → Errored (page survives).
  • Use case: add the column you computed in TransformData, hide a column, mark a field read-only, add a button. Prefer the ListSchemaBuilder/FormSchemaBuilder over editing the collections by hand.
public sealed class MovieListComponent : ListComponent
{
    protected override void ContributeSchema(ListSchema schema)
    {
        ListSchemaBuilder.For(schema)
            .AddColumn("AVAILABILITY", "Availability")   // show the computed value
            .Hide("INTERNAL_NOTES");                     // drop a noisy column
    }
}

Pair them. TransformData supplies a value; ContributeSchema declares the column that displays it. Override both for a computed column.

3. Visibility — ShouldHideComponent

public virtual Task<bool> ShouldHideComponent(ComponentContext context, CancellationToken ct)
    => Task.FromResult(false);
  • What you can touch: return true to hide. The passed context (ComponentContext) exposes only context.ViewData — the component's built view data, typed as object (cast it to the archetype's view-data type if you need it). For the entity / user / config inputs, read the component's own members — Context.IDs, Context.User, Configuration — not the passed context.
  • Fires: in ComponentEvaluator, before the component is rendered, as a single whole-component decision (not once per form-group segment). Upgrade note: this check now runs ahead of the form-group branch, so a form-with-groups can be hidden by ShouldHideComponent where it previously could not — the one intentional behavior change in the seam rollout. A component that doesn't override it is unaffected (default false).
  • If it throws: caught → Errored (page survives). Returning true instead yields the clean SkippedHidden outcome.
  • Default: false — never hide.
  • Use case: hide a panel based on entity state, a feature flag, or the user — without emitting an empty component.
public sealed class PpvScheduleComponent : OrderedRelatedListComponent
{
    public override Task<bool> ShouldHideComponent(ComponentContext context, CancellationToken ct)
        => Task.FromResult(Configuration.GetFlag("PPV_ENABLED") == false);
}

4. Permission — OnPermissionResolved

protected virtual void OnPermissionResolved(ComponentPermission permission,
                                            List<ResponseItemContent> responses) { }
  • What you can touch: permission.Create / Read / Write / Delete, each a PermissionType:

    public enum PermissionType { Deny = -1, DontCare = 1, Allow = 2 }
    

    Deny always wins; DontCare defers to the page/source default; Allow grants. Adjust in place — the Core already computed the baseline.

  • Fires: at the end of GetPermissions, on the resolved ComponentPermission.

  • If it throws: caught → Errored (page survives).

  • Use case: customer-specific permission overrides — the tvopenplatform GvpCustomPermissions and CMSE SkipDeleteErrorCheck cases — layered on top of the standard computation.

public sealed class SubscriptionFormComponent : FormComponent
{
    protected override void OnPermissionResolved(ComponentPermission permission,
                                                 List<ResponseItemContent> responses)
    {
        if (!Context.User.HasCapability("subscription.create"))
            permission.Create = PermissionType.Deny;   // revoke create; leave the rest as resolved
    }
}

5. Save — before persist

// SimpleRelatedList / OrderedRelatedList — the reconciled add/remove set
protected virtual void OnRelatedSaveReconciled(PersistenceComponentSaveRelatedEntitiesData saveData,
                                               PostbackComponentRequest request) { }
// Form — the single-entity save model
protected virtual void OnFormSaveBuilt(PersistenceComponentSaveSingleEntityData saveData,
                                       PostbackComponentRequest request) { }
// ContentCriteria — the built save model (recreate / new-from-item paths)
protected virtual void OnContentCriteriaSaveBuilt(IPersistenceSaveData saveData,
                                                  PostbackComponentRequest request) { }
  • What you can touch:
    • PersistenceComponentSaveRelatedEntitiesDataAddedRelateds (List<int>), RemovedRelateds (List<int>), OrderedRelateds (List<string>), ParentIDs, ParentBatchReference, BulkMode.
    • PersistenceComponentSaveSingleEntityDataFields (Dictionary<string, object>), Etags (Dictionary<int, string>), Files, IDs, Relateds.
  • Fires: inside Save, after the Core builds the persistence save model and before it is sent to the API.
  • If it throws: the save fails with that exception. Throw a PersistenceValidationException(TemplateComponentKey, "{DICT:…}") to surface a clean, localized validation message to the user (this is exactly what the real SubscriptionRelation component does).
  • Use case: enforce a rule on the change set, set a generated field, drop a duplicate relation, stamp a derived value.
public sealed class SubscriptionRelationComponent : SimpleRelatedListComponent
{
    // One rule on the add set — the whole reason this used to be a ~177-line class.
    protected override void OnRelatedSaveReconciled(PersistenceComponentSaveRelatedEntitiesData saveData,
                                                    PostbackComponentRequest request)
    {
        var existing = GetExistingDefinesIds();
        saveData.AddedRelateds.RemoveAll(id => existing.Contains(id));
    }
}
public sealed class MovieFormComponent : FormComponent
{
    protected override void OnFormSaveBuilt(PersistenceComponentSaveSingleEntityData saveData,
                                            PostbackComponentRequest request)
        => saveData.Fields["SLUG"] = Slugify(saveData.Fields.GetValue<string>("TITLE"));
}

Gotcha — ParentBatchReference is load-bearing. When the parent is being created in the same save, related rows reference it through ParentBatchReference; don't null it. Use RelatedSaveBuilder / LifecycleContext if you build a save model from scratch.

6. Save — after persist

protected virtual void OnAfterSaveResponseBuilt(PostbackComponentResponse response,
                                                List<PersistenceComponentResult> results) { }
  • What you can touch: response.Status (PostbackStatus), response.ErrorMessage, response.JsonData, response.EtagData. results carry the persisted ids/etags.

    public enum PostbackStatus {
        Success = 0, SuccessNoContent = 1, Error = 2, NoOperation = 3,
        ValidationException = 4, ErrorHandled = 5, InvalidEtag = 6,
        SuccessEtag = 7, SuccessWithWarning = 8
    }
    
  • Fires: at the end of OnAfterSave, after the Core builds the response and before it is returned.

  • If it throws: the post-save response build fails. Guard side effects so a best-effort failure (e.g. a Kafka hiccup) does not fail an already-committed save — downgrade to SuccessWithWarning instead (see PostSaveChain/SaveTransactionContext).

  • Use case: post-save side effects — Kafka publish, external sync, cache invalidation, audit — or augmenting the response.

public sealed class MovieFormComponent : FormComponent
{
    protected override void OnAfterSaveResponseBuilt(PostbackComponentResponse response,
                                                     List<PersistenceComponentResult> results)
    {
        if (response.Status == PostbackStatus.Error) return;   // don't act on a failed save
        _kafka.PublishMovieChanged(Context.IDs.First());
        if (MissingArtwork(results))
            response.Status = PostbackStatus.SuccessWithWarning; // soft warning, save still succeeds
    }
}

7. Delete — before & after persist

protected virtual void OnDeleteBuilt(IPersistenceDeleteData deleteData,
                                     PostbackComponentRequest request) { }          // before persist
protected virtual void OnAfterDeleteResponseBuilt(PostbackComponentResponse response,
                                                  List<PersistenceComponentResult> results) { } // after persist
  • What you can touch: the built IPersistenceDeleteData before it runs, and the PostbackComponentResponse afterwards (same fields as the save response).
  • Fires: OnDeleteBuilt on every delete-model the Delete method produces (the MissingId throw path is naturally excluded); OnAfterDeleteResponseBuilt at the end of OnAfterDelete.
  • If it throws: OnDeleteBuilt → the delete fails; OnAfterDeleteResponseBuilt → guard your side effect (the rows are already gone).
  • Use case: external cleanup on delete, cascade to another system, audit, or adjusting the delete model (etags, ids).
public sealed class AssetRelatedListComponent : SimpleRelatedListComponent
{
    protected override void OnAfterDeleteResponseBuilt(PostbackComponentResponse response,
                                                       List<PersistenceComponentResult> results)
    {
        if (response.Status != PostbackStatus.Error)
            _blobStore.DeleteOrphans(Context.IDs);   // clean external storage after the delete
    }
}

8. Fetch shaping — OnSearchCriteriaBuilt (List)

protected virtual void OnSearchCriteriaBuilt(SearchCriteria criteria) { }
  • What you can touch: criteria.GlobalFilter, criteria.Order, criteria.DisplayFields (string[]), criteria.Conditions (Condition[]), criteria.Limit / criteria.Page.
  • Fires: inside ListComponentBase.GetSecondaryInformationRequests, on the resolved SearchCriteria (freshly built or restored from saved state), before it drives the data request.
  • If it throws: caught → Errored (page survives).
  • Use case: shape what the list fetches — a server-side filter, the sort order, the display fields — on every fetch.
public sealed class ActiveOnlyListComponent : ListComponent
{
    protected override void OnSearchCriteriaBuilt(SearchCriteria criteria)
    {
        criteria.GlobalFilter = string.IsNullOrEmpty(criteria.GlobalFilter)
            ? "STATUS=ACTIVE"
            : $"({criteria.GlobalFilter}) AND STATUS=ACTIVE";
        criteria.Order = "DATEINS,desc";
    }
}

Why List only? SimpleRelatedList and OrderedRelatedList build their V2 fetch request inside the related-list workflow (CreateGetRelatedsRequest); their only SearchCriteria lives on the legacy refresh path, which is frozen. Shape related-list rendering with TransformData/ContributeSchema instead.

9. Data source — OnFetchData (List · SimpleRelatedList · OrderedRelatedList)

protected virtual bool ProvidesExternalData => false;                          // opt-in; default off
protected virtual Task<ListV2ViewData> OnFetchData(FetchContext context, CancellationToken ct)
    => Task.FromResult<ListV2ViewData>(null);
  • What you can touch: everything about where the rows come from. Set ProvidesExternalData => true and return the page of rows yourself — OnSearchCriteriaBuilt/TransformData only shape the MibApi fetch; this one replaces it. context (a FetchContext) carries ParentIds (the parent id(s) for a related list; empty for a top-level list), Page (zero-based), Limit, Order (e.g. "DATEINS,desc"), MediaType, and two filter fields: Filter (the raw free-text value, a string) and FilterCondition (a Condition carrying Field + Operator + Value). Read FilterCondition — not just Filter — when you need to honour a structured comparison like id < 2 versus name == 2; the related/ordered drawer sends the field and operator there. Return a ListV2ViewData { Items, Total } (declare the columns in ContributeSchema); return null for an empty list.

    Which FetchContext fields are populated depends on the archetype. A List fills all of them from its resolved SearchCriteria (Page/Limit/Order/Filter). A SimpleRelatedList / OrderedRelatedList always fills ParentIds + MediaType, plus Page/Limit and a structured FilterCondition on the async-data endpoint — but Order is never carried for related/ordered (the related criteria has no sort field), and the async-refresh path (the Refresh button) sends no filter. Always null-guard Order / Filter / FilterCondition.

  • Fires across the whole render surface. When ProvidesExternalData is true the Core skips its MibApi fetch (returns NoInformationRequest) and calls OnFetchData instead — on the initial render and on server-side refresh / paging / sort / filter (including the Refresh button). For a List the refresh path threads the postback criteria into context; for a related / ordered list the async data/refresh endpoints resolve the component and call it with the requested page/limit/filter. All three archetypes are wired end-to-end.

  • If it throws: render seam → caught → Errored (page survives).

  • Use case: the data lives somewhere else — an external service, a job/scheduler API, an aggregate over several backends. Today that forces a full custom component; with this seam it's a stock list/related-list subclass that overrides one method. (This is the seam that retires components like the DMM Importer Scheduler, which reads the external CWF job API — verified on a dev environment: the Importer Schedules page renders ~179 live CWF jobs through the stock list widget, no custom component, paging and Refresh intact.)

// A stock list whose rows come from an external jobs API instead of MibApi.
public sealed class ImporterJobsListComponent : ListComponent
{
    private readonly IJobsApi _jobs;
    protected override bool ProvidesExternalData => true;

    protected override void ContributeSchema(ListSchema schema) =>
        schema.Columns = new() {
            new(){ DataIndex="ID", Title="Job" }, new(){ DataIndex="STATUS", Title="Status" },
            new(){ DataIndex="CREATED", Title="Created" },
        };

    protected override async Task<ListV2ViewData> OnFetchData(FetchContext ctx, CancellationToken ct)
    {
        var page = await _jobs.ListAsync(ctx.Page, ctx.Limit, ct);   // external API
        return new ListV2ViewData(new ListViewData()) {
            Total = page.Total,
            Items = page.Jobs.Select(j => new ItemV2ViewData {
                Id = j.Id,
                Fields = new() { ["STATUS"] = j.Status, ["CREATED"] = j.CreatedAt }
            }).ToList()
        };
    }
}
flowchart LR
    GR["GetRenderData<br/>(self-fetch: skip MibApi,<br/>return the list view path)"] --> MS["MapSchema<br/>ContributeSchema<br/>(Filters + Configuration non-null)"] --> MD{"MapData<br/>ProvidesExternalData?"}
    MD -- "true" --> OF[["OnFetchData → your rows"]]
    MD -- "false" --> CORE[["MibApi fetch (default)"]]

Gotcha — the seam is multi-method. OnFetchData supplies the data, but the V2 render pipeline runs GetRenderData and MapSchema before MapData, and both reach MibApi meta/page code that a self-fetch component has skipped. The base classes already guard all of them for you — so overriding only OnFetchData (plus ContributeSchema for the columns) is enough. If you do reimplement MapSchema, keep the contract: it must emit a non-null Filters and Configuration — the React list widget calls filters.flatMap() and reads configuration.*, and a null there renders "An unexpected error happened." (Charts may be null.)

Related / ordered paging is partial, by design. The related/ordered live render path is workflow-driven and does not instantiate the component; the async data/refresh endpoints resolve it via IDisplayWorkflow.TryGetExternalListData and call FetchExternalData(int page, int limit, Condition filter, …), which threads the request into your FetchContext. The match to List is not total: paging always reaches OnFetchData; a structured FilterCondition reaches it on the async-data endpoint; but the async-refresh endpoint sends no filter, and sort / Order is never carried for related/ordered (the related criteria has no order field). If your external source needs sort, or refresh-time filtering, on a related/ordered list, that is not wired yet — track it on MEDIAIBOX-12033. (A top-level List has all of paging, sort, and filter end-to-end.)

Permissions on a self-fetch related/ordered list. A self-fetch related list never runs the MibApi relateds fetch, so it has no relateds view-data — the copy-flow and remove-button permission inputs (RelatedCopyType, RemoveButtonBehavior) don't apply and are skipped. GetPermissions resolves from the user's media-type permission alone. If you override OnPermissionResolved on a self-fetch list, do not assume those relateds-only inputs were considered.

The seam is byte-identical by default — a component that leaves ProvidesExternalData false fetches from MibApi exactly as before.


Error & resilience contract

The seam stage determines what a thrown exception does. This is deliberate — a buggy render override degrades one panel; a save override is allowed to reject the write.

Seam stage Seams If your override throws
Render TransformData, ContributeSchema, ShouldHideComponent, OnSearchCriteriaBuilt, OnPermissionResolved ComponentEvaluator catches it → the component's outcome is Errored. The page still renders; only this panel is affected. Nothing crashes.
Save / Delete (before persist) OnRelatedSaveReconciled, OnFormSaveBuilt, OnContentCriteriaSaveBuilt, OnDeleteBuilt The write fails with your exception. Throw PersistenceValidationException(TemplateComponentKey, "{DICT:KEY}") for a clean, localized user message; other exceptions surface as a generic error.
Save / Delete (after persist) OnAfterSaveResponseBuilt, OnAfterDeleteResponseBuilt The entity is already committed. A throw here only breaks the response build, not the data. Guard best-effort side effects and downgrade to SuccessWithWarning rather than throwing.

For after-persist side effects that must be observable or rolled back, use PostSaveChain / PostSaveResult (aggregate to the worst status) or SaveTransactionContext.OnCompensate (reverse-order compensation).

The golden rule. A seam you do not override changes nothing — the component is byte-identical to the stock one. Keep your override that way: guard side effects on success, and only throw from a before-persist seam when you actually mean to reject the write.


Which seam do I need?

I want to… Seam(s) Notes
Add a computed/derived column TransformData + ContributeSchema value in Fields, column in schema
Hide / reorder / relabel columns ContributeSchema use ListSchemaBuilder
Mark a form field read-only / change its variant ContributeSchema (Form) use FormSchemaBuilder.OverrideWhere(...)
Filter / sort what a list fetches OnSearchCriteriaBuilt List only
Hide the whole panel ShouldHideComponent honoured for all archetypes
Grant/deny a permission for this component OnPermissionResolved Deny wins
Block a save with a message before-persist save seam → throw PersistenceValidationException or a Validation rule
Tweak the change set (drop/add/dedupe relations, set a field) OnRelatedSaveReconciled / OnFormSaveBuilt mutate the save model in place
Run a side effect after save/delete (Kafka, sync, cache, audit) OnAfterSaveResponseBuilt / OnAfterDeleteResponseBuilt guard on success; downgrade to SuccessWithWarning
Validate the whole change set with ordered rules Validation registry, called from a save seam building blocks
Reconcile a collection (create/update/delete diff) CrudReconciler from a save seam building blocks
Swap the data source entirely (external API instead of MibApi) ProvidesExternalData + OnFetchData List / SimpleRelated / OrderedRelated — see §9

Testing your override

A seam is a tiny, isolated method, so test your logic directly — subclass the Core component, reach the protected seam, and assert default-vs-override behavior. This mirrors the framework's own seam test suite.

[TestFixture]
public class MovieListSeamTests
{
    // Expose the protected seam for the test.
    private class Probe : MovieListComponent
    {
        public void Invoke(ListV2ViewData data, ListViewData raw) => TransformData(data, raw);
    }

    [Test]
    public void TransformData_AddsAvailabilityColumn()
    {
        var data = new ListV2ViewData { Items = { /* an item with AVAILABLE_FROM/TO */ } };
        new Probe().Invoke(data, new ListViewData());
        Assert.That(data.Items[0].Fields.ContainsKey("AVAILABILITY"), Is.True);
    }
}

For save seams whose host method needs heavy collaborators, contract-test the seam directly (a Probe that calls the protected method and asserts the model was mutated) — the surrounding Core path is already covered by the framework tests. NSubstitute + reflection field injection covers the cases where you do want the seam exercised through the real Save/OnAfterSave.


Seam availability matrix

Capability Seam List Related Form Ordered ContentCriteria
Render data TransformData
Schema ContributeSchema
Visibility ShouldHideComponent
Permission OnPermissionResolved
Save · before persist OnRelatedSaveReconciled / OnFormSaveBuilt / OnContentCriteriaSaveBuilt
Save · after persist OnAfterSaveResponseBuilt
Delete · before persist OnDeleteBuilt
Delete · after persist OnAfterDeleteResponseBuilt
Fetch shaping OnSearchCriteriaBuilt
Data source (swap fetch) ProvidesExternalData + OnFetchData

(List has no entity save/delete of its own — it is a read surface — so the save/delete seams do not apply to it.)


Reusable building blocks

Beyond the per-component seams, a toolbox under MediaiBox.Cms.FrontEnd.Server.Component.Extensions removes the copy-pasted plumbing the old custom classes carried — CRUD reconciliation, schema builders, JSON helpers, validation, tree building, date/timezone math, a resilience wrapper, a request-scoped fetch cache, and more.

Full reference: Extension Building Blocks — all 27 helpers, grouped by family, each with signatures and a usage example.

Family Headliners
CRUD reconciliation CrudReconciler, ICrudReconciler<TRow,TKey>, BulkReconciler, OrderedReconciler
Save / persistence RelatedSaveBuilder, EtagPolicy, IdCodec, SaveTransactionContext, PostSaveChain
Schema building ListSchemaBuilder, FormSchemaBuilder
Validation ValidationRuleRegistry<T>, IValidationRule<T>, ValidationErrors
Data / JSON / trees JsonHelpers, TreeBuilder, DateConverter
Lifecycle & state LifecycleContext, ComponentRequestState
Runtime / resilience Resilience, RequestFetchCache, CorrelationContext, RenderBudget

Before & after

MovieFormComponent is ~269 lines — a full MapData / MapSchema / Save / OnAfterSave reimplementation — to add three behaviors: a Kafka publish on save, a parent status update, and a metadata-language validation. As a Core subclass it becomes the stock FormComponent plus:

public sealed class MovieFormComponent : FormComponent
{
    protected override void OnFormSaveBuilt(PersistenceComponentSaveSingleEntityData saveData,
                                            PostbackComponentRequest request)
        => ValidateMetadataLanguage(saveData);                 // ~15 lines of real logic

    protected override void OnAfterSaveResponseBuilt(PostbackComponentResponse response,
                                                     List<PersistenceComponentResult> results)
    {
        if (response.Status == PostbackStatus.Error) return;
        _kafka.PublishMovieChanged(Context.IDs.First());
        UpdateParentSeriesStatus(Context);
    }
}

Everything else — fetch, schema, the entire save pipeline, permissions, response shaping — is inherited and unchanged.

Worked, real examples: the Migration Cookbook walks three actual customer components (SubscriptionRelation, MovieForm, DescriptionList) from their wrapper implementations to a Core subclass + seam, with line-count deltas and the adoption trade-offs.


What is not yet a seam

These were deliberately deferred; track them on MEDIAIBOX-12033 before relying on them:

  • Richer data-fetch helpers. The data-source swap itself shipped for all three list archetypes, including the related/ordered async data/refresh path — OnFetchData (§9). Still pending around it: the additive request builders (BuildPrimary/Secondary/TertiaryRequest), a typed PagedFetch, and polymorphic RegisterEntityTypeHandler.
  • OnRenderError degraded render. Resilience already exists — a throwing component is caught and marked Errored (the page never crashes). A future seam would let a component supply a visible placeholder instead of being dropped.
  • Field-level schemaComputeFieldValue, async/cascading IOptionProvider, ResolveFieldState. For now do field-level work inside ContributeSchema (you have the whole schema) and TransformData.
  • ContentCriteria restriction internalsBuildRestriction, ConvertOperator, ValidateSelection.
  • Custom-action pipeline (RegisterAction) and the non-CRUD ViewComponentBase archetype.

Rules of the road

  • Don't break the default. A subclass that overrides nothing must behave identically to the Core component. Guard side effects on success; don't throw from a render or after-persist seam unless you mean it.
  • V2 only. Seams are the V2/React path. The legacy Razor Index() viewers are frozen; don't route legacy behavior through a seam.
  • Test the override, not the framework. See Testing your override.
  • Reuse the building blocks. Reach for ListSchemaBuilder, the CRUD reconciler, and the JSON helpers before writing plumbing — that plumbing is exactly what they were built to delete.