Skip to content

Generated Code

The Data Store 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.

Generated Store Interface

For each [StoredEntity], the generator produces a store interface:

public interface IArticleStore
{
    // Core CRUD
    Task<Article?> GetByIdAsync(ArticleId id, CancellationToken ct = default);
    Task SaveAsync(Article article, CancellationToken ct = default);
    Task DeleteAsync(ArticleId id, CancellationToken ct = default);

    // Composable query builder
    StoreQuery<Article> Query();

    // Batch operations
    Task SaveManyAsync(IEnumerable<Article> articles, CancellationToken ct = default);
    Task<IReadOnlyList<Article>> GetByIdsAsync(IEnumerable<ArticleId> ids, CancellationToken ct = default);
    Task DeleteManyAsync(IEnumerable<ArticleId> ids, CancellationToken ct = default);

    // Convenience queries
    Task<bool> ExistsAsync(ArticleId id, CancellationToken ct = default);
    Task<int> CountAsync(CancellationToken ct = default);
    Task<IReadOnlyList<Article>> GetAllAsync(CancellationToken ct = default);

    // Lookup methods (one per [Lookup] property)
    StoreQuery<Article> GetByAuthorId(AuthorId authorId);
}

For composite key entities, GetByIdAsync, DeleteAsync, and ExistsAsync accept all key properties as separate parameters. See [CompositeKey] for details.

Lookup methods

GetBy{Prop} methods are only generated for properties decorated with [Lookup]. They return a StoreQuery<T> with the lookup predicate pre-applied, so you can further filter, sort, and paginate. See [Lookup] for details.

Composable Query Builder

Every generated store exposes a Query() method that returns a StoreQuery<T> — an immutable, composable builder for filtering, sorting, and paginating.

Building Queries

Chain .Where(), .OrderBy(), or .OrderByDescending() to build up a query, then call a terminal method:

// Simple page with defaults
var page = await store.Query().ToPageAsync(1, 20);

// Filter + sort + paginate
var page = await store.Query()
    .Where(a => a.IsPublished)
    .OrderBy(a => a.Title)
    .ToPageAsync(1, 20);

// Filter with cursor pagination
var result = await store.Query()
    .Where(a => a.AuthorId == authorId)
    .ToCursorAsync(cursor, limit: 10);

// Get all matching items
var items = await store.Query()
    .Where(a => a.Category == "Tech")
    .OrderByDescending(a => a.CreatedAt)
    .ToListAsync();

// First match or null
var article = await store.Query()
    .Where(a => a.Slug == slug)
    .FirstOrDefaultAsync();

// Count matching items
var count = await store.Query()
    .Where(a => a.IsPublished)
    .CountAsync();

Multiple .Where() calls are ANDed together:

var page = await store.Query()
    .Where(a => a.IsPublished)
    .Where(a => a.Category == "Tech")
    .OrderBy(a => a.CreatedAt)
    .ToPageAsync(1, 20);

Terminal Methods

Method Return Type Description
ToPageAsync(page, pageSize) QueryResult<T> Offset-based pagination
ToCursorAsync(cursor, limit) CursorResult<T> Cursor-based pagination
ToListAsync() IReadOnlyList<T> All matching items
FirstOrDefaultAsync() T? First match or null
CountAsync() int Count of matches

Result Types

QueryResult<T> — Offset-Based Pagination

var result = await store.Query()
    .Where(a => a.IsPublished)
    .OrderBy(a => a.Title)
    .ToPageAsync(page: 1, pageSize: 10);

QueryResult<T> includes:

Property Type Description
Data IReadOnlyList<T> The page of results
TotalCount int Total number of matching entities
Page int Current page number
PageSize int Requested page size
TotalPages int Computed total pages
HasNextPage bool Whether more pages follow
HasPreviousPage bool Whether earlier pages exist

CursorResult<T> — Cursor-Based Pagination

var result = await store.Query()
    .Where(a => a.IsPublished)
    .ToCursorAsync(cursor: null, limit: 10);

CursorResult<T> includes:

Property Type Description
Data IReadOnlyList<T> The page of results
Cursor string? Opaque cursor for the next page
HasMore bool Whether more results exist

Stable Sorting

All pagination methods guarantee deterministic, stable results:

  • ToPageAsync defaults to sorting by primary key. Use .OrderBy() or .OrderByDescending() to override.
  • ToCursorAsync always sorts by primary key — the cursor is the primary key value, so the sort order is inherent to the pagination strategy. Any .OrderBy() on the builder is ignored.

In-Memory Implementation

A ConcurrentDictionary-backed implementation is generated for every entity:

public class InMemoryArticleStore : IArticleStore, IStoreQueryExecutor<Article> { ... }

This is registered by default via TryAddSingleton in the DI bootstrap — real implementations (e.g., Postgres) override it.

DI Registration

The generator creates a registration extension method:

// Generated
public static IServiceCollection AddAppStore(this IServiceCollection services)
{
    services.TryAddSingleton<IArticleStore, InMemoryArticleStore>();
    services.TryAddSingleton<ICommentStore, InMemoryCommentStore>();
    return services;
}

TryAddSingleton means concrete implementations registered first take priority.

Injecting and Using Directly

Use the generated interface with standard constructor injection:

public class ArticleService(IArticleStore store)
{
    public async Task<Article?> GetArticle(ArticleId id, CancellationToken ct)
        => await store.GetByIdAsync(id, ct);

    public async Task<QueryResult<Article>> ListArticles(int page, CancellationToken ct)
        => await store.Query().ToPageAsync(page, pageSize: 20, ct);

    public async Task<IReadOnlyList<Article>> GetPublished(CancellationToken ct)
        => await store.Query()
            .Where(a => a.IsPublished)
            .OrderByDescending(a => a.CreatedAt)
            .ToListAsync(ct);
}

Swapping Implementations

Register a concrete implementation before calling the generated Add* method — TryAddSingleton won't override it:

// Postgres store takes precedence over in-memory
services.AddSingleton<IArticleStore, PgArticleStore<AppStoreDbContext>>();
services.AddAppStore(); // InMemoryArticleStore won't override PgArticleStore

The Deepstaging.Data.Postgres package generates EF Core implementations automatically. See Effects Composition → Postgres for setup details.

Postgres DbContext

The Postgres generator produces a {Store}DbContext with several auto-configured features beyond DbSet properties and key configuration.

ConfigureConventions — TypedId Value Converters

All [TypedId] types used across entity properties (including those on owned collection element types) are automatically registered with EF Core value converters:

protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder)
{
    configurationBuilder.Properties<ArticleId>().HaveConversion<ArticleId.EfCoreValueConverter>();
    configurationBuilder.Properties<CommentId>().HaveConversion<CommentId.EfCoreValueConverter>();
}

This means you never need to manually register value converters for your [TypedId] types — the generator discovers and registers them all.

OwnsMany — Auto-Configured Owned Collections

List<T> properties where T is not a [StoredEntity] are automatically configured as EF Core owned types via OwnsMany:

[StoredEntity]
public partial record Order(OrderId Id, string Customer, List<OrderItem> Items);

public record OrderItem(string ProductName, int Quantity, decimal Price);

The generator produces:

modelBuilder.Entity<Order>(e =>
    e.OwnsMany(o => o.Items, owned =>
    {
        owned.WithOwner().HasForeignKey("OrderId");
        owned.Property<int>("Id").ValueGeneratedOnAdd();
        owned.HasKey("Id");
    }));

When to use owned collections

Use owned collections for value-type child data that doesn't have its own identity (e.g., order line items, address history). If the child type has a [TypedId] and needs its own store, use [StoredEntity] with [ForeignKey<T>] instead.

OnModelCreatingPartial Hook

The generated DbContext calls a partial method at the end of OnModelCreating, allowing you to add custom EF Core configuration without modifying generated code:

public partial class AppStoreDbContext : DbContext
{
    partial void OnModelCreatingPartial(ModelBuilder modelBuilder);

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        // ... generated key config, indexes, owned types ...
        OnModelCreatingPartial(modelBuilder);
    }
}

Implement the partial method in your own file:

public partial class AppStoreDbContext
{
    partial void OnModelCreatingPartial(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<Article>()
            .Property(a => a.Title)
            .HasMaxLength(200);
    }
}

Don't override OnModelCreating

Use OnModelCreatingPartial instead of overriding OnModelCreating — the generator owns that method and will overwrite your changes.

Bring Your Own DbContext

The generated {Store}DbContext is convenient for prototyping, but most applications will want a single shared DbContext. The generator produces ModelBuilder extension methods that let you apply all entity configuration to your own context:

public class MyAppDbContext(DbContextOptions<MyAppDbContext> options) : DbContext(options)
{
    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        base.OnModelCreating(modelBuilder);
        modelBuilder.ConfigureAppStore(); // applies keys, indexes, owned types
    }

    protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder)
    {
        base.ConfigureConventions(configurationBuilder);
        configurationBuilder.ConfigureAppStoreConventions(); // registers TypedId converters
    }
}

Then register using the generic overload — it wires up the store implementations without creating a separate DbContext:

services.AddDbContext<MyAppDbContext>(options => options.UseNpgsql(connectionString));
services.AddAppStorePostgres<MyAppDbContext>();

The extensions are the same ones the generated DbContext uses internally, so both paths produce identical entity configuration.