Table of Contents

Samples & Use Cases

Complete, realistic components for real needs — the kind you copy as a starting point. This page is deliberately different from its two neighbours:

Page What it gives you
Seam Reference → Recipes minimal single-seam snippets (one task, a few lines)
Migration Cookbook before/after re-platforms of four real customer components
This page whole, realistic components — often combining several seams — with the config wiring, what the operator sees, and a test

Every sample compiles against the real 7.0 contracts (see the Seam Reference for exact types). Names are illustrative; the shapes are real.


Grounded in the real codebases

Every use case and sample on this page is mined from the two production custom-component codebases:

  • CMSE…/Agile.Platform.Cms.Frontend.Components/Custom/Pages/… (~17 behavior-overriding custom components)
  • tvopenplatform-mib3src/Pages/… (~217 behavior-overriding custom components)

The tables below name real components (repo · path) for each seam, so you can open the original alongside the seam version. Paths are repo-relative.

The #1 finding — permission-gate boilerplate. Nearly every custom component opens Save/MapData with a Permissions.Get(...) → early-return gate — it is universal across all 17 Voucher components and the large majority of the others. That gate is exactly what the inherited permission computation + OnPermissionResolved remove. It is the single highest-volume thing the seams delete.

External data source — OnFetchData / ProvidesExternalData

Rows that come from a service/store, not the MibApi entity:

Real component Repo · path What it really does
UserFormComponent mib3 · GVP.Mib3.UsersReact/UserForm edits a GVP user through IUserService, not the MibApi DAO
UserCrmHistoryComponent mib3 · GVP.Mib3.UsersReact/UserCrmHistory reads the DocumentStorage CRM log (LogUserCrm, limit 100)
SubscriptionUserComponent mib3 · GVP.Mib3.SubscriptionsReact/SubscriptionUsers live active-subscriber count via external UserApi
EmailResendingComponent mib3 · GVP.Mib3.UsersReact/EmailResending lists a user's sent mail from ProvisionApi
MoviePurgeListComponent mib3 · MoviePurge/MoviePurgeList paged external GVP purge list with server-side search
DrmProtectionComponent mib3 · CdnLives/DrmProtection DRM key profiles deserialized from external JSON
UxTargetTreeViewComponent mib3 · …UxTargetsReact UxTargetTree a UX-target inheritance tree resolved by the shared inheritance engine
provider drawers CMSE · Content/{DescriptionList,GenreList,CastList,ImagesList} overlay live provider-API data on the stored DB rows

(~14 in the GVP React set + the CMSE provider-drawer family.)

Save-time validation / generated field — OnFormSaveBuilt

Reject a write with a message, or stamp a derived value:

Real component Repo · path What it really does
CodeGeneratorComponent mib3 · Vouchers/CodeGenerator generates a unique 6-digit voucher code (excludes active overlaps)
BPointComponent mib3 · CdnLives/BPoint DB-driven regex validation + generates a CDN token via the external OrchAuth service, injecting upt= into the URL
DiscountTypeComponent / VoucherDiscountComponent mib3 · Vouchers/DiscountType, VouchersProducts/VoucherDiscount percentage(0–100)-vs-fixed-positive validation (the same rule duplicated across two domains)
VoucherCodeComponent mib3 · VouchersProducts/VoucherCode start≤end + non-empty + no active-overlap validation
…ValidationComponent (×35) mib3 · */.../*Validation the dominant family — field/cross-field rules that throw PersistenceValidationException

After-persist side effect — OnAfterSaveResponseBuilt

Kafka, external sync, cascade, audit — guarded so a hiccup never fails a committed save:

Real component Repo · path What it really does
MovieFormComponent mib3 · GVP.Mib3.MovieReact/MovieForm batched Kafka events per source (created vs updated topic), flag-gated by EnableMib3KafkaEvents
XpvrComponent mib3 · GVP.Mib3.LiveChannelsReact/Xpvr two transition-detected Kafka events (channel-deactivated, CPVR-disabled)
SubscriptionValidation mib3 · GVP.Mib3.SubscriptionsReact/SubscriptionsValidations Kafka events for removed CPVR live channels (a removed-relation diff)
PurchaseTypeCriteriaComponent mib3 · VouchersReact/.../PurchaseTypeCriteria stashes the model, then on the created id calls an external Purchase API
SubscriptionSyncComponent mib3 · Subscriptions/SubscriptionSync resyncs subscription grants to an external Task Manager
BiUserCrmComponent mib3 · Users/BiUserCrm before/after snapshot diff → CRM audit documents to DocumentStorage
CatchUpPodSettingsComponent mib3 · …LiveChannelsReact/CatchUpPodSettings marks the channel to re-sync only if a field actually changed
UserRightsComponent mib3 · GVP.Mib3.UsersReact/UserRights bumps the user timestamp when a child save reports NoOperation/SuccessNoContent

Relation reconcile rule — OnRelatedSaveReconciled

A rule on the reconciled add/remove/order set:

Real component Repo · path What it really does
SubscriptionRelationComponent mib3 · GVP.Mib3.CommercialOfferReact/SubscriptionRelation enforces one Defines relation per subscription across all offers
MoviePricingModelComponent mib3 · GVP.Mib3.MovieReact/PricingModel composite-key dedupe + orphan instance-price cleanup + 4 bulk-edit modes
RedemptionSelectionComponent mib3 · VouchersProductsReact/.../RedemptionSelection delete-guard (block removing a purchased row) + counter resequencing
DeviceAvailabilityComponent mib3 · Vouchers/DeviceAvailability set-diff of a checkbox grid against existing rows
NowOnTvChannelComponent mib3 · GVP.Mib3.ChannelsReact/ChannelNowOnTvChannels ordered reconcile with re-indexing
GrcMappingsComponent / DisablersComponent mib3 · BlackoutsReact/GrcsMapping, GVP.Mib3.CxSettings/Disablers canonical diff/delete/upsert reconcile

Visibility — ShouldHideComponent

Real component Repo · path Hides when…
SubscriptionUserComponent mib3 · …/SubscriptionUsers the parent is new / bulk / copy
XpvrComponent mib3 · …LiveChannelsReact/Xpvr the user lacks XPVR read permission
CdnDeliveryJobsComponent mib3 · GVP.Mib3.MediaFilesReact/CdnDeliveryJobs the media is CDN-ignored (data-derived)
DmmCwfComponent mib3 · GVP.Mib3.MovieReact/DmmCwf the movie's commercialization type isn't in a config allow-list

Permission / capability gating — OnPermissionResolved

Real component Repo · path Capability rule
UserPinsComponent mib3 · GVP.Mib3.UsersReact/UserPins obfuscates PINs and switches to a no-value schema unless UserPinsAccess
DrmProtectionComponent mib3 · CdnLives/DrmProtection obfuscates DRM key values unless DrmProtectionEditorKeysAccess
LabelsComponent mib3 · GVP.Mib3.ContentsReact/ContentLabels filters rows by per-source read permission
OperationalTasksComponent mib3 · …LiveChannelsReact/OperationalTasks panel visible only to the Administrator group

Computed value / schema tweak — TransformData · ContributeSchema

Real component Repo · path What it really does
UserHistoryComponent mib3 · GVP.Mib3.UsersReact/UserHistory derives a display purchase-status string from status × active × product type
UsersCriteriaComponent mib3 · Vouchers/UsersCriteria UTC ↔ user-timezone conversion on read/write
ContentRelatedListMibComponent CMSE · ImageEdit/ContentRelatedListMibComponent toggles add/edit/limit capabilities purely from config keys
UxTargetsOverridesComponent mib3 · …UxTargetsReact/UxTargetsOverrides permission-conditional schema (editable only with write)

Honestly blocks-only (no clean seam — use the building blocks, stay custom)

Real component Repo · path Why no seam fits
DescriptionListMibComponent / GenreListComponent CMSE · Content/… N strings inside one metadata_field JSON array → rows by composite id (CompositeIdCodec + JsonHelpers + CrudReconciler)
SchedulerComponent CMSE · Jobs/SchedulerComponent DB ↔ AWS scheduler state machine (EventBridge create/update/delete by trigger transition)
TimeshiftValidationComponent mib3 · …SubscriptionsReact/TimeshiftValidation maintains a shadow/clone "timeshift" subscription entirely on save
JobsWorkflowComponent mib3 · CustomComponents/JobWorkflowReact a config-keyed strategy dispatch over the whole lifecycle
UsersCriteriaUploadComponent / MultipleCopyComponent mib3 · Vouchers… base64-CSV ingestion / bulk N-copy clone — bespoke actions
VoucherValidationComponent mib3 · VouchersProducts/VoucherValidation reaches into a sibling component's reconciled save data (cross-component)

Use-case catalogue

"I want to…" → the seam(s) → a sample below (and see the real components above). Broader than the Which seam do I need? table — these are concrete product scenarios.

Lists

Use case Seam(s) Sample
Show rows from an external service (jobs, billing, a 3rd-party catalogue) OnFetchData + ContributeSchema 1
Server-side default filter the operator can't remove (tenant/status scoping) OnSearchCriteriaBuilt 6
Add a computed/derived column (SLA, availability, age) TransformData + ContributeSchema 1
Mask / redact a column for users without a capability (PII, secrets) TransformData + OnPermissionResolved 1
Force a whole list read-only for a role OnPermissionResolved 6
Hide a list panel when a feature flag is off ShouldHideComponent 3
Re-label / hide / reorder columns per customer ContributeSchema (ListSchemaBuilder) 6

Forms

Use case Seam(s) Sample
Reject a save with a clean localized message (business rule) OnFormSaveBuiltPersistenceValidationException 2, 7
Cross-field / multi-rule validation with ordering OnFormSaveBuilt + ValidationRuleRegistry 7
Stamp a generated/derived field on save (slug, checksum, normalized value) OnFormSaveBuilt 2
Publish to Kafka / sync to another system after save OnAfterSaveResponseBuilt (+ PostSaveChain) 2
Cascade a status change to a parent entity after save OnAfterSaveResponseBuilt 2
Make a field read-only / change its input variant ContributeSchema (FormSchemaBuilder) 6
Revoke create/delete for users without a capability OnPermissionResolved 5
Use case Seam(s) Sample
Reject / dedupe a relation on save (no second "primary", no duplicate) OnRelatedSaveReconciled 3
Enforce a cap on the number of related items OnRelatedSaveReconciled 4
Clean up external storage when relations are deleted OnAfterDeleteResponseBuilt 4
Computed column on related rows TransformData + ContributeSchema 3
Hide the related panel by entity state ShouldHideComponent 3
Self-fetch related rows from an external source OnFetchData §9

Content criteria

Use case Seam(s) Sample
Gate editing behind a capability OnPermissionResolved 5
Add a computed/preview value to the rendered selection TransformData 5

Complete samples

Each sample is the entire custom class plus its wiring — everything else is inherited. Wire every one the same way (the type-key stays the stock widget):

Config key Value
ASSEMBLY_NAME your assembly, e.g. Acme.Cms.Components
CLASS_NAME the fully-qualified class, e.g. Acme.Cms.Components.JobsListComponent
(type-key) unchanged — list / form / relatedlist / orderedrelatedlist / contentcriteria

Full SQL is in the Authoring Guide → Database wiring.


Sample 1 — External-data list with a computed column and masked PII

Scenario. An Import Jobs page whose rows live in an external scheduler API (not MibApi). Show id/status/created, plus a computed "Age" column, and mask the submitter's email for operators without the pii.view capability. Server-side paging, sort and search must work. (List combines OnFetchData + ContributeSchema + TransformData + OnPermissionResolved.)

Modeled on real components: external-data lists like MoviePurgeListComponent (mib3 · MoviePurge/MoviePurgeList) and UserCrmHistoryComponent (mib3 · GVP.Mib3.UsersReact/UserCrmHistory); the capability-gated masking is exactly what UserPinsComponent and DrmProtectionComponent do; the derived column mirrors UserHistoryComponent's computed purchase-status.

public sealed class JobsListComponent : ListComponent
{
    private readonly IJobsApi _jobs;
    public JobsListComponent(IJobsApi jobs) => _jobs = jobs;

    protected override bool ProvidesExternalData => true;

    // columns — declare what the computed/fetched fields render as
    protected override void ContributeSchema(ListSchema schema) =>
        ListSchemaBuilder.For(schema)
            .AddColumn("ID", "Job")
            .AddColumn("STATUS", "Status")
            .AddColumn("CREATED", "Created", c => c.Sorter = true)
            .AddColumn("AGE", "Age")            // computed in TransformData
            .AddColumn("SUBMITTER", "Submitter");

    // data — fetch the requested page from the external service (List threads paging/sort/filter)
    protected override async Task<ListV2ViewData> OnFetchData(FetchContext ctx, CancellationToken ct)
    {
        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.ToString("o"),
                    ["SUBMITTER"] = j.SubmitterEmail,
                }
            }).ToList()
        };
    }

    // derive + mask on the rows the user actually sees (runs on initial render AND every self-fetch)
    protected override void TransformData(ListV2ViewData data, ListViewData viewData)
    {
        var canSeePii = Context.User.HasCapability("pii.view");
        foreach (var item in data.Items)
        {
            var created = DateTime.Parse((string)item.Fields["CREATED"]);
            item.Fields["AGE"] = $"{(DateTime.UtcNow - created).Days}d";
            if (!canSeePii) item.Fields["SUBMITTER"] = "•••";
        }
    }
}

Operator sees: the stock list widget — sortable/searchable/paged — with a live Age column and the submitter masked unless they hold pii.view. No custom React, no MibApi entity behind it.

Why TransformData and not just OnFetchData? TransformData also runs on the live self-fetch path, so the mask/derive can't be bypassed once the widget re-fetches. See the render-pipeline note.


Sample 2 — Movie form: validate, stamp a slug, publish, cascade

Scenario. A movie edit form that (a) rejects an unknown type with a clean message, (b) stamps a slug derived from the title, (c) publishes a Kafka event after save, and (d) cascades a status change to the parent series — without ever failing a committed save because a side-effect hiccuped. (Form combines OnFormSaveBuilt + OnAfterSaveResponseBuilt + PostSaveChain.)

Modeled on the real MovieFormComponent (mib3 · GVP.Mib3.MovieReact/MovieForm): it really does run the type/game/duplicated-language validations, cascade the parent series/season status with a workflow-history write, and publish batched Kafka events per source (created vs updated topic) gated by EnableMib3KafkaEvents. XpvrComponent and SubscriptionValidation use the same after-save Kafka pattern.

public sealed class MovieFormComponent : FormComponent
{
    private readonly IKafkaWrapper _kafka;
    private readonly ISeriesClient _series;
    public MovieFormComponent(IKafkaWrapper kafka, ISeriesClient series) { _kafka = kafka; _series = series; }

    // (a)+(b) before persist: reject bad input, stamp derived fields
    protected override void OnFormSaveBuilt(PersistenceComponentSaveSingleEntityData saveData,
                                            PostbackComponentRequest request)
    {
        var title = saveData.Fields.GetValueOrDefault("TITLE") as string;
        var typeId = Convert.ToInt32(saveData.Fields.GetValueOrDefault("TYPE") ?? 0);

        if (typeId == 0)
            throw new PersistenceValidationException(TemplateComponentKey, "{DICT:ERROR/UNKNOWN_TYPE}");
        if (string.IsNullOrWhiteSpace(title))
            throw new PersistenceValidationException(TemplateComponentKey, "{DICT:ERROR/TITLE_REQUIRED}");

        saveData.Fields["SLUG"] = Slugify(title);   // generated, not entered
    }

    // (c)+(d) after persist: guarded side effects, aggregated to the worst status
    protected override void OnAfterSaveResponseBuilt(PostbackComponentResponse response,
                                                     List<PersistenceComponentResult> results)
    {
        if (response.Status == PostbackStatus.Error) return;   // never act on a failed save
        var movieId = Context.IDs.First();

        var outcome = new PostSaveChain()
            .Add(async ct => { await _kafka.PublishMovieChangedAsync(movieId, ct); return PostSaveResult.Success(); })
            .Add(async ct =>
            {
                var ok = await _series.RefreshStatusForMemberAsync(movieId, ct);
                return ok ? PostSaveResult.Success() : PostSaveResult.Warning("series status not refreshed");
            })
            .RunAsync(CancellationToken.None).GetAwaiter().GetResult();

        if (outcome.Status == PostSaveStatus.SuccessWithWarning)
            response.Status = PostbackStatus.SuccessWithWarning;   // soft warning; the movie is saved
    }

    private static string Slugify(string s) => new string(s.ToLowerInvariant()
        .Select(c => char.IsLetterOrDigit(c) ? c : '-').ToArray());
}

Operator sees: a normal save. Bad type → a clean localized error, nothing written. Good save → the slug is set, the event fires, the series re-evaluates; if the series refresh hiccups they get a saved-with-warning, not a failure.


Sample 3 — Subscription relations: dedupe rule, computed column, hide-by-flag

Scenario. A related list of subscription↔offer relations that (a) rejects a duplicate "Defines" relation, (b) shows a computed "Active?" column, and (c) hides itself entirely when the OFFERS_ENABLED flag is off. (SimpleRelatedList combines OnRelatedSaveReconciled + TransformData + ContributeSchema + ShouldHideComponent.)

Modeled on the real SubscriptionRelationComponent (mib3 · GVP.Mib3.CommercialOfferReact/SubscriptionRelation), whose actual rule is exactly this: a subscription may be Defines-related to only one commercial offer, validated on save across all offers. SubscriptionRelationshipsComponent enforces a similar typed-relation rule (rejecting TimeShift subscriptions as relations).

public sealed class SubscriptionRelationComponent : SimpleRelatedListComponent
{
    private readonly ISubscriptionService _svc;
    public SubscriptionRelationComponent(ISubscriptionService svc) => _svc = svc;

    public override Task<bool> ShouldHideComponent(ComponentContext context, CancellationToken ct)
        => Task.FromResult(Configuration.GetFlag("OFFERS_ENABLED") == false);

    protected override void ContributeSchema(RelatedListSchema schema) =>
        ListSchemaBuilder.For(schema).AddColumn("ACTIVE", "Active?");

    protected override void TransformData(ListV2ViewData data, ListViewData viewData)
    {
        foreach (var item in data.Items)
            item.Fields["ACTIVE"] = _svc.IsActive(item.Id) ? "Yes" : "No";
    }

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

Operator sees: the relation table with an Active? column; adding a duplicate "Defines" is blocked with a localized message; the whole tab disappears where offers are disabled.


Sample 4 — Ordered playlist: enforce a cap, clean up on delete

Scenario. An ordered list of playlist entries that (a) rejects a save that would exceed 50 items, and (b) deletes the rendered thumbnails from blob storage after entries are removed. (OrderedRelatedList combines OnRelatedSaveReconciled + OnAfterDeleteResponseBuilt.)

Modeled on real ordered-reconcile components: NowOnTvChannelComponent and MenusComponent (ordered re-indexing on save); the cap + orphan-cleanup mirrors MoviePricingModelComponent's composite-key dedupe and orphan instance-price deletion; the after-delete external cleanup follows the guarded side-effect rule.

public sealed class PlaylistComponent : OrderedRelatedListComponent
{
    private const int MaxItems = 50;
    private readonly IBlobStore _blobs;
    public PlaylistComponent(IBlobStore blobs) => _blobs = blobs;

    protected override void OnRelatedSaveReconciled(PersistenceComponentSaveRelatedEntitiesData saveData,
                                                    PostbackComponentRequest request)
    {
        // OrderedRelateds carries the final ordered set; cap the total.
        if (saveData.OrderedRelateds.Count > MaxItems)
            throw new PersistenceValidationException(TemplateComponentKey, "{DICT:ERROR/PLAYLIST_TOO_LONG}");
    }

    protected override void OnAfterDeleteResponseBuilt(PostbackComponentResponse response,
                                                       List<PersistenceComponentResult> results)
    {
        if (response.Status == PostbackStatus.Error) return;
        try { _blobs.DeleteThumbnails(Context.IDs); }   // best-effort external cleanup
        catch (Exception ex) { Context.MibLog?.Exception(ex); }   // never fail the committed delete
    }
}

Operator sees: drag-and-drop ordering as usual; trying to save a 51-item playlist is blocked; removing entries also tidies up their external thumbnails.


Sample 5 — Content criteria: capability-gated edit + computed preview

Scenario. A content-criteria selector that is read-only for operators without criteria.edit, and that surfaces a computed value on the rendered selection. (ContentCriteria combines OnPermissionResolved + TransformData.)

Modeled on real capability-gating: UserPinsComponent (UserPinsAccess) and DrmProtectionComponent (DrmProtectionEditorKeysAccess) both gate write/visibility on a custom capability exactly this way; LabelsComponent gates per source.

public sealed class CuratedListCriteriaComponent : ContentCriteriaComponent
{
    protected override void OnPermissionResolved(ComponentPermission permission,
                                                 List<ResponseItemContent> responses)
    {
        if (!Context.User.HasCapability("criteria.edit"))
        {
            permission.Write  = PermissionType.Deny;   // Deny always wins
            permission.Create = PermissionType.Deny;
            permission.Delete = PermissionType.Deny;
        }
    }

    protected override void TransformData(ContentCriteriaV2ViewData data, ContentCriteriaViewData viewData)
    {
        // e.g. annotate the operation so the widget can show "manual + rule"
        if (data.Highlights?.Any() == true && data.Groups?.Any() == true)
            data.Operation = $"{data.Operation} (+{data.Highlights.Count()} pinned)";
    }
}

Operator sees: the criteria builder renders normally but the Save/affordances are gone without the capability; the header reflects pinned highlights.


Sample 6 — Read-only audit list: forced filter, relabeled columns, role lock

Scenario. An audit-log list that (a) is always scoped to the current tenant regardless of what the operator types, (b) relabels and reorders columns, and (c) is read-only for everyone (it's a log). (List combines OnSearchCriteriaBuilt + ContributeSchema + OnPermissionResolved.)

Modeled on real components: the forced server-side scope mirrors the Configuration.FilterGlobalFilter shaping; the role lock follows OperationalTasksComponent's admin-group gate; per-source read filtering follows LabelsComponent (mib3 · GVP.Mib3.ContentsReact/ContentLabels).

public sealed class AuditListComponent : ListComponent
{
    protected override void OnSearchCriteriaBuilt(SearchCriteria criteria)
    {
        var tenant = Context.User.TenantId;
        criteria.GlobalFilter = string.IsNullOrEmpty(criteria.GlobalFilter)
            ? $"TENANT={tenant}"
            : $"({criteria.GlobalFilter}) AND TENANT={tenant}";   // non-removable scope
        if (string.IsNullOrEmpty(criteria.Order)) criteria.Order = "WHEN,desc";
    }

    protected override void ContributeSchema(ListSchema schema) =>
        ListSchemaBuilder.For(schema)
            .Override("WHEN", c => { c.Title = "Timestamp"; c.Sorter = true; })
            .SetOrder("WHEN", 0)
            .Hide("INTERNAL_TRACE");

    protected override void OnPermissionResolved(ComponentPermission permission,
                                                 List<ResponseItemContent> responses)
    {
        permission.Create = PermissionType.Deny;   // a log is never edited
        permission.Write  = PermissionType.Deny;
        permission.Delete = PermissionType.Deny;
    }
}

Operator sees: a clean, timestamp-first audit list they can only read, always scoped to their tenant even if they try to search across tenants.


Sample 7 — Form with an ordered multi-rule validation registry

Scenario. A subscription form whose save must pass several validations, some depending on others (don't run the price check until the currency is valid), collecting all failures rather than stopping at the first. (Form combines OnFormSaveBuilt + ValidationRuleRegistry.)

Modeled on the largest real family: ~35 …ValidationComponent classes in tvopenplatform-mib3 (e.g. Subscriptions/SubscriptionValidation, LiveChannels/Validations, Movie/MovieValidation) are exactly this — save-time rule batteries that throw PersistenceValidationException. DiscountTypeComponent and VoucherCodeComponent show the range/uniqueness rule shape. The registry turns the hand-ordered if-throw chains into declarative, dependency-ordered rules.

public sealed class SubscriptionFormComponent : FormComponent
{
    protected override void OnFormSaveBuilt(PersistenceComponentSaveSingleEntityData saveData,
                                            PostbackComponentRequest request)
    {
        var errors = new ValidationRuleRegistry<PersistenceComponentSaveSingleEntityData>()
            .Add("currency", (m, e, ct) =>
            {
                if (!IsKnownCurrency(m.Fields.GetValueOrDefault("CURRENCY") as string))
                    e.Add("Unknown currency", field: "CURRENCY", code: "CURRENCY_UNKNOWN");
                return Task.CompletedTask;
            })
            .Add("price", (m, e, ct) =>
            {
                if (Convert.ToDecimal(m.Fields.GetValueOrDefault("PRICE") ?? 0) <= 0)
                    e.Add("Price must be positive", field: "PRICE", code: "PRICE_INVALID");
                return Task.CompletedTask;
            }, dependsOn: "currency")   // skipped if currency failed
            .RunAsync(saveData, CancellationToken.None).GetAwaiter().GetResult();

        if (errors.HasErrors)
            throw new PersistenceValidationException(TemplateComponentKey,
                $"{{DICT:ERROR/VALIDATION}} {string.Join("; ", errors.Items.Select(i => i.Message))}");
    }
}

Operator sees: one save attempt surfaces every applicable problem at once, in a stable order, with the dependent price check skipped when the currency is wrong.


Testing the samples

Seams are tiny isolated methods — test your logic directly with a Probe that exposes the protected seam. (Mirrors the framework's own seam test suite.)

A render/transform seam (no collaborators needed):

private sealed class Probe : JobsListComponent
{
    public Probe() : base(new FakeJobsApi()) { }
    public void Run(ListV2ViewData d) => TransformData(d, new ListViewData());
}

[Test]
public void Age_Is_Derived_And_Email_Masked_Without_Capability()
{
    var c = new Probe { Context = new ContextViewData { User = UserWithout("pii.view") } };
    var data = new ListV2ViewData(new ListViewData()) {
        Items = { new ItemV2ViewData { Id = 1, Fields = new() {
            ["CREATED"] = DateTime.UtcNow.AddDays(-3).ToString("o"), ["SUBMITTER"] = "a@b.com" } } } };

    c.Run(data);

    Assert.That(data.Items[0].Fields["AGE"], Is.EqualTo("3d"));
    Assert.That(data.Items[0].Fields["SUBMITTER"], Is.EqualTo("•••"));
}

A before-persist save seam (assert it rejects / mutates the model):

private sealed class Probe : MovieFormComponent
{
    public Probe() : base(new NoopKafka(), new NoopSeries()) { }
    public void Run(PersistenceComponentSaveSingleEntityData d) => OnFormSaveBuilt(d, new PostbackComponentRequest());
}

[Test]
public void UnknownType_Is_Rejected()
{
    var d = new PersistenceComponentSaveSingleEntityData { Fields = new() { ["TITLE"] = "X", ["TYPE"] = 0 } };
    Assert.Throws<PersistenceValidationException>(() => new Probe().Run(d));
}

[Test]
public void Slug_Is_Stamped_From_Title()
{
    var d = new PersistenceComponentSaveSingleEntityData { Fields = new() { ["TITLE"] = "My Movie!", ["TYPE"] = 2 } };
    new Probe().Run(d);
    Assert.That(d.Fields["SLUG"], Is.EqualTo("my-movie-"));
}

For a seam you want exercised through the real Save/OnAfterSave, inject the collaborators with NSubstitute and drive the public method — see the framework's RelatedSaveSeamTests / OnFetchDataSeamTests for the pattern, and the Testing your override section.


Related docs. Extending Core Components — the seam catalogue, the pipeline, the resilience contract. Seam Reference — exact signatures, the type glossary, and minimal recipes. Migration Cookbook — before/after re-platforms of four real components. Troubleshooting — when an override misbehaves.