Skip to content

Effects Composition

The Data Store generator produces an optional Eff<RT, T> composition layer on top of the plain .NET store interfaces. This enables functional effect pipelines with built-in OpenTelemetry tracing.

Capability Interface

The generator produces a single capability interface per data store:

public interface IHasAppStore
{
    IArticleStore ArticleStore { get; }
    ICommentStore CommentStore { get; }
    // ... one property per stored entity
}

Use this as a runtime constraint on effect methods — any runtime type implementing IHasAppStore can resolve stores at execution time.

Effect Methods

Effect methods are generated as nested static classes on the data store container:

public static partial class AppStore
{
    public static partial class Articles
    {
        // Core CRUD
        public static Eff<RT, Option<Article>> GetById<RT>(ArticleId id)
            where RT : IHasAppStore => ...

        public static Eff<RT, Unit> Save<RT>(Article article)
            where RT : IHasAppStore => ...

        public static Eff<RT, Unit> Delete<RT>(ArticleId id)
            where RT : IHasAppStore => ...

        // Composable queries
        public static Eff<RT, QueryResult<Article>> QueryPage<RT>(
            int page, int pageSize,
            Expression<Func<Article, bool>>? filter = null,
            Expression<Func<Article, object>>? orderBy = null)
            where RT : IHasAppStore => ...

        public static Eff<RT, CursorResult<Article>> QueryCursor<RT>(
            string? cursor, int limit,
            Expression<Func<Article, bool>>? filter = null)
            where RT : IHasAppStore => ...

        public static Eff<RT, IReadOnlyList<Article>> QueryList<RT>(
            Expression<Func<Article, bool>>? filter = null,
            Expression<Func<Article, object>>? orderBy = null)
            where RT : IHasAppStore => ...

        public static Eff<RT, Option<Article>> QueryFirst<RT>(
            Expression<Func<Article, bool>>? filter = null)
            where RT : IHasAppStore => ...

        public static Eff<RT, int> QueryCount<RT>(
            Expression<Func<Article, bool>>? filter = null)
            where RT : IHasAppStore => ...

        // Batch operations
        public static Eff<RT, Unit> SaveMany<RT>(IEnumerable<Article> articles)
            where RT : IHasAppStore => ...

        public static Eff<RT, IReadOnlyList<Article>> GetByIds<RT>(IEnumerable<ArticleId> ids)
            where RT : IHasAppStore => ...

        public static Eff<RT, Unit> DeleteMany<RT>(IEnumerable<ArticleId> ids)
            where RT : IHasAppStore => ...

        // Convenience queries
        public static Eff<RT, bool> Exists<RT>(ArticleId id)
            where RT : IHasAppStore => ...

        public static Eff<RT, int> Count<RT>()
            where RT : IHasAppStore => ...

        public static Eff<RT, IReadOnlyList<Article>> GetAll<RT>()
            where RT : IHasAppStore => ...

        // Lookup query methods (one set per [Lookup] property)
        public static Eff<RT, QueryResult<Article>> QueryPageByAuthorId<RT>(
            AuthorId authorId, int page, int pageSize,
            Expression<Func<Article, bool>>? filter = null,
            Expression<Func<Article, object>>? orderBy = null)
            where RT : IHasAppStore => ...

        public static Eff<RT, CursorResult<Article>> QueryCursorByAuthorId<RT>(
            AuthorId authorId, string? cursor, int limit,
            Expression<Func<Article, bool>>? filter = null)
            where RT : IHasAppStore => ...

        public static Eff<RT, IReadOnlyList<Article>> QueryListByAuthorId<RT>(
            AuthorId authorId,
            Expression<Func<Article, bool>>? filter = null,
            Expression<Func<Article, object>>? orderBy = null)
            where RT : IHasAppStore => ...

        public static Eff<RT, Option<Article>> QueryFirstByAuthorId<RT>(
            AuthorId authorId,
            Expression<Func<Article, bool>>? filter = null)
            where RT : IHasAppStore => ...

        public static Eff<RT, int> QueryCountByAuthorId<RT>(
            AuthorId authorId,
            Expression<Func<Article, bool>>? filter = null)
            where RT : IHasAppStore => ...

        // Require overloads (get or fail)
        public static Eff<RT, Article> Require<RT>(ArticleId id)
            where RT : IHasAppStore => ...

        public static Eff<RT, Article> Require<RT>(ArticleId id, Func<ArticleId, Error> onNotFound)
            where RT : IHasAppStore => ...

        public static Eff<RT, Article> Require<RT>(ArticleId id, Func<ArticleId, string> onNotFound)
            where RT : IHasAppStore => ...
    }
}

Each method lifts the corresponding IArticleStore async call into an Eff<RT, T> value.

Require — Get or Fail

The Require methods are convenience overloads that return Eff<RT, T> (not Option<T>). If the entity is not found, the effect fails with an error instead of returning None.

Three overloads are generated:

// Default error message: "Article {id} not found"
from article in AppStore.Articles.Require<AppRuntime>(articleId)

// Custom Error via factory
from article in AppStore.Articles.Require<AppRuntime>(articleId,
    id => Error.New(404, $"No article with ID {id}"))

// Custom string message via factory
from article in AppStore.Articles.Require<AppRuntime>(articleId,
    id => $"Article {id} does not exist")

Require vs GetById + guard

Use Require to replace the common GetByIdguard(option.IsSome, ...) pattern with a single call. The default overload produces a clear error message including the entity name and key value(s).

OpenTelemetry Tracing

DataStore vs EffectsModule

DataStore generates its own effect methods with built-in OpenTelemetry instrumentation. Each effect method includes .WithActivity() calls that create tracing spans automatically. You don't need to wrap with [EffectsModule] for tracing — it's included by default. Set Instrumented = false on [DataStore] to disable it.

When Instrumented = true (the default), every effect method creates an OpenTelemetry activity span. This gives you distributed tracing out of the box without any additional configuration.

Runtime Integration

The [DataStore] capability is auto-discovered by the runtime in the same assembly:

[Runtime]
public sealed partial class AppRuntime;
// BlogStore is auto-discovered — no [Uses] needed

The runtime resolves all store interfaces from the DI container, making all BlogStore.* effect methods available in your pipelines.

Effect Pipeline Examples

Use LINQ from/select syntax to compose store operations:

// Typed IDs
[TypedId] public readonly partial struct ArticleId;
[TypedId] public readonly partial struct CommentId;

// Entities
[StoredEntity]
public partial record Article(
    ArticleId Id,
    string Title,
    string Body,
    DateTimeOffset CreatedAt);

[StoredEntity]
public partial record Comment(
    CommentId Id,
    [ForeignKey<Article>] ArticleId ArticleId,
    string Body,
    DateTimeOffset CreatedAt);

// Data store
[DataStore]
public static partial class BlogStore;

// Effect pipeline
var program =
    from article in BlogStore.Articles.GetById<AppRuntime>(articleId)
    from _ in guard(article.IsSome, Error.New("Article not found"))
              >> BlogStore.Articles.Save<AppRuntime>(article.Value! with { Title = "Updated" })
    select unit;

Adding Resilience

DataStore generates effect methods directly from [StoredEntity] declarations with built-in OpenTelemetry tracing. Since the store interfaces (IArticleStore, etc.) are generated, you can't add [Retry], [Timeout], or [CircuitBreaker] attributes to them directly.

To add declarative resilience, extend the generated interface and redeclare only the methods that need resilience:

public interface IResilientArticleStore : IArticleStore
{
    [Retry(MaxAttempts = 3, DelayMs = 500)]
    [Timeout(5_000)]
    new Task<Option<Article>> GetByIdAsync(ArticleId id);

    [CircuitBreaker(FailureThreshold = 5)]
    [Retry]
    new Task SaveAsync(Article article);

    // DeleteAsync, QueryPage, QueryCursor — inherited from IArticleStore unchanged
}

[EffectsModule(typeof(IResilientArticleStore))]
public sealed partial class ResilientArticleEffects;

The generator picks up all methods — redeclared methods get resilience wrapping, inherited methods get plain liftEff + tracing. Use the resilient module's effect methods in your pipelines:

from article in ResilientArticleEffects.ArticleStore.GetById<AppRuntime>(articleId)
from _       in guard(article.IsSome, Error.New("Article not found"))
select article.Value!;

Wire both the data store (for DI registration) and the resilient module into your runtime:

[Runtime]
public sealed partial class AppRuntime;
// BlogStore and ResilientArticleEffects are auto-discovered

See Resilience for the full reference on [Retry], [Timeout], and [CircuitBreaker] attributes.

Postgres Implementation

The Deepstaging.Data.Postgres package includes a generator that produces EF Core implementations:

dotnet add package Deepstaging.Data.Postgres --prerelease

This generates:

  • PgArticleStore<TContext> — generic EF Core implementation of IArticleStore
  • AppStoreDbContextDbContext with DbSet<Article> properties (for standalone use)
  • AppStoreModelBuilderExtensionsModelBuilder extensions for applying entity config to any DbContext
  • AppStorePostgresRegistration — DI extensions with default and BYOC overloads
// Standalone — uses the generated DbContext
services.AddAppStorePostgres(connectionString);

// Bring your own DbContext
services.AddDbContext<MyAppDbContext>(options => options.UseNpgsql(connectionString));
services.AddAppStorePostgres<MyAppDbContext>();

When using your own DbContext, call the generated configuration extensions in OnModelCreating and ConfigureConventions. See Generated Code → Bring Your Own DbContext for the full pattern.

The Postgres stores are registered with AddSingleton, so they take priority over the in-memory defaults registered by TryAddSingleton. See Generated Code → Swapping Implementations for the registration order pattern.