Custom Table no-data responses and duplicate keys

Map Custom Table responses into named C# rows, return an optional account note, and keep duplicates, malformed data, and failed reads distinct.

Znode 10PatternIntermediateGCG engineering guide
In this guide
  1. Validated key
  2. Fixed table read
  3. Zero rows
  4. One typed row
  5. Duplicate conflict
  6. Malformed contract error
A bounded lookup classifies every result before business code reads custom data.

No data is a normal outcome

A lookup for optional custom data can validly find no row. The application should represent that result explicitly instead of turning an empty collection into a null-reference error or silently selecting an unrelated record. The calling code needs to know whether no data is expected, whether configuration is missing, or whether the response could not be understood.

Keep the query narrow. A fixed table and an exact, validated key are easier to reason about than a generic data access helper that accepts arbitrary tables and filters. Narrow access also reduces accidental permission expansion and makes tests more focused.

  • No match: return an explicit absent result.
  • One match: parse the known row into a typed value.
  • More than one match: report a data-integrity conflict.
  • Malformed row: report a contract error, not an empty result.

Normalize at a typed boundary

FixedTableReader accepts an authorized ReadScope and one exact note key. INoteAuthorization checks read permission before the lookup. IFixedTableGateway calls one server-configured table and maps known SDK fields into TableNoteRow, whose TenantId, AccountId, Key, and Text properties make the expected data explicit.

The reader asks for up to two rows, enough to distinguish one match from a duplicate. The gateway must also preserve evidence of further matches in HasMore. Normalize the selected SDK's known no-data response into an empty page, while keeping authentication, transport, and malformed responses as errors.

After checking scope, key, text, and pagination, FindAsync returns AccountNote with only Key and Text. The exact ASCII key policy belongs to this application, so its writers and gateway must use the same identity rules.

FixedTableReader.cs csharp

using System;
using System.Collections.Generic;
using System.IO;
using System.Threading;
using System.Threading.Tasks;

namespace Gcg.Examples.CustomData;

public sealed record ReadScope(Guid TenantId, Guid AccountId, Guid ActorId);
// The SDK adapter decodes known response fields into this typed transport model.
public sealed record TableNoteRow(Guid? TenantId, Guid? AccountId, string? Key, string? Text);
public sealed record TablePage(IReadOnlyList<TableNoteRow> Rows, bool HasMore);
public sealed record AccountNote(string Key, string Text);
public interface INoteAuthorization
{
    // Enforce authenticated actor membership and permission for this scope.
    Task DemandReadAsync(ReadScope scope, CancellationToken ct);
}
public interface IFixedTableGateway
{
    // Resolve table/endpoint server-side; filter by tenant, account AND exact key.
    // Normalize only the selected SDK's documented no-data response to an empty page.
    // Return up to 'take' rows and preserve evidence of further matches in HasMore.
    // Authentication, transport and malformed-envelope failures remain exceptions.
    Task<TablePage> FindAsync(ReadScope scope, string key, int take, CancellationToken ct);
}

public sealed class FixedTableReader
{
    private readonly IFixedTableGateway gateway;
    private readonly INoteAuthorization authorization;
    public FixedTableReader(IFixedTableGateway gateway, INoteAuthorization authorization)
    {
        this.gateway = gateway ?? throw new ArgumentNullException(nameof(gateway));
        this.authorization = authorization ?? throw new ArgumentNullException(nameof(authorization));
    }

    public async Task<AccountNote?> FindAsync(ReadScope scope, string key, CancellationToken ct)
    {
        ArgumentNullException.ThrowIfNull(scope);
        if (scope.TenantId == Guid.Empty || scope.AccountId == Guid.Empty || scope.ActorId == Guid.Empty)
            throw new ArgumentException("An authenticated scope is required.", nameof(scope));
        if (string.IsNullOrEmpty(key) || key.Length > 100)
            throw new ArgumentException("A key of 1 to 100 characters is required.", nameof(key));
        foreach (char ch in key)
            if (!char.IsAsciiLetterOrDigit(ch) && ch != '-' && ch != '_' && ch != '.')
                throw new ArgumentException("Use an exact ASCII key without spaces.", nameof(key));

        await authorization.DemandReadAsync(scope, ct);
        var page = await gateway.FindAsync(scope, key, 2, ct)
            ?? throw new InvalidDataException("Missing table response.");
        if (page.Rows is null || page.Rows.Count > 2 || (page.Rows.Count == 0 && page.HasMore))
            throw new InvalidDataException("Invalid bounded page.");

        AccountNote? result = null;
        foreach (var row in page.Rows)
        {
            if (row is null || row.TenantId != scope.TenantId || row.AccountId != scope.AccountId ||
                !string.Equals(row.Key, key, StringComparison.Ordinal))
                throw new InvalidDataException("The row does not match the authorized lookup.");
            if (row.Text is null)
                throw new InvalidDataException("The note text field is missing.");
            if (row.Text.Length > 2048)
                throw new InvalidDataException("The note exceeds its size limit.");

            result = new AccountNote(key, row.Text);
        }
        if (page.Rows.Count > 1 || page.HasMore)
            throw new InvalidOperationException("Multiple rows match a unique lookup.");
        return result;
    }
}

An empty Rows list with HasMore false returns null. One valid row for the requested tenant, account, and key returns an AccountNote. Two rows, or a row with HasMore true, raise a duplicate-match error. A wrong-account row raises a data-contract error before its text reaches the caller. These read checks detect conflicts; they do not create a uniqueness guarantee in the table.

Do not repair bad data by guessing

If a duplicate or malformed record appears, preserve enough sanitized context to investigate and return a safe failure to the caller. Choosing the first row can produce an apparently successful but incorrect business decision. Automatically deleting duplicate custom data is also risky because the application may not own every related record.

Use a dedicated correction process that identifies the stable key, owner, target version, and expected single record. Test it in a permitted non-production environment. Keep authorization checks in place for both the read and the correction path.

  • No rows and one complete row.
  • Two matching rows, or one row with further matches.
  • Different returned key, tenant, or account.
  • Missing fields, oversized text, or contradictory pagination.
  • Unauthorized caller, transport failure, and malformed response envelope.

Version-specific verification

Bind the fixed table and field mapping to the installed SDK, then verify zero matches, one valid row, duplicate matches, malformed fields, and failed authorization. Use the same key in two accounts so the test proves ownership filtering independently of the key text.

Keep request, response, page, and timeout limits in the gateway. If the data needs frequent writes or concurrent uniqueness guarantees, move that workflow to suitable project-owned relational storage. A bounded Custom Table read is useful for small reference data; it cannot supply transaction guarantees missing from the storage contract.

References and further reading

Bring your next engineering question.

Need help designing durable custom data boundaries?

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

Address recommendations without breaking checkout state

Next
Next

Fix out-of-order cart updates before they corrupt the UI