Connect an external product or artwork workflow without duplicating state

Use named workflow records and explicit state transitions to coordinate artwork approval, concurrent edits, audit history, and provider notifications.

MigrationPatternAdvancedGCG engineering guide
In this guide
  1. Product owner
  2. Artwork provider
  3. Project-owned adapter
  4. Customer-safe storefront projection
  5. Order snapshot
  6. Audited state history
A data-ownership map keeps authoritative artwork state with its owner while the storefront and order hold controlled projections and stable references.

Begin with ownership, not synchronization

Artwork workflows create duplicate-state problems when a commerce platform, product system, and external design provider each store a version, preview, approval status, and customer reference. The resulting records can look consistent until a buyer edits an item after a product feed changes. The first design task is to state which system owns each fact and which systems hold a derived projection.

A useful ownership table includes product identity, sellable configuration, artwork asset, customer approval, preview URL, production status, and archival policy. A downstream system should reference the owner through a stable external identifier instead of copying an uncontrolled mutable record. A preview is not necessarily an approved production asset.

This implementation is the modern .NET 8 target of the migration. During coexistence, a Znode 9 Framework 4.8 application communicates with that separately hosted service through its authenticated API. Keep the net8.0 project and its modern API dependencies outside the legacy application process.

  • Product catalog owner
  • Artwork and asset owner
  • Customer approval owner
  • Order snapshot owner
  • Audit and retention owner

Model lifecycle transitions explicitly

ArtworkScope identifies the authenticated tenant, account, and actor. TransitionRequest identifies the artwork, the state and version last read, and the requested next state. ArtworkTransitionService maps that transition to a required permission, then creates a TransitionCommit for the store.

The policy permits draft submission, provider preview or rejection, reviewer approval or rejection, return from rejected to draft, and archiving of an approved or rejected item. Archived has no outgoing transition. These are the example application's rules; map the permissions to the roles and ownership rules of your workflow.

Implement IAtomicArtworkStore in the project's transactional database. In one transaction it must authorize the actor, match artwork ownership plus expected state and version, update state, increment version, and add the audit and notification records. Keep the immutable artwork asset revision separate from this workflow version.

ArtworkTransitionService.cs csharp

using System;
using System.Threading;
using System.Threading.Tasks;

namespace Gcg.Examples.Artwork;

public enum ArtworkState
{
    Draft, Submitted, PreviewReady, Approved, Rejected, Archived
}
public enum ArtworkPermission
{
    Submit, ReportPreview, Review, Archive
}
public enum TransitionResult
{
    Applied, Conflict, NotFoundOrDenied, InvalidTransition
}
public sealed record ArtworkScope(Guid TenantId, Guid AccountId, Guid ActorId);
public sealed record TransitionRequest(
    Guid ArtworkId, long ExpectedVersion, ArtworkState ExpectedState, ArtworkState NextState);
public sealed record TransitionCommit(
    ArtworkScope Scope, TransitionRequest Request,
    ArtworkPermission RequiredPermission, DateTimeOffset OccurredAt);

public interface IAtomicArtworkStore
{
    // In ONE project-database transaction:
    // 1. Authorize ActorId for this tenant/account/artwork and required permission.
    // 2. Match artwork ID, tenant, account, ExpectedVersion AND ExpectedState.
    // 3. Update state, increment version, append actor/from/to audit and outbox rows.
    // Commit all changes together; zero matches must cause no audit or side effect.
    // Use durable row locks or a conditional UPDATE, not a process-local lock.
    Task<TransitionResult> TryApplyAsync(TransitionCommit commit, CancellationToken ct);
}

public sealed class ArtworkTransitionService
{
    private readonly IAtomicArtworkStore store;
    private readonly TimeProvider clock;

    public ArtworkTransitionService(IAtomicArtworkStore store, TimeProvider clock)
    {
        this.store = store ?? throw new ArgumentNullException(nameof(store));
        this.clock = clock ?? throw new ArgumentNullException(nameof(clock));
    }

    // Scope is resolved by authenticated server middleware, never from request JSON.
    public Task<TransitionResult> MoveAsync(
        ArtworkScope scope, TransitionRequest request, CancellationToken ct)
    {
        ArgumentNullException.ThrowIfNull(scope);
        ArgumentNullException.ThrowIfNull(request);
        ct.ThrowIfCancellationRequested();
        if (scope.TenantId == Guid.Empty || scope.AccountId == Guid.Empty ||
            scope.ActorId == Guid.Empty || request.ArtworkId == Guid.Empty ||
            request.ExpectedVersion < 0 || request.ExpectedVersion == long.MaxValue)
            throw new ArgumentException("A valid scope, artwork ID and version are required.");

        ArtworkPermission? permission = request.ExpectedState switch
        {
            ArtworkState.Draft when request.NextState == ArtworkState.Submitted
                => ArtworkPermission.Submit,
            ArtworkState.Submitted when request.NextState == ArtworkState.PreviewReady ||
                                        request.NextState == ArtworkState.Rejected
                => ArtworkPermission.ReportPreview,
            ArtworkState.PreviewReady when request.NextState == ArtworkState.Approved ||
                                           request.NextState == ArtworkState.Rejected
                => ArtworkPermission.Review,
            ArtworkState.Rejected when request.NextState == ArtworkState.Draft
                => ArtworkPermission.Submit,
            ArtworkState.Approved when request.NextState == ArtworkState.Archived
                => ArtworkPermission.Archive,
            ArtworkState.Rejected when request.NextState == ArtworkState.Archived
                => ArtworkPermission.Archive,
            _ => null
        };
        if (permission is null)
            return Task.FromResult(TransitionResult.InvalidTransition);

        return store.TryApplyAsync(
            new TransitionCommit(scope, request, permission.Value, clock.GetUtcNow()), ct);
    }
}

A request from PreviewReady version 4 to Approved requires Review permission. If another reviewer already advanced version 4, the store returns Conflict and records no second approval. If version 4 still matches and permission remains valid, it commits one state change, one audit entry, and one outbound notification. The application can then show the updated workflow version while an order retains the approved asset revision it accepted.

Save the notification with the workflow change

An outbox is a database record of work that must be sent after a local change commits. Save that record in the same transaction as the artwork state change. A separate worker reads committed outbox rows and calls the provider, so a process failure cannot lose the notification between saving approval and scheduling delivery.

Delivery can repeat, so keep one stable operation identity across attempts and use the provider's supported deduplication or reconciliation contract. After an uncertain response, read back that identity before creating another upload, approval, or archive request. Keep provider response models inside the adapter and map them into the named application records.

  • Reject caller-selected destinations.
  • Set payload and timeout bounds.
  • Keep attachments out of ordinary application logs.
  • Expose a sanitized correlation identifier for support.

Test with synthetic assets

Exercise every allowed transition and every rejected pair, including same-state requests and transitions from Archived. Submit two commands with the same expected version and verify that one commits, one conflicts, and one audit/outbox pair exists. Also cover a permission revoked before commit, a different account, a missing artwork ID, and an invalid state value.

Use fictional assets and the selected provider's sandbox for callbacks, expired previews, failures, and order snapshots. Map provider status changes to the explicit permission rules. Preserve immutable approved references when an order is accepted.

Show a safe customer experience

Display a concise customer-safe state such as Ready for approval or Approval required, plus the next permitted action. Do not expose provider identifiers, raw status payloads, file storage paths, or operational failure details. Support users need a separate authenticated diagnostic view that links a correlation identifier to the bounded state history.

This design makes product customization useful without turning the storefront into a copy of a provider's internal workflow.

Compile the workflow helper with explicit .NET settings

Put ArtworkTransitionService.cs beside this SDK-style project and run dotnet build -c Release. TargetFramework net8.0 selects the modern .NET 8 APIs used by TimeProvider; LangVersion 12.0 enables the C# syntax. Nullable analysis and warnings-as-errors make missing reference handling visible during the build. Implement the transactional store in its infrastructure project and register it when composing the hosted application. This assembly is separate from a Znode 9 Framework 4.8 process.

ArtworkWorkflow.csproj xml

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>net8.0</TargetFramework>
    <LangVersion>12.0</LangVersion>
    <Nullable>enable</Nullable>
    <ImplicitUsings>disable</ImplicitUsings>
    <TreatWarningsAsErrors>true</TreatWarningsAsErrors>
  </PropertyGroup>
</Project>

References and further reading

Bring your next engineering question.

Need help designing a product-data integration?

Explore how GCG can help

Independent guidance from GCG. Znode is a trademark of its owner. Examples use fictional data and are not official platform documentation. Suggest a correction.

Previous
Previous

Quick order that handles invalid and unavailable products clearly

Next
Next

Diagnose a missing Store global attribute through group and family mappings