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 overrideMapData(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…ValidationComponentfamily (~35 classes that justthrow 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.