Common Development Tasks¶
This guide covers the most common tasks you'll perform while developing LBS Foundry. Each task includes step-by-step instructions and examples.
Quick Navigation¶
- Creating New Features
- Working with Data
- Testing Workflows
- Debugging & Troubleshooting
- Performance Optimization
- Deployment Tasks
- Documentation Tasks
- Common Patterns
- Event Versioning
Creating New Features¶
Adding a New Command¶
When to use: When you need to modify application state
IMPORTANT: ALL commands MUST have [RequiresRoles] attribute and CANNOT use RoleDefinition.Public
// 1. Create the command
[DataContract(Name = "UpdatePlayerSalary")]
[RequiresRoles(RoleDefinition.UserManager, RoleDefinition.Admin)] // REQUIRED on all commands
public sealed record UpdatePlayerSalaryCommand : DomainCommand<PlayerId>
{
[DataMember]
public decimal NewSalary { get; init; }
[DataMember]
public string Reason { get; init; } = string.Empty;
}
// 2. Add to aggregate
public void Execute(UpdatePlayerSalaryCommand command)
{
// Validation
if (command.NewSalary < 0)
throw new ArgumentException("Salary cannot be negative");
// Business logic
var previousSalary = this.Salary;
// Raise event
this.RaiseEvent(new PlayerSalaryUpdatedEvent
{
PlayerId = command.AggregateRootId,
PreviousSalary = previousSalary,
NewSalary = command.NewSalary,
Reason = command.Reason,
UpdatedAt = DateTime.UtcNow
});
}
// 3. Apply event
public void Apply(PlayerSalaryUpdatedEvent @event)
{
this.Salary = @event.NewSalary;
this.LastUpdated = @event.UpdatedAt;
}
// 4. Add tests
[Test]
public async Task UpdatePlayerSalary_WithValidData_ShouldUpdateSalary()
{
using (AuthorizationTestHelpers.SetTestUser(RoleDefinition.UserManager))
{
var command = new UpdatePlayerSalaryCommand
{
AggregateRootId = PlayerId.New(),
NewSalary = 150000,
Reason = "Performance bonus"
};
var events = await this.commandExecutor.ExecuteAsync(command, 1);
events.Should().HaveCount(1);
events[0].Should().BeOfType<PlayerSalaryUpdatedEvent>();
}
}
Adding a New Query¶
When to use: When you need to retrieve data for display
Choosing Query Security Type¶
IMPORTANT: ALL queries MUST implement either ISecureQuery OR IPublicQuery (NOT ISearchQuery directly)
Use ISecureQuery when: - Query returns sensitive or user-specific data - Authentication is required - Query results should be filtered based on user roles - Examples: User profiles, private competitions, financial data
Use IPublicQuery when: - Query returns publicly available information - No authentication required - Data is the same for all users (though may be enhanced for authenticated users) - Examples: Public leaderboards, public team listings, general statistics
Example 1: Secure Query (ISecureQuery)¶
For queries requiring authentication:
// 1. Create the query
using System.Runtime.Serialization;
using System.Security.Claims;
using LBS.Anchor;
using LBS.Anchor.Security;
using LBS.Augment.Authentication;
using LBS.Augment.Query;
[RequiresRoles(RoleDefinition.Member)]
[DataContract(Name = "TopPlayers")]
public sealed class TopPlayersQuery : ISecureQuery, IRequiresUserContext
{
public string QueryType => this.GetType().GetQueryTypeName();
public ClaimsPrincipal? User => UserContext.Current;
// Pagination and ordering live as properties on the query object.
public int Skip { get; set; }
public int? Take { get; set; }
public string? OrderBy { get; set; }
public int? Limit { get; set; } = 10;
public string? Position { get; set; }
public decimal? MinSalary { get; set; }
}
// 2. Create the handler.
// IQueryHandler<in TQuery, TResult> takes a single-argument HandleAsync(TQuery)
// and returns Task<(IEnumerable<TResult> Results, int TotalCount)>.
public class TopPlayersQueryHandler : IQueryHandler<TopPlayersQuery, PlayerContract>
{
private readonly IDocumentSession session;
private readonly ILogger<TopPlayersQueryHandler> logger;
public TopPlayersQueryHandler(IDocumentSession session, ILogger<TopPlayersQueryHandler> logger)
{
this.session = session;
this.logger = logger;
}
public async Task<(IEnumerable<PlayerContract> Results, int TotalCount)> HandleAsync(TopPlayersQuery query)
{
var queryBuilder = this.session.Query<PlayerContract>()
.Where(p => query.Position == null || p.Position == query.Position)
.Where(p => query.MinSalary == null || p.Salary >= query.MinSalary)
.OrderByDescending(p => p.TotalPoints);
var totalCount = await queryBuilder.CountAsync();
var results = await queryBuilder
.Skip(query.Skip)
.Take(query.Take ?? query.Limit ?? 10)
.ToListAsync();
return (results, totalCount);
}
}
// 3. Register the handler
services.AddScoped<IQueryHandler<TopPlayersQuery, PlayerContract>, TopPlayersQueryHandler>();
// 4. Add tests
[Test]
public async Task TopPlayersQuery_WithAuth_ShouldReturnPlayers()
{
using (AuthorizationTestHelpers.SetTestUser(RoleDefinition.Member))
{
await this.SeedTestPlayers();
var query = new TopPlayersQuery { Limit = 5, Take = 5 };
var handler = new TopPlayersQueryHandler(this.session, this.logger);
var (results, count) = await handler.HandleAsync(query);
results.Should().HaveCount(5);
count.Should().BeGreaterThan(0);
}
}
Example 2: Public Query (IPublicQuery)¶
For queries accessible without authentication:
// 1. Create the query
using System.Runtime.Serialization;
using System.Security.Claims;
using LBS.Anchor;
using LBS.Augment.Authentication;
using LBS.Augment.Query;
[DataContract(Name = "PublicLeaderboard")]
public sealed class PublicLeaderboardQuery : IPublicQuery, IRequiresUserContext
{
public string QueryType => this.GetType().GetQueryTypeName();
public ClaimsPrincipal? User => UserContext.Current;
// Pagination and ordering live as properties on the query object.
public int Skip { get; set; }
public int? Take { get; set; }
public string? OrderBy { get; set; }
public int? Limit { get; set; } = 10;
public string? Sport { get; set; }
}
// 2. Create the handler.
// IQueryHandler<in TQuery, TResult> takes a single-argument HandleAsync(TQuery)
// and returns Task<(IEnumerable<TResult> Results, int TotalCount)>.
public class PublicLeaderboardQueryHandler : IQueryHandler<PublicLeaderboardQuery, LeaderboardContract>
{
private readonly IDocumentSession session;
private readonly ILogger<PublicLeaderboardQueryHandler> logger;
public PublicLeaderboardQueryHandler(IDocumentSession session, ILogger<PublicLeaderboardQueryHandler> logger)
{
this.session = session;
this.logger = logger;
}
public async Task<(IEnumerable<LeaderboardContract> Results, int TotalCount)> HandleAsync(PublicLeaderboardQuery query)
{
var queryBuilder = this.session.Query<LeaderboardContract>()
.Where(l => query.Sport == null || l.Sport == query.Sport)
.OrderByDescending(l => l.Points);
// Optional: Enhance data for authenticated users
if (query.User?.Identity?.IsAuthenticated == true)
{
// Could add additional fields or personalized data
this.logger.LogDebug("Authenticated user accessing public leaderboard");
}
var totalCount = await queryBuilder.CountAsync();
var results = await queryBuilder
.Skip(query.Skip)
.Take(query.Take ?? query.Limit ?? 10)
.ToListAsync();
return (results, totalCount);
}
}
// 3. Register the handler
services.AddScoped<IQueryHandler<PublicLeaderboardQuery, LeaderboardContract>, PublicLeaderboardQueryHandler>();
// 4. Add tests
[Test]
public async Task PublicLeaderboardQuery_WithoutAuth_ShouldReturnData()
{
// Public queries work without authentication
await this.SeedTestLeaderboard();
var query = new PublicLeaderboardQuery { Limit = 5, Take = 5 };
var handler = new PublicLeaderboardQueryHandler(this.session, this.logger);
var (results, count) = await handler.HandleAsync(query);
results.Should().HaveCount(5);
count.Should().BeGreaterThan(0);
}
[Test]
public async Task PublicLeaderboardQuery_WithAuth_ShouldReturnEnhancedData()
{
// Test that authenticated users can also access public queries
using (AuthorizationTestHelpers.SetTestUser(RoleDefinition.Member))
{
await this.SeedTestLeaderboard();
var query = new PublicLeaderboardQuery { Limit = 5, Take = 5 };
var handler = new PublicLeaderboardQueryHandler(this.session, this.logger);
var (results, count) = await handler.HandleAsync(query);
results.Should().HaveCount(5);
}
}
Adding a New API Endpoint¶
When to use: When you need to expose functionality via REST API
// 1. Create request/response contracts
public sealed class UpdatePlayerSalaryRequest
{
public Guid PlayerId { get; set; }
public decimal NewSalary { get; set; }
public string Reason { get; set; } = string.Empty;
}
public sealed class UpdatePlayerSalaryResponse
{
public bool Success { get; set; }
public string Message { get; set; } = string.Empty;
}
// 2. Create the endpoint
public sealed class UpdatePlayerSalaryEndpoint : Endpoint<UpdatePlayerSalaryRequest, UpdatePlayerSalaryResponse>
{
private readonly ICommandExecutor commandExecutor;
private readonly ILogger<UpdatePlayerSalaryEndpoint> logger;
public UpdatePlayerSalaryEndpoint(ICommandExecutor commandExecutor, ILogger<UpdatePlayerSalaryEndpoint> logger)
{
this.commandExecutor = commandExecutor;
this.logger = logger;
}
public override void Configure()
{
this.Put("/api/players/{PlayerId}/salary");
this.AuthSchemes(AuthenticationExtensions.DefaultScheme);
this.Description(d => d
.WithTags("Players")
.WithSummary("Update player salary")
.WithDescription("Updates a player's salary with audit trail"));
}
public override async Task HandleAsync(UpdatePlayerSalaryRequest req, CancellationToken ct)
{
try
{
var command = new UpdatePlayerSalaryCommand
{
AggregateRootId = new PlayerId(req.PlayerId),
NewSalary = req.NewSalary,
Reason = req.Reason
};
await this.commandExecutor.ExecuteAsync(command, 0);
await this.SendOkAsync(new UpdatePlayerSalaryResponse
{
Success = true,
Message = "Player salary updated successfully"
}, ct);
}
catch (Exception ex)
{
this.logger.LogError(ex, "Failed to update player salary");
await this.SendAsync(new UpdatePlayerSalaryResponse
{
Success = false,
Message = "Failed to update player salary"
}, 400, ct);
}
}
}
Working with Data¶
Adding Sample Data¶
// Create a seeding service
public class PlayerSeedingService : ISeedingService
{
private readonly ICommandExecutor commandExecutor;
public PlayerSeedingService(ICommandExecutor commandExecutor)
{
this.commandExecutor = commandExecutor;
}
public async Task<SeedingResult> SeedAsync()
{
using (UserContext.RunAsSystem())
{
var commands = new List<IDomainCommand>
{
new CreatePlayerCommand
{
AggregateRootId = PlayerId.New(),
PlayerName = "Test Player 1",
Salary = 100000,
Position = "Halfback"
},
new CreatePlayerCommand
{
AggregateRootId = PlayerId.New(),
PlayerName = "Test Player 2",
Salary = 120000,
Position = "Fullback"
}
};
foreach (var command in commands)
{
await this.commandExecutor.ExecuteAsync(command, 0);
}
return SeedingResult.Success($"Seeded {commands.Count} players");
}
}
}
// Register the seeding service
services.AddSeedingService<PlayerSeedingService>();
Evolving the Database Schema¶
There is no standalone migrations project. Marten owns the schema and applies it on application startup, so schema changes are expressed in code (document/event registrations, indexes) rather than as hand-written migration scripts. See Database schema management below for how the schema is applied.
To add indexes or other schema configuration, contribute an IConfigureMarten
implementation. Marten picks these up when it builds the store, and the changes
are applied on the next app start.
// Add schema configuration via IConfigureMarten
public class AddPlayerIndexes : IConfigureMarten
{
public void Configure(IServiceProvider services, StoreOptions options)
{
// Add indexes for better query performance
options.Schema.For<PlayerContract>()
.Index(x => x.TeamId)
.Index(x => x.Position)
.Index(x => x.Salary);
}
}
// Register in DI
services.AddSingleton<IConfigureMarten, AddPlayerIndexes>();
Testing Workflows¶
Running Tests¶
These are xUnit v3 tests, so run them with dotnet run against the test project
(not dotnet test). xUnit v3 takes filter flags after a -- separator.
# Run the whole unit-test project
dotnet run --project src/Tests/LBS.UnitTests/LBS.UnitTests.csproj
# Run only the tests in a specific class (fully qualified name)
dotnet run --project src/Tests/LBS.UnitTests/LBS.UnitTests.csproj -- \
--filter-class "LBS.UnitTests.Players.TopPlayersQueryTests"
# Run a single test by method name (supports wildcards)
dotnet run --project src/Tests/LBS.UnitTests/LBS.UnitTests.csproj -- \
--filter-method "*UpdatePlayerSalary_WithValidData_ShouldUpdateSalary"
Writing Integration Tests¶
[TestFixture]
public class PlayerIntegrationTests : IntegrationTestBase
{
[Test]
public async Task CreatePlayer_EndToEnd_ShouldWork()
{
// Arrange
using (AuthorizationTestHelpers.SetTestUser(RoleDefinition.Member))
{
var request = new CreatePlayerRequest
{
PlayerName = "Integration Test Player",
Salary = 100000,
Position = "Halfback"
};
// Act - Call API endpoint
var response = await this.httpClient.PostAsJsonAsync("/api/players", request);
// Assert - Verify response
response.StatusCode.Should().Be(HttpStatusCode.Created);
// Verify data was persisted
var playerId = await this.GetCreatedPlayerId(response);
var player = await this.LoadPlayer(playerId);
player.Should().NotBeNull();
player!.PlayerName.Should().Be(request.PlayerName);
}
}
}
Debugging & Troubleshooting¶
Debugging Commands¶
// Add logging to command execution
public void Execute(CreatePlayerCommand command)
{
this.logger.LogInformation("Creating player: {PlayerName}", command.PlayerName);
try
{
// Command logic here
this.RaiseEvent(new PlayerCreatedEvent { /* ... */ });
this.logger.LogInformation("Successfully created player: {PlayerId}", command.AggregateRootId);
}
catch (Exception ex)
{
this.logger.LogError(ex, "Failed to create player: {PlayerName}", command.PlayerName);
throw;
}
}
Debugging Queries¶
// Add query logging
public async Task<(IEnumerable<PlayerContract> Results, int TotalCount)> HandleAsync(PlayerSearchQuery query)
{
this.logger.LogDebug("Executing PlayerSearchQuery with term: {SearchTerm}", query.SearchTerm);
var stopwatch = Stopwatch.StartNew();
try
{
var results = await this.BuildQuery(query).ToListAsync();
this.logger.LogInformation("PlayerSearchQuery completed in {ElapsedMs}ms, returned {Count} results",
stopwatch.ElapsedMilliseconds, results.Count);
return (results, results.Count);
}
catch (Exception ex)
{
this.logger.LogError(ex, "PlayerSearchQuery failed after {ElapsedMs}ms", stopwatch.ElapsedMilliseconds);
throw;
}
}
Using the Aspire Dashboard¶
# Start with Aspire dashboard
dotnet run --project src/Aspire/LBS.AspireHost/LBS.AspireHost.csproj
# Navigate to dashboard
open http://localhost:15000
# Monitor:
# - Service health and logs
# - Database connections
# - HTTP requests and responses
# - Background job execution
Performance Optimization¶
Optimizing Queries¶
// Good - Efficient query with proper indexing
public async Task<IEnumerable<PlayerContract>> GetPlayersByTeamAsync(TeamId teamId)
{
return await this.session
.Query<PlayerContract>()
.Where(p => p.TeamId == teamId.Value) // Uses index
.OrderBy(p => p.PlayerName)
.ToListAsync();
}
// Bad - Loads all data then filters
public async Task<IEnumerable<PlayerContract>> GetPlayersByTeamSlowAsync(TeamId teamId)
{
var allPlayers = await this.session.Query<PlayerContract>().ToListAsync();
return allPlayers.Where(p => p.TeamId == teamId.Value);
}
Optimizing Commands¶
// Good - Batch operations when possible
public async Task UpdateMultiplePlayersAsync(IEnumerable<UpdatePlayerCommand> commands)
{
using var session = this.store.LightweightSession();
foreach (var command in commands)
{
await this.commandExecutor.ExecuteAsync(command, 0);
}
await session.SaveChangesAsync(); // Single database round trip
}
Deployment Tasks¶
Local Development¶
# Start all services
dotnet run --project src/Aspire/LBS.AspireHost/LBS.AspireHost.csproj
# Start just the API
dotnet run --project src/Apps/LBS.Api/LBS.Api.csproj
# Start just the frontend (from repo root)
pnpm web:dev
Database Schema Management¶
There is no separate migrations project to run. Marten manages the PostgreSQL
schema directly: the Foundry store is configured with
AutoCreateSchemaObjects = AutoCreate.All and .ApplyAllDatabaseChangesOnStartup(),
so every required table, index, and event-store object is created or patched
automatically when the host (e.g. LBS.Api) starts. See
src/Domain/LBS.Domain.Infrastructure/ServiceConfiguration/MartenConfiguration.cs.
# Apply the schema by starting the API (Marten applies all changes on startup)
dotnet run --project src/Apps/LBS.Api/LBS.Api.csproj
# Or start the full local stack via Aspire (also applies the schema on startup)
dotnet run --project src/Aspire/LBS.AspireHost/LBS.AspireHost.csproj
# Reset database (development only) — the next app start recreates the schema
dropdb lbs_foundry_dev && createdb lbs_foundry_dev
dotnet run --project src/Apps/LBS.Api/LBS.Api.csproj
# Backup database
pg_dump -h localhost -U lbs_dev lbs_foundry_dev > backup.sql
# Restore database
psql -h localhost -U lbs_dev lbs_foundry_dev < backup.sql
Documentation Tasks¶
Updating API Documentation¶
API documentation is automatically generated from your code:
public override void Configure()
{
this.Put("/api/players/{PlayerId}/salary");
this.Description(d => d
.WithTags("Players")
.WithSummary("Update player salary")
.WithDescription("Updates a player's salary with complete audit trail")
.Accepts<UpdatePlayerSalaryRequest>("application/json")
.Produces<UpdatePlayerSalaryResponse>(200)
.ProducesProblem(400)
.ProducesProblem(401)
.ProducesProblem(403));
}
Adding Code Documentation¶
/// <summary>
/// Updates a player's salary with audit trail.
/// Requires UserManager or Admin role for authorization.
/// </summary>
/// <param name="command">The salary update command containing new salary and reason</param>
/// <returns>Domain events representing the salary change</returns>
/// <exception cref="ArgumentException">Thrown when salary is negative</exception>
/// <exception cref="UnauthorizedAccessException">Thrown when user lacks required roles</exception>
[RequiresRoles(RoleDefinition.UserManager, RoleDefinition.Admin)]
public void Execute(UpdatePlayerSalaryCommand command)
{
// Implementation
}
Common Patterns¶
Error Handling¶
// In commands
public void Execute(CreatePlayerCommand command)
{
// Validate inputs
if (string.IsNullOrWhiteSpace(command.PlayerName))
throw new ArgumentException("Player name is required", nameof(command.PlayerName));
if (command.Salary < 0)
throw new ArgumentException("Salary cannot be negative", nameof(command.Salary));
// Business rule validation
if (this.IsPlayerNameTaken(command.PlayerName))
throw new InvalidOperationException($"Player name '{command.PlayerName}' is already taken");
// Execute command
this.RaiseEvent(new PlayerCreatedEvent { /* ... */ });
}
// In endpoints
public override async Task HandleAsync(CreatePlayerRequest req, CancellationToken ct)
{
try
{
var command = this.MapToCommand(req);
await this.commandExecutor.ExecuteAsync(command, 0);
await this.SendCreatedAsync(new CreatePlayerResponse { Success = true }, ct);
}
catch (ArgumentException ex)
{
await this.SendAsync(new CreatePlayerResponse
{
Success = false,
Message = ex.Message
}, 400, ct);
}
catch (UnauthorizedAccessException)
{
await this.SendForbiddenAsync(ct);
}
catch (Exception ex)
{
this.logger.LogError(ex, "Failed to create player");
await this.SendAsync(new CreatePlayerResponse
{
Success = false,
Message = "Internal server error"
}, 500, ct);
}
}
Mapping Patterns¶
// Request to Command mapping
private CreatePlayerCommand MapToCommand(CreatePlayerRequest request)
{
return new CreatePlayerCommand
{
AggregateRootId = PlayerId.New(),
PlayerName = request.PlayerName?.Trim(),
Salary = request.Salary,
Position = request.Position?.Trim(),
TeamId = request.TeamId != null ? new TeamId(request.TeamId.Value) : null
};
}
// Event to Contract mapping (in projection builders)
public void Apply(PlayerCreatedEvent @event, PlayerContract contract)
{
contract.Id = @event.PlayerId.Value;
contract.PlayerName = @event.PlayerName;
contract.Salary = @event.Salary;
contract.Position = @event.Position;
contract.CreatedAt = @event.Timestamp;
}
This guide covers the most common development tasks. For more specific scenarios, check the other guides in our developer documentation or ask in the development channel!