Skip to content

Generated Code

The Azure Table Storage generator produces standard .NET interfaces and implementations that work with plain async/await — no effects required. For the optional Eff<RT, T> composition layer, see Effects Composition.

Entity Mapper Methods

For each [TableEntity], the generator adds two methods to the partial type:

ToTableEntity()

Converts the entity to an Azure.Data.Tables.TableEntity for storage:

public Azure.Data.Tables.TableEntity ToTableEntity()
{
    var entity = new Azure.Data.Tables.TableEntity(ThreadTs, PhoneNumber);
    entity["DisplayName"] = DisplayName;
    entity["MessageCount"] = MessageCount;
    entity["CreatedAt"] = CreatedAt;
    entity["ThreadTs"] = ThreadTs;
    entity["PhoneNumber"] = PhoneNumber;
    return entity;
}

The partition key and row key are passed to the TableEntity constructor. All data properties (including the key properties) are stored as named values.

When a constant partition key is used, the literal value replaces the property reference:

var entity = new Azure.Data.Tables.TableEntity("PhoneMappings", ThreadTs);
// PhoneNumber property is NOT added to the entity

FromTableEntity()

Reconstructs the entity from a TableEntity:

public static Conversation FromTableEntity(Azure.Data.Tables.TableEntity entity)
{
    return new Conversation
    {
        ThreadTs = entity.GetString("ThreadTs") ?? "",
        PhoneNumber = entity.GetString("PhoneNumber") ?? "",
        DisplayName = entity.GetString("DisplayName") ?? "",
        MessageCount = entity.GetInt32("MessageCount") ?? 0,
        CreatedAt = entity.GetDateTimeOffset("CreatedAt") ?? default,
    };
}

Non-nullable properties use null-coalescing with type-appropriate defaults ("" for strings, 0 for integers, default for value types). Nullable properties omit the fallback:

// Nullable properties — no default fallback
return new Item
{
    UserId = entity.GetString("UserId") ?? "",
    ItemId = entity.GetString("ItemId") ?? "",
    Description = entity.GetString("Description"),    // string? — no ??
    SortOrder = entity.GetInt32("SortOrder"),          // int? — no ??
};

Property Conversion Strategies

The generator automatically selects a conversion strategy based on the property type:

Strategy Property Types ToTableEntity() FromTableEntity()
Native string, bool, int, long, double, byte[], DateTime, DateTimeOffset, Guid Direct assignment entity.GetString(), entity.GetInt32(), etc.
Enum Any enum type .ToString() Enum.Parse<T>()
TypedId Types with [TypedId] attribute .Value.ToString() TypedId.Parse()
Json All other types (List<T>, custom objects, etc.) JsonSerializer.Serialize() JsonSerializer.Deserialize<T>()
Option Option<T> (LanguageExt) Nullable handling Nullable handling

DSAT04 — JSON serialization diagnostic

Properties that use the Json strategy trigger an informational diagnostic (DSAT04) at compile time: "Property '{name}' on '{type}' will be stored as JSON because its type is not natively supported by Azure Table Storage." This is informational only — no action required.

JSON serialization example

[TableEntity("Users")]
public partial record User
{
    [PartitionKey] public required string UserId { get; init; }
    [RowKey] public required string Email { get; init; }
    public required List<string> Tags { get; init; } // ← DSAT04 info
}

Generated conversion:

// ToTableEntity
entity["Tags"] = JsonSerializer.Serialize(Tags);

// FromTableEntity
Tags = JsonSerializer.Deserialize<List<string>>(entity.GetString("Tags") ?? "[]") ?? new(),

Generated Store Interface

For each [TableEntity], the generator produces a store interface with four async CRUD methods:

public interface IConversationStore
{
    Task UpsertAsync(Conversation entity, CancellationToken cancellationToken = default);
    Task<Conversation?> GetAsync(string partitionKey, string rowKey, CancellationToken cancellationToken = default);
    Task DeleteAsync(string partitionKey, string rowKey, CancellationToken cancellationToken = default);
    Task<List<Conversation>> QueryByPartitionAsync(string partitionKey, CancellationToken cancellationToken = default);
}

Store Implementation

The generated ConversationStore is a sealed class backed by a TableServiceClient:

public sealed class ConversationStore(TableServiceClient tableServiceClient) : IConversationStore
{
    private TableClient? _tableClient;

    private TableClient GetTableClient()
    {
        _tableClient ??= tableServiceClient.GetTableClient("Conversations");
        return _tableClient;
    }

    public async Task UpsertAsync(Conversation entity, CancellationToken cancellationToken = default)
    {
        var client = GetTableClient();
        await client.CreateIfNotExistsAsync(cancellationToken: cancellationToken);
        await client.UpsertEntityAsync(entity.ToTableEntity(), TableUpdateMode.Replace, cancellationToken);
    }

    public async Task<Conversation?> GetAsync(
        string partitionKey, string rowKey, CancellationToken cancellationToken = default)
    {
        var client = GetTableClient();
        try
        {
            var response = await client.GetEntityAsync<TableEntity>(
                partitionKey, rowKey, cancellationToken: cancellationToken);
            return Conversation.FromTableEntity(response.Value);
        }
        catch (RequestFailedException ex) when (ex.Status == 404)
        {
            return null;
        }
    }

    public async Task DeleteAsync(
        string partitionKey, string rowKey, CancellationToken cancellationToken = default)
    {
        var client = GetTableClient();
        try
        {
            await client.DeleteEntityAsync(partitionKey, rowKey, cancellationToken: cancellationToken);
        }
        catch (RequestFailedException ex) when (ex.Status == 404)
        {
            // Idempotent delete — ignore 404
        }
    }

    public async Task<List<Conversation>> QueryByPartitionAsync(
        string partitionKey, CancellationToken cancellationToken = default)
    {
        var client = GetTableClient();
        var results = new List<Conversation>();
        await foreach (var entity in client.QueryAsync<TableEntity>(
            e => e.PartitionKey == partitionKey, cancellationToken: cancellationToken))
        {
            results.Add(Conversation.FromTableEntity(entity));
        }
        return results;
    }
}

Key implementation details:

  • Lazy TableClient — initialized on first use via null-coalescing assignment (??=)
  • Table auto-creationCreateIfNotExistsAsync is called before every upsert
  • Idempotent deletes — 404 responses are caught and silently ignored
  • Null on missGetAsync returns null when the entity is not found (404)

DI Registration

The generator creates a registration extension method per entity:

public static class ConversationStoreServiceCollectionExtensions
{
    public static IServiceCollection AddConversationStore(this IServiceCollection services)
    {
        services.TryAddSingleton<IConversationStore, ConversationStore>();
        return services;
    }
}

TryAddSingleton means concrete implementations registered first take priority — register a custom implementation before calling AddConversationStore to override the default.

Injecting and Using Directly

Use the generated interface with standard constructor injection:

public class ConversationService(IConversationStore store)
{
    public async Task<Conversation?> GetConversation(
        string threadTs, string phone, CancellationToken ct)
        => await store.GetAsync(threadTs, phone, ct);

    public async Task<List<Conversation>> ListByThread(
        string threadTs, CancellationToken ct)
        => await store.QueryByPartitionAsync(threadTs, ct);
}