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:
ToPageAsyncdefaults to sorting by primary key. Use.OrderBy()or.OrderByDescending()to override.ToCursorAsyncalways 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:
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.