Connect an external product or artwork workflow without duplicating state

Give every product and artwork fact one owner, exchange stable references and lifecycle events, and make the customer-facing state explicit.

MigrationPatternAdvancedGCG engineering guide
In this guide
Product owner Artwork provider Project-owned adapter Customer-safe storefront projection Order snapshot 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.

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

Model lifecycle transitions explicitly

This example allows eight explicit transitions. A buyer submits a draft, a provider reports a preview or rejection, a reviewer approves or rejects the preview, and an authorized operator archives an approved or rejected revision. A rejected item can return to draft. Archived is terminal. These are application policy choices, so map each permission to the roles and ownership rules of your workflow.

The request carries the state and version the caller last saw. The store applies the change only when both still match and the authenticated actor remains authorized for that tenant, account, and artwork. The update, incremented workflow version, audit event, and outbound notification belong in one database transaction. That requirement prevents two simultaneous approvals from both succeeding.

IAtomicArtworkStore is a project-owned persistence port. Its adapter uses a conditional UPDATE or durable database locks, and returns Conflict when the version or state changed. It returns NotFoundOrDenied without exposing another account's record. Keep the artwork asset revision immutable and separate from this workflow version; order lines retain the approved asset revision they accepted.

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, request.NextState) switch
{
(ArtworkState.Draft, ArtworkState.Submitted) => ArtworkPermission.Submit,
(ArtworkState.Submitted, ArtworkState.PreviewReady) => ArtworkPermission.ReportPreview,
(ArtworkState.Submitted, ArtworkState.Rejected) => ArtworkPermission.ReportPreview,
(ArtworkState.PreviewReady, ArtworkState.Approved) => ArtworkPermission.Review,
(ArtworkState.PreviewReady, ArtworkState.Rejected) => ArtworkPermission.Review,
(ArtworkState.Rejected, ArtworkState.Draft) => ArtworkPermission.Submit,
(ArtworkState.Approved, ArtworkState.Archived) => ArtworkPermission.Archive,
(ArtworkState.Rejected, 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);
}
}

Use an adapter and an outbox boundary

Keep provider wire models out of storefront and order code. An adapter translates them into project-owned contracts with bounded inputs and redacted diagnostics. Store this concurrent workflow in a project-owned relational database. The same transaction that changes workflow state adds an outbox row; a separate worker delivers it through the provider API. Delivery can repeat, so give the receiver a stable idempotency key.

Use stable idempotency keys where the target accepts them. When a response is uncertain, read back by the stable external reference or reconcile through an approved operational path before creating another request. Do not retry uploads or archive commands indefinitely without knowing whether the target already applied the change.

  • 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.

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.