Design a bounded Znode 10 Custom API endpoint

Read a preference through authenticated context and named C# models, checking permission before an account-aware cache lookup.

Znode 10GuideIntermediateGCG engineering guide
Live React preview

Try the carton calculator

Run the recipe’s React form, change quantities, and inspect its request and response. The embedded API response comes from a local demo adapter.

Open interactive preview
Browser-only demo · example data
Follow the complete recipe
In this guide
  1. gateway route
  2. authenticated request context
  3. endpoint adapter
  4. application service
  5. owned data boundary
The platform adapter supplies verified routing and identity; the application service owns the small behavior.

Choose a narrow contract

PreferenceService reads a small display preference for the current user and buying context. IRequestContext supplies RequestIdentity from authenticated server state. IPreferenceAuthorization checks access, IPreferenceStore reads the value, and IPreferenceCache stores the response. The caller supplies no account, Store, or subject ID to GetAsync.

PreferenceKey includes tenant, account, Store, subject, and schema version. Authorization runs before every cache lookup, including a hit, so a previously cached label cannot bypass a revoked permission. The response contains one named Label property and limits text to 160 characters.

The example caches this non-sensitive preference for one minute. The cache adapter must serialize the full typed key without collisions and honor that lifetime. Evict the exact key after an edit, increase SchemaVersion when the cached format changes, and add locale if it changes the value.

PreferenceService.cs csharp

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

namespace Gcg.Examples.Preferences;

public sealed record RequestIdentity(
    bool IsAuthenticated, string SubjectId, Guid TenantId, Guid AccountId, Guid StoreId);
public sealed record PreferenceKey(Guid TenantId, Guid AccountId, Guid StoreId, string SubjectId, int SchemaVersion);
public sealed record PreferenceResponse(string? Label);
public interface IRequestContext
{
    RequestIdentity Current
    {
        get;
    }
}
public interface IPreferenceAuthorization
{
    Task DemandReadAsync(RequestIdentity identity, CancellationToken ct);
}
public interface IPreferenceStore
{
    Task<string?> FindLabelAsync(PreferenceKey key, CancellationToken ct);
}
public interface IPreferenceCache
{
    // Null means cache miss; a response with Label=null means a cached absent preference.
    Task<PreferenceResponse?> GetAsync(PreferenceKey key, CancellationToken ct);
    Task SetAsync(PreferenceKey key, PreferenceResponse value, TimeSpan ttl, CancellationToken ct);
}

public sealed class PreferenceService
{
    private readonly IRequestContext context;
    private readonly IPreferenceAuthorization authorization;
    private readonly IPreferenceStore store;
    private readonly IPreferenceCache cache;
    public PreferenceService(IRequestContext context, IPreferenceAuthorization authorization,
        IPreferenceStore store, IPreferenceCache cache)
    {
        this.context = context ?? throw new ArgumentNullException(nameof(context));
        this.authorization = authorization ?? throw new ArgumentNullException(nameof(authorization));
        this.store = store ?? throw new ArgumentNullException(nameof(store));
        this.cache = cache ?? throw new ArgumentNullException(nameof(cache));
    }

    public async Task<PreferenceResponse> GetAsync(CancellationToken ct)
    {
        ct.ThrowIfCancellationRequested();
        var identity = context.Current;
        if (identity is null || !identity.IsAuthenticated || string.IsNullOrWhiteSpace(identity.SubjectId) ||
            identity.SubjectId.Length > 200 || identity.TenantId == Guid.Empty ||
            identity.AccountId == Guid.Empty || identity.StoreId == Guid.Empty)
            throw new UnauthorizedAccessException("An authenticated buying context is required.");

        // Membership and read permission are checked before every cache lookup.
        await authorization.DemandReadAsync(identity, ct);
        var key = new PreferenceKey(identity.TenantId, identity.AccountId,
            identity.StoreId, identity.SubjectId, SchemaVersion: 1);
        var cached = await cache.GetAsync(key, ct);
        if (cached is not null)
            return Validate(cached);

        var response = Validate(new PreferenceResponse(await store.FindLabelAsync(key, ct)));
        ct.ThrowIfCancellationRequested();
        await cache.SetAsync(key, response, TimeSpan.FromMinutes(1), ct);
        return response;
    }

    private static PreferenceResponse Validate(PreferenceResponse response)
    {
        if (response.Label is not null && response.Label.Length > 160)
            throw new InvalidDataException("Preference label exceeds 160 characters.");
        return response;
    }
}

When GetAsync finds no cache entry, it reads the store and caches a PreferenceResponse. A response with Label null means the preference is absent; that is still a cacheable result. On the next request, authorization runs again and that response can be reused. A null response from GetAsync on the cache interface means a cache miss, which is deliberately different from a response whose Label is null.

Connect the supported Custom API components

Use the installed Custom API controller conventions to call this application service. Znode's guide places controllers and services in Custom.API.Core and startup and gateway configuration in Engine.Custom.API. Register the service and adapters through that SDK's supported startup path, with IRequestContext scoped to the request.

Map authentication and authorization failures to the host's appropriate responses, preserve cancellation, and return PreferenceResponse. Expose the route through the documented customapi route key and installed gateway configuration. Use supported clients for any Znode-owned data read by the adapters.

Test the boundary

Cover a valid preference, absent value, cache hit, expired entry, oversized label, empty subject, unauthenticated request, and revoked permission. Change each key dimension independently to confirm cache isolation. Verify cancellation reaches the store and cache adapters.

Exercise the deployed gateway route with authorized and unauthorized sessions, then switch the buying account and Store. Confirm that the server resolves the new context and that neither a query string nor a forged request header can select another subject's cached value.

References and further reading

Bring your next engineering question.

Need an API boundary designed for your 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

Read ERP order history through a bounded account-aware adapter

Next
Next

Trace one Znode 9 request from browser to service