Custom Table no-data responses and duplicate keys
Normalize a fixed-table response at one typed boundary, distinguish no data from malformed data, and reject duplicate rows for a key that should be unique.
In this guide
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
The reader validates every returned row against the exact requested key and authorized tenant/account. A successful HTTP response or a single row does not establish that the server applied the intended filter. A mismatch is a contract error, and the caller receives no row content.
The gateway is application-owned. Bind it to one server-configured table and the installed SDK's supported query contract. Normalize only the known empty-result envelope to an empty page. Preserve authentication, transport, and parsing failures as errors. The helper uses exact ordinal ASCII keys, so its gateway and data-writing policy must use the same identity rules.
A two-row limit detects duplicate matches, and HasMore preserves evidence when the API supplies a continuation. Fetch enough bounded pages to distinguish zero, one, and multiple matches. This policy does not create a platform uniqueness constraint. Keep Custom Tables for small administrator-managed reference data; move transactional or concurrency-sensitive uniqueness to a project-owned relational database.
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);
public sealed record RawRow(IReadOnlyDictionary<string, string?> Fields);
public sealed record TablePage(IReadOnlyList<RawRow> Rows, bool HasMore);
public sealed record Note(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<Note?> 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.");
Note? result = null;
foreach (var row in page.Rows)
{
var fields = row?.Fields ?? throw new InvalidDataException("Missing row fields.");
string Required(string name) => fields.TryGetValue(name, out var value) && value is not null
? value : throw new InvalidDataException("Missing required row field.");
if (!Guid.TryParse(Required("TenantId"), out var tenant) || tenant != scope.TenantId ||
!Guid.TryParse(Required("AccountId"), out var account) || account != scope.AccountId ||
!string.Equals(Required("Key"), key, StringComparison.Ordinal))
throw new InvalidDataException("The row does not match the authorized lookup.");
string text = Required("Text");
if (text.Length > 2048) throw new InvalidDataException("Note exceeds its size limit.");
result = new Note(key, text);
}
if (page.Rows.Count > 1 || page.HasMore)
throw new InvalidOperationException("Multiple rows match a unique lookup.");
return result;
}
}
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
Configure the fixed table and field mapping for the selected release, then exercise the fixtures through its supported API. Include two accounts with the same note key so ownership is checked independently of the key. Keep request size, response size, page count, and timeout limits in the gateway.
The resulting classification is explicit: absent, present, conflicting, malformed, or unauthorized. Each outcome has a distinct handling path, so optional no-data results stay ordinary and integrity failures remain visible.
References and further reading
Bring your next engineering question.
Need help designing durable custom data boundaries?
Independent guidance from GCG. Znode is a trademark of its owner. Examples use fictional data and are not official platform documentation. Suggest a correction.