
scaffold-projection
by aalmada
Full-stack .NET online book store application with event-sourced backend API and Blazor frontend, orchestrated by Aspire.
SKILL.md
name: scaffold-projection description: Create a new Marten read model projection for CQRS queries. Use this when you need to create an optimized read model from event streams.
Follow this guide to create a new Marten projection (read model) for efficient queries in the ApiService.
-
Define the Projection Class
- Create a
classinsrc/BookStore.ApiService/Projections/ - Naming:
{Resource}Projection(e.g.,AuthorProjection) - Inheritance: Inherit from appropriate Marten base or use
[ViewProjection] - Template:
namespace BookStore.ApiService.Projections; public class AuthorProjection { public Guid Id { get; set; } public string Name { get; set; } = string.Empty; public string Biography { get; set; } = string.Empty; public bool Deleted { get; set; } public int Version { get; set; } public DateTimeOffset CreatedAt { get; set; } public DateTimeOffset? UpdatedAt { get; set; } // For localized content, use Dictionary<string, string> public Dictionary<string, string> Biographies { get; set; } = new(); }
- Create a
-
Implement Create Method
- Handle the initial event to create the projection
- Pattern: Static method returning projection instance
- Example:
public static AuthorProjection Create(AuthorCreated @event) { return new AuthorProjection { Id = @event.Id, Name = @event.Name, Biography = @event.Biography, CreatedAt = @event.CreatedAt, Version = 1 }; }
-
Implement Apply Methods
- Handle subsequent events to update the projection
- Pattern: Instance method, returns void, single event parameter
- Example:
public void Apply(AuthorUpdated @event) { Name = @event.Name; Biography = @event.Biography; UpdatedAt = @event.UpdatedAt; Version++; } public void Apply(AuthorDeleted @event) { Deleted = true; UpdatedAt = @event.DeletedAt; Version++; } public void Apply(AuthorRestored @event) { Deleted = false; UpdatedAt = @event.RestoredAt; Version++; }
-
Configure Marten Projection
-
Open
src/BookStore.ApiService/Infrastructure/Extensions/MartenConfigurationExtensions.cs -
Locate
RegisterProjections(StoreOptions options)method -
Register the projection:
static void RegisterProjections(StoreOptions options) { // Existing configuration... // Add inline projection options.Projections.Add<AuthorProjection>(ProjectionLifecycle.Inline); // OR for async projection (recommended for production) options.Projections.Add<AuthorProjection>(ProjectionLifecycle.Async); });[!NOTE] For Async projections, ensure you also register the
ProjectionCommitListenerto trigger SSE notifications.
-
-
Add Indexing (Optional but Recommended)
- Inside
MartenConfigurationExtensions.cs, locateConfigureIndexes(StoreOptions options):static void ConfigureIndexes(StoreOptions options) { options.Schema.For<AuthorProjection>() .Index(x => x.Name) // For name searches .Index(x => x.Deleted) // For filtering deleted items .GinIndexJsonData(); // For full-text search (if using JSONB) }
- Inside
-
Create Queries Using the Projection
- In endpoints, query the projection (not the aggregate):
public static async Task<IResult> GetAuthors( IDocumentStore store, int page = 1, int pageSize = 20, CancellationToken cancellationToken = default) { await using var session = store.QuerySession(); var authors = await session.Query<AuthorProjection>() .Where(x => !x.Deleted) .OrderBy(x => x.Name) .ToPagedListAsync(page, pageSize, cancellationToken); return Results.Ok(authors); }
- In endpoints, query the projection (not the aggregate):
-
Add Caching (Recommended)
- Wrap projection queries with HybridCache:
public static async Task<IResult> GetAuthors( IDocumentStore store, HybridCache cache, int page = 1, int pageSize = 20, CancellationToken cancellationToken = default) { var cacheKey = $"authors:page:{page}:size:{pageSize}"; var authors = await cache.GetOrCreateAsync( cacheKey, async entry => { entry.SetOptions(new HybridCacheEntryOptions { Expiration = TimeSpan.FromMinutes(5), LocalCacheExpiration = TimeSpan.FromMinutes(1) }); await using var session = store.QuerySession(); return await session.Query<AuthorProjection>() .Where(x => !x.Deleted) .OrderBy(x => x.Name) .ToPagedListAsync(page, pageSize, cancellationToken); }, tags: [CacheTags.AuthorList], cancellationToken: cancellationToken ); return Results.Ok(authors); }
- Wrap projection queries with HybridCache:
-
Rebuild Projections (Development)
- To rebuild all projections from events:
curl -X POST http://localhost:5000/api/admin/projections/rebuild
- To rebuild all projections from events:
Projection Types
Inline Projections
- When: Real-time updates required
- Pro: Always up-to-date
- Con: Slower writes (blocks until projection updated)
- Use: Small datasets, critical consistency
Async Projections
- When: High write volume
- Pro: Non-blocking writes, better performance
- Con: Eventually consistent (slight delay)
- Use: Large datasets, acceptable eventual consistency
Localized Content Pattern
For multi-language support:
public class AuthorProjection
{
// Store all translations
public Dictionary<string, string> Biographies { get; set; } = new();
// Helper method to get localized value
public string GetBiography(string culture, string defaultCulture = "en")
{
return LocalizationHelper.GetLocalizedValue(
Biographies,
culture,
defaultCulture,
""
);
}
}
Performance Tips
- ✅ Index frequently queried fields (Name, Deleted, etc.)
- ✅ Use GIN indexes for JSONB search (full-text)
- ✅ Limit projection size (only include query-required fields)
- ✅ Use async projections for high-volume events
- ✅ Cache projection queries with appropriate invalidation
Troubleshooting
Projection Not Updating
- Check Marten configuration includes the projection
- Verify Apply method signatures match events exactly
- For async projections, check daemon is running
- Run projection rebuild:
POST /api/admin/projections/rebuild
Missing Data in Queries
- Ensure projection includes all required fields
- Check Apply methods handle all relevant events
- Verify indexes exist for queried fields
Related Skills
Prerequisites:
/scaffold-aggregate- Ensure events and aggregates exist first- Events should be defined before creating projections
Next Steps:
/scaffold-read- Create query endpoints using this projection/scaffold-write- Ensure MartenCommitListener sends SSE notifications/scaffold-test- Create integration tests/verify-feature- Complete verification
Related:
/debug-cache- If cached projection data is stale
See Also:
- scaffold-aggregate - Event and aggregate patterns
- scaffold-read - Query endpoint implementation
- scaffold-write - SSE notification setup
- marten-guide - Marten event store and projections
- ApiService AGENTS.md - Projection patterns and Marten configuration
Score
Total Score
Based on repository quality metrics
SKILL.mdファイルが含まれている
ライセンスが設定されている
100文字以上の説明がある
GitHub Stars 100以上
1ヶ月以内に更新
10回以上フォークされている
オープンIssueが50未満
プログラミング言語が設定されている
1つ以上のタグが設定されている
Reviews
Reviews coming soon

