Table of Contents

Migration Cookbook

Four real customer components, taken from the live repos and re-expressed with the extension seams and building blocks. The point is to show the value honestly — including where a seam migration is clean, where it is partial, where only the building blocks help, and where a seam retires a whole component.

Read this first — what "migration" means here. The heavy custom components below were written against the MibComponent / MibComponentWrapper<T> wrapper SDK (they override MapData(object)→object, MapSchema, Save(IMibRequest, ISaveManager), OnAfterSave). The seams live on the Core bases (FormComponent, SimpleRelatedListComponent, …). So an "after" is a re-platform — the class changes its base from the wrapper to the Core component — not a line-for-line edit. That is worth it for new components and for opportunistic cleanups; it is a deliberate choice for existing ones. Each case states how clean the fit is.

Code below is condensed and lightly anonymized from the real sources (paths given). Treat it as the shape, not a copy-paste.

Case Source Lines Fit
1. SubscriptionRelation tvopenplatform-mib3/.../CommercialOfferReact/SubscriptionRelation ~177 ✅ Clean — RelatedList + one save seam
2. MovieForm tvopenplatform-mib3/.../MovieReact/MovieForm ~269 🟡 Mostly — Form + validation/after-save seams; one caveat
3. DescriptionList CMSE/.../Custom/Pages/Content/DescriptionList… ~437 🔧 Blocks-only — stays custom; helpers cut the boilerplate
4. ImporterScheduler MibServer3/.../DmmLegacy/.../ImporterScheduler full custom ✅ Clean — List + OnFetchData retires the whole component

Case 1 — SubscriptionRelation → a clean seam

Source: tvopenplatform-mib3/src/Pages/GVP.Mib3.CommercialOfferReact/SubscriptionRelation/SubscriptionRelationComponent.cs (~177 lines).

What it does: a related list of subscription↔commercial-offer relations. The only non-standard behavior is one rule: reject a save that would create a second "Defines" relation for a subscription. Everything else — list render, schema, add/remove — is exactly what SimpleRelatedListComponent already does.

Before (wrapper SDK, condensed)

public class SubscriptionRelationComponent : MibComponentWrapper<SubscriptionRelationMibComponent> { }

[Component("SubscriptionRelation", "{DICT:…/TITLE}")]
public class SubscriptionRelationMibComponent : MibComponent
{
    // ctor with ISubscriptionCommercialOfferService …

    public override Task<object> MapData(object viewData, CancellationToken ct)
    {
        // permission gate, list-by-commercial-offer, project to ItemReactModel<…>  (~25 lines)
    }

    public override Task<object> MapSchema(object viewData, CancellationToken ct)
    {
        // RelatedListSchemaBuilder<…>.AddTitle().AddMediaType().CanAddNew().CanEdit().Build()  (~8 lines)
    }

    public override void Save(IMibRequest request, ISaveManager saveManager)
    {
        // THE RULE:
        foreach (var item in existingDefines)
            if (IsDuplicateDefines(item))
                throw new PersistenceValidationException(TemplateComponentKey, "{DICT:…/ALREADY_DEFINED}");

        // re-implement add/update/remove against saveManager  (~30 lines)
    }
    // + CheckRelationValidity / AddOrUpdateRelations / RemoveRelations / an [AjaxMethod]  (~60 lines)
}

After (Core subclass + one seam)

public sealed class SubscriptionRelationComponent : SimpleRelatedListComponent
{
    private readonly ISubscriptionCommercialOfferService _service;

    protected override void OnRelatedSaveReconciled(PersistenceComponentSaveRelatedEntitiesData saveData,
                                                    PostbackComponentRequest request)
    {
        var commercialOfferId = Context.IsCopy ? Context.CopiedFrom : Context.IDs.FirstOrDefault();
        foreach (var subscriptionId in saveData.AddedRelateds)
        {
            if (_service.WouldDuplicateDefines(subscriptionId, commercialOfferId))
                throw new PersistenceValidationException(TemplateComponentKey,
                    $"{{DICT:…/ALREADY_DEFINED}} SubscriptionId: {subscriptionId}");
        }
    }
}

Result: ~177 lines → ~15. The render, schema, and the entire add/remove reconciliation are inherited; the duplicate-Defines rule is the one thing left. The [AjaxMethod] live-check can stay as-is until the action pipeline is wired.

Fit: clean ✅. The component's persistence is a real related collection, so OnRelatedSaveReconciled gives exactly the right model (AddedRelateds).


Case 2 — MovieForm → mostly clean, with one honest caveat

Source: tvopenplatform-mib3/src/Pages/GVP.Mib3.MovieReact/MovieForm/MovieFormComponent.cs (~269 lines).

What it does: a movie edit form with (a) save-time validations (unknown type, game/online inconsistency, duplicated metadata language), (b) a Kafka publish after save, and (c) a parent series/season status cascade + a status-history write.

Before (wrapper SDK, condensed)

public class MovieFormComponent : MibComponentWrapper<MovieFormMibComponent> { }

[Component("MovieFormComponent", "{DICT:TITLES/GVP_MOVIES}")]
public class MovieFormMibComponent : MibComponent
{
    // 5 injected clients/builders …

    public override Task<object> MapData(object vd, CancellationToken ct) { /* movieClient.Get(includes…) → MovieModel  (~18 lines) */ }
    public override Task<object> MapSchema(object vd, CancellationToken ct) { /* FormSchemaBuilder<MovieModel>…Build()  (~8 lines) */ }

    public override void Save(IMibRequest request, ISaveManager saveManager)
    {
        var movieModel = request.GetValueByTextJson<MovieModel>();
        ExecuteMovieValidations(movieModel, saveManager);          // ← real logic
        foreach (var id in IDs) saveManager.Save(MediaTypeNames.Movie, id, movieToSave, etag: null);
    }

    private void ExecuteMovieValidations(…)
    {
        ValidateMovieType(…); ValidateIsGame(…);                   // throw PersistenceValidationException
        ValidateMetadataLanguages(saveManager, movieId);          // duplicate-language check
        UpdateParentsStatus(movie, saveManager);                  // writes SERIES + SEASON entities
        UpdateMovieStatusDate(movie, saveManager, newStatus);     // writes STATUS_DATE + a history entity
    }

    public override object OnAfterSave(List<PersistenceComponentResult> results)
    {
        // guarded by settings flags; group movies by source; Kafka produce in batches; try/catch → SCOM.Error  (~45 lines)
    }
}

After (Core subclass + seams + a post-save chain)

public sealed class MovieFormComponent : FormComponent
{
    private readonly IMovieClient _movies;
    private readonly IKafkaWrapper _kafka;
    private readonly ISettingsClient _settings;

    // (a) save-time validations → fail the write with a localized message
    protected override void OnFormSaveBuilt(PersistenceComponentSaveSingleEntityData saveData,
                                            PostbackComponentRequest request)
    {
        var movie = MovieModel.From(saveData.Fields);
        if (movie.Type.Id == (int)MovieType.Unknown)
            throw new PersistenceValidationException(TemplateComponentKey, "{DICT:…/UNKNOWN_TYPE}");
        if (!movie.IsGame && (movie.IsMultiplayer || movie.IsOnline))
            throw new PersistenceValidationException(TemplateComponentKey, "{DICT:…/MULTIPLAYER_INVALID}");
        if (HasDuplicateMetadataLanguage(saveData))
            throw new PersistenceValidationException(TemplateComponentKey, "{DICT:…/METADATA_DUPLICATED_LANGUAGE}");
    }

    // (b)+(c) after-persist side effects, guarded, aggregated to the worst status
    protected override void OnAfterSaveResponseBuilt(PostbackComponentResponse response,
                                                     List<PersistenceComponentResult> results)
    {
        if (response.Status == PostbackStatus.Error) return;

        var outcome = new PostSaveChain()
            .Add(ct => PublishKafkaAsync(results, ct))        // (b) the OnAfterSave body
            .Add(ct => CascadeParentStatusAsync(ct))          // (c) series/season + history
            .RunAsync(CancellationToken.None).GetAwaiter().GetResult();

        if (outcome.Status == PostSaveStatus.SuccessWithWarning)
            response.Status = PostbackStatus.SuccessWithWarning;   // matches the original's swallow-and-log
    }
}

Result: ~269 lines → ~60 of real logic (the validation predicates + the two side-effect bodies); fetch/schema/save plumbing is inherited. The Kafka guard-flags and try/catch become a PostSaveChain that aggregates to SuccessWithWarning — the same "never fail a committed save for a side-effect hiccup" behavior the original got from its try/catch → SCOM.Error.

Fit: mostly clean 🟡 — one honest caveat. In the original, the parent series/season status writes happen inside Save via saveManager (same postback). The single-entity OnFormSaveBuilt seam only hands you this movie's save model — it can't enqueue writes to other entities. So the cascade moves to after-persist (extra API writes via the injected client), which is a slightly different transactionality. If you need it in the same transaction, that is the multi-entity save case — still on the deferred list. Document the choice; don't pretend it's identical.


Case 3 — DescriptionList → the building blocks, not a seam

Source: CMSE/MiB/.../Custom/Pages/Content/DescriptionListMibComponent.cs (~437 lines).

What it does: edits a list of descriptions that are persisted as a JSON string array on a single MetadataField entity (one row per provider), with a provider-data drawer. Rows carry composite ids (array index + field id) so the UI can address an element inside the JSON array. The class is ~437 lines, of which ~114 are copy-pasted JsonElement/array parsing and id math.

Fit: blocks-only 🔧. This is the honest one: the persistence shape — N strings inside one entity's JSON column — is not a real related collection. SimpleRelatedListComponent's save model speaks AddedRelateds/RemovedRelateds as entity ids; it cannot express "insert at index 3 of this field's JSON array." So DescriptionList stays a custom component — the seams don't migrate it. What the toolbox does remove is the boilerplate:

Hand-rolled today Replace with
IdHelper.GenerateId(index, fieldId) / GetRealId / GetIndex CompositeIdCodec
FixjsonListString(...) + JsonSerializer.Deserialize<List<string>> everywhere JsonHelpers
manual Add / Update / Delete split over submitted vs db rows CrudReconciler.Diff / ReconcileAsync
GetProviderSchema / GetProviderData [AjaxMethod]s stay as-is (the action pipeline is 🚧 deferred)
// The composite-id + JSON parsing that repeats ~5× in the original collapses to:
private static readonly CompositeIdCodec Ids = new('-');

string[] Current(MetadataField field) =>
    JsonHelpers.GetValue<string[]>(JsonDocument.Parse(field.PreferredValue).RootElement, "$") ?? [];

// and the Add/Update/Delete split becomes one reconcile:
await CrudReconciler.ReconcileAsync(current: dbRows, edited: submittedRows, handler: this, keyOf: r => r.CompositeId, ct);

Result: the ~114 lines of JSON/id plumbing shrink to a handful of helper calls; the component stays custom but is materially smaller and consistent with every other component's id/JSON handling. This is the building blocks value, independent of the seams.


Case 4 — DMM Importer Scheduler → a self-fetch list (external data source)

Source: MibServer3/.../DmmLegacy/.../ImporterScheduler… — a full custom component whose rows are not in MibApi at all: they come from the external CWF job API (importer schedules). Verified live on a dev environment: the Importer Schedules page renders ~179 real CWF jobs through the stock list widget, with paging and Refresh intact — no custom widget.

What it does: list importer-scheduler jobs (id, status, created, next run) fetched from an external service, paged. There is no entity save — it is a read surface. Historically this had to be a full custom component, because the one thing it needs (fetch from somewhere other than MibApi) was exactly the thing the Core list could not do.

Before (wrapper SDK, condensed)

public class ImporterSchedulerComponent : MibComponentWrapper<ImporterSchedulerMibComponent> { }

[Component("ImporterScheduler", "{DICT:…/IMPORT_SCHEDULES}")]
public class ImporterSchedulerMibComponent : MibComponent
{
    private readonly ICwfJobsClient _jobs;   // external job API

    public override async Task<object> MapData(object vd, CancellationToken ct)
    {
        // read paging off the request by hand, call the external API, project to the
        // list react model, compute Total for the pager  (~30 lines)
        var page = await _jobs.ListAsync(ParsePage(vd), ParseLimit(vd), ct);
        return new ListReactModel { Items = page.Jobs.Select(Project).ToList(), Total = page.Total };
    }

    public override Task<object> MapSchema(object vd, CancellationToken ct)
    {
        // hand-build columns + the Filters/Configuration the widget needs (non-null!)  (~15 lines)
    }
    // + refresh/paging plumbing, the empty-state handling, etc.  (~40 lines)
}

After (Core subclass + OnFetchData)

public sealed class ImporterSchedulerComponent : ListComponent
{
    private readonly ICwfJobsClient _jobs;

    protected override bool ProvidesExternalData => true;   // swap the data source

    protected override void ContributeSchema(ListSchema schema) =>
        ListSchemaBuilder.For(schema)
            .AddColumn("ID", "Job")
            .AddColumn("STATUS", "Status")
            .AddColumn("CREATED", "Created")
            .AddColumn("NEXT_RUN", "Next run");

    protected override async Task<ListV2ViewData> OnFetchData(FetchContext ctx, CancellationToken ct)
    {
        // List threads paging + sort + filter into the context for you.
        var page = await _jobs.ListAsync(ctx.Page, ctx.Limit, ctx.Order, ctx.Filter, ct);
        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, ["NEXT_RUN"] = j.NextRun }
            }).ToList()
        };
    }
}

Result: a full custom component → ~25 lines. The base now owns everything the original hand-rolled: the render pipeline (GetRenderData/MapSchema/MapData all flag-guarded for self-fetch), the non-null Filters/Configuration schema contract the React widget requires, paging, and the Refresh button — all routing back to your one OnFetchData. You write the fetch and the columns; nothing else.

Fit: clean ✅ for a List. A top-level List threads Page/Limit/Order/Filter into FetchContext, so server-side paging, sort, and search all reach OnFetchData. Caveat for related/ordered self-fetch: the same seam exists on SimpleRelatedListComponent/OrderedRelatedListComponent, but there Order is never carried and the Refresh button sends no filter (only Page/Limit and a structured FilterCondition on the async-data endpoint) — see §9. For a sortable/refresh-filterable external list, use a List.


What the spectrum tells you

Clean seam (Case 1) Mostly clean (Case 2) Blocks-only (Case 3) Self-fetch (Case 4)
Base change wrapper → Core subclass wrapper → Core subclass stays custom wrapper → Core subclass
Seams used OnRelatedSaveReconciled OnFormSaveBuilt, OnAfterSaveResponseBuilt none OnFetchData + ContributeSchema
Blocks used PostSaveChain CompositeIdCodec, JsonHelpers, CrudReconciler ListSchemaBuilder
~Lines 177 → 15 269 → 60 437 → ~320 (boilerplate removed) full custom → ~25
Caveat none multi-entity cascade → after-persist persistence shape isn't a Core relation sort/refresh-filter reach OnFetchData on List only

Rule of thumb. Reach for a Core subclass + seam when the component's data and persistence already match a Core archetype and you only need a small behavioral twist (Cases 1–2). When the data shape is bespoke (Case 3), keep the custom component but use the building blocks to delete the plumbing. New components should start from the Core base by default.

Surveyed set. The four cases above are drawn from a full sweep of the two production custom-component codebases — ~17 behavior-overriding components in CMSE and ~217 in tvopenplatform-mib3 (~234 total). The recurring cost was 40–150 lines of real logic wrapped in 100–200 lines of identical plumbing — dominated by a permission-gate that opens nearly every Save/MapData, a …ValidationComponent family (~35 classes that just throw PersistenceValidationException), related-list reconcile, external-data reads, and after-save side-effects (Kafka / external sync / audit). The Samples & Use Cases page maps each of those real patterns to the seam that absorbs it, with the actual component names and paths. Cases 1–2 retire most of the plumbing via seams; the CMSE JSON-array family (Case 3) retires the ~110-line parsing/id block via the building blocks while remaining custom; Case 4 retires a whole component.


Related docs. Extending Core Components — the seam catalogue and the error/resilience contract. Extension Building Blocks — every helper used above. Authoring Guide — how a custom class is wired and loaded.