scaffold-projection
Create a new Marten read model projection for CQRS queries. Use this when you need to create an optimized read model from event streams.
Installation
Follow the repository instructions for this skill and your agent. Review the files and dependencies before adding it to your workflow.
View source ↗GuidesSkill instructions
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
Examples
Read the usage examples in the source repository.
View source ↗Source history
- Indexed on
- 2026-01-20
- Index updated
- 2026-01-26
- Branch
- main
Index and sync dates describe this directory. View the source repository for code history.
GitHub ↗chat Comments (0)
Sign in to join the discussion and leave a comment.
No comments yet. Be the first to share your thoughts!