Skip to content

Effects Composition

The [TableStore] generator produces an optional Eff<RT, T> composition layer on top of the plain .NET store interfaces. This enables functional effect pipelines with runtime capability resolution.

Capability Interface

The generator produces a single capability interface per table store:

public interface IHasChatStore
{
    public ChatStoreService ChatStoreService { get; }
}

Use this as a runtime constraint on effect methods — any runtime type implementing IHasChatStore can resolve the backing service at execution time.

Aggregated Service

The generator produces a ChatStoreService class that contains methods for all [TableEntity] types in the assembly. Each entity gets its own lazy-initialized TableClient:

public sealed class ChatStoreService(TableServiceClient tableServiceClient)
{
    private TableClient? _conversationsClient;
    private TableClient? _phoneMappingsClient;

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

    public async Task<Conversation?> GetConversationAsync(
        string partitionKey, string rowKey, CancellationToken cancellationToken = default) { ... }
    public async Task UpsertConversationAsync(
        Conversation entity, CancellationToken cancellationToken = default) { ... }
    public async Task DeleteConversationAsync(
        string partitionKey, string rowKey, CancellationToken cancellationToken = default) { ... }
    public async Task<List<Conversation>> QueryConversationsAsync(
        string partitionKey, CancellationToken cancellationToken = default) { ... }

    // ... same methods for PhoneMapping, etc.
}

The service uses the same implementation patterns as the per-entity stores — idempotent deletes, null on 404, table auto-creation on upsert.

Effect Methods

Effect methods are generated as static members on the [TableStore] class. Each [TableEntity] produces four methods:

public static partial class ChatStore
{
    public static Eff<RT, Option<Conversation>> GetConversation<RT>(
        string partitionKey, string rowKey)
        where RT : IHasChatStore
        => liftEff<RT, Option<Conversation>>(async rt =>
            Optional(await rt.ChatStoreService.GetConversationAsync(partitionKey, rowKey)));

    public static Eff<RT, Unit> UpsertConversation<RT>(Conversation entity)
        where RT : IHasChatStore
        => liftEff<RT, Unit>(async rt =>
        {
            await rt.ChatStoreService.UpsertConversationAsync(entity);
            return unit;
        });

    public static Eff<RT, Unit> DeleteConversation<RT>(
        string partitionKey, string rowKey)
        where RT : IHasChatStore
        => liftEff<RT, Unit>(async rt =>
        {
            await rt.ChatStoreService.DeleteConversationAsync(partitionKey, rowKey);
            return unit;
        });

    public static Eff<RT, List<Conversation>> QueryConversations<RT>(
        string partitionKey)
        where RT : IHasChatStore
        => liftEff<RT, List<Conversation>>(async rt =>
            await rt.ChatStoreService.QueryConversationsAsync(partitionKey));
}
Method Return Type Description
Get{Entity}<RT> Eff<RT, Option<T>> Returns Some(entity) or None
Upsert{Entity}<RT> Eff<RT, Unit> Upserts entity, returns unit
Delete{Entity}<RT> Eff<RT, Unit> Idempotent delete, returns unit
Query{Entity}s<RT> Eff<RT, List<T>> All entities in a partition

Each method uses LanguageExt.Prelude.liftEff to lift async operations into Eff<RT, T> values, with the where RT : IHas{StoreName} constraint providing access to the service at execution time.

Constant Partition Key Handling

When a [TableEntity] uses [PartitionKey(Constant = "...")], the generated Eff methods omit the partition key parameter and bake the constant value directly into the service call:

// Entity with constant partition key
[TableEntity("PhoneMappings")]
public partial record PhoneMapping
{
    [PartitionKey(Constant = "PhoneMappings")] public required string PhoneNumber { get; init; }
    [RowKey] public required string ThreadTs { get; init; }
}

Generated Eff methods — no partitionKey parameter needed:

// Get — only rowKey required
public static Eff<RT, Option<PhoneMapping>> GetPhoneMapping<RT>(string rowKey)
    where RT : IHasChatStore
    => liftEff<RT, Option<PhoneMapping>>(async rt =>
        Optional(await rt.ChatStoreService.GetPhoneMappingAsync("PhoneMappings", rowKey)));

// Delete — only rowKey required
public static Eff<RT, Unit> DeletePhoneMapping<RT>(string rowKey)
    where RT : IHasChatStore
    => liftEff<RT, Unit>(async rt =>
    {
        await rt.ChatStoreService.DeletePhoneMappingAsync("PhoneMappings", rowKey);
        return unit;
    });

// Query — no parameters at all (constant partition is baked in)
public static Eff<RT, List<PhoneMapping>> QueryPhoneMappings<RT>()
    where RT : IHasChatStore
    => liftEff<RT, List<PhoneMapping>>(async rt =>
        await rt.ChatStoreService.QueryPhoneMappingsAsync("PhoneMappings"));

DI Registration

The generator creates a registration extension method for the table store service:

public static class ChatStoreServiceCollectionExtensions
{
    public static IServiceCollection AddChatStore(this IServiceCollection services)
    {
        services.TryAddSingleton<ChatStoreService>();
        return services;
    }
}

Per-entity vs table store registration

AddConversationStore registers IConversationStoreConversationStore for direct injection. AddChatStore registers ChatStoreService for effects composition. Use whichever fits your usage pattern — or both.

Composing with [Runtime] and [Uses]

Wire the capability interface into your runtime to enable effect composition:

[Runtime]
[Uses<IHasChatStore>]
public sealed partial class AppRuntime;

The [Runtime] generator resolves IHasChatStore from the DI container, making all ChatStore.* effect methods available in your pipelines.

Effect Pipeline Examples

Use LINQ from/select syntax to compose table storage operations:

// Define entities
[TableEntity("Conversations")]
public partial record Conversation
{
    [PartitionKey] public required string ThreadTs { get; init; }
    [RowKey] public required string PhoneNumber { get; init; }
    public required string DisplayName { get; init; }
    public required int MessageCount { get; init; }
    public required DateTimeOffset CreatedAt { get; init; }
}

[TableEntity("PhoneMappings")]
public partial record PhoneMapping
{
    [PartitionKey(Constant = "PhoneMappings")] public required string PhoneNumber { get; init; }
    [RowKey] public required string ThreadTs { get; init; }
    public required DateTime CreatedUtc { get; init; }
}

// Table store
[TableStore]
public static partial class ChatStore;

// Effect pipeline — look up a conversation and update its display name
var program =
    from conv in ChatStore.GetConversation<AppRuntime>(threadTs, phoneNumber)
    from _ in guard(conv.IsSome, Error.New("Conversation not found"))
              >> ChatStore.UpsertConversation<AppRuntime>(conv.Value! with { DisplayName = "Updated" })
    select unit;

// Effect pipeline — query all phone mappings (constant partition key, no params)
var listMappings =
    from mappings in ChatStore.QueryPhoneMappings<AppRuntime>()
    select mappings;

Multiple Entities

A single [TableStore] automatically discovers and generates methods for all [TableEntity] types in the assembly. The ChatStoreService contains methods for every entity, and the ChatStore static class exposes Eff methods for each:

// All generated automatically from [TableStore]
ChatStore.GetConversation<RT>(partitionKey, rowKey)       // Eff<RT, Option<Conversation>>
ChatStore.UpsertConversation<RT>(conversation)             // Eff<RT, Unit>
ChatStore.DeleteConversation<RT>(partitionKey, rowKey)      // Eff<RT, Unit>
ChatStore.QueryConversations<RT>(partitionKey)              // Eff<RT, List<Conversation>>

ChatStore.GetPhoneMapping<RT>(rowKey)                      // Eff<RT, Option<PhoneMapping>>
ChatStore.UpsertPhoneMapping<RT>(phoneMapping)             // Eff<RT, Unit>
ChatStore.DeletePhoneMapping<RT>(rowKey)                   // Eff<RT, Unit>
ChatStore.QueryPhoneMappings<RT>()                         // Eff<RT, List<PhoneMapping>>

Note how PhoneMapping methods omit the partition key parameter because it uses [PartitionKey(Constant = "PhoneMappings")].