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:
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 IConversationStore → ConversationStore 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:
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")].