Scaffolding guide for building vertical slice features in FinanceHub (.NET 10), including Use Cases, Minimal API endpoints, Command/Query handlers, FluentValidation, and financial reconciliation endpoints.
62
72%
Does it follow best practices?
Run evals on this skill
Adds up to 20 points to the overall score
View guide
Passed
No findings from the security scan
Fix and improve this skill with Tessl
tessl review fix ./.agents/skills/dotnet-vertical-slice/SKILL.mdThis guide provides explicit patterns for scaffolding new features using Vertical Slice Architecture in FinanceHub (.NET 10 / C# 13). Features are organized around domain business capabilities within their respective microservices rather than technical layers.
Each feature (Use Case) lives in its own self-contained directory under the target microservice project, e.g., src/Services/FinanceHub.TransactionAggregator/Features/<DomainGroup>/<UseCaseName>/.
src/Services/FinanceHub.TransactionAggregator/Features/Transactions/
└── ReconcileTransactions/
├── ReconcileTransactionsEndpoint.cs // Minimal API Route Mapping
├── ReconcileTransactionsCommand.cs // Request & Response Records (CQRS)
├── ReconcileTransactionsHandler.cs // Business Logic / Mediator Handler
├── ReconcileTransactionsValidator.cs // FluentValidation Rules
└── ReconcileTransactionsModels.cs // Response DTOs & EnumsUse immutable record types with explicit property types (e.g. decimal for monetary values, DateTimeOffset for timestamps).
namespace FinanceHub.TransactionAggregator.Features.Transactions.ReconcileTransactions;
public record ReconcileTransactionsCommand(
Guid AccountId,
DateTimeOffset StartDate,
DateTimeOffset EndDate,
IReadOnlyCollection<ExternalTransactionItem> ExternalTransactions
) : IRequest<Result<ReconcileTransactionsResult>>;
public record ExternalTransactionItem(
string ExternalId,
decimal Amount,
string Currency,
DateTimeOffset TransactionDate,
string Description,
string? FitId
);
public record ReconcileTransactionsResult(
Guid BatchId,
int MatchedCount,
int UnmatchedInternalCount,
int UnmatchedExternalCount,
decimal TotalMatchedAmount,
IReadOnlyCollection<ReconciliationDiscrepancy> Discrepancies
);
public record ReconciliationDiscrepancy(
string Type, // e.g. "AmountMismatch", "UnmatchedExternal", "UnmatchedInternal"
string Description,
decimal DiscrepancyAmount,
Guid? InternalTransactionId,
string? ExternalTransactionId
);Add strict validation rules to protect domain invariants prior to command execution:
namespace FinanceHub.TransactionAggregator.Features.Transactions.ReconcileTransactions;
public sealed class ReconcileTransactionsValidator : AbstractValidator<ReconcileTransactionsCommand>
{
public ReconcileTransactionsValidator()
{
RuleFor(x => x.AccountId)
.NotEmpty()
.WithMessage("AccountId is required.");
RuleFor(x => x.StartDate)
.LessThanOrEqualTo(x => x.EndDate)
.WithMessage("StartDate must be before or equal to EndDate.");
RuleFor(x => x.ExternalTransactions)
.NotNull();
RuleForEach(x => x.ExternalTransactions).ChildRules(item =>
{
item.RuleFor(t => t.ExternalId).NotEmpty();
item.RuleFor(t => t.Amount).NotEqual(0).WithMessage("Transaction amount cannot be zero.");
item.RuleFor(t => t.Currency).Length(3).WithMessage("Currency must be a valid 3-letter ISO code.");
});
}
}Inject domain repositories, microservice DbContext (TransactionAggregatorDbContext), or services. Enforce explicit transactional boundaries for financial integrity.
namespace FinanceHub.TransactionAggregator.Features.Transactions.ReconcileTransactions;
public sealed class ReconcileTransactionsHandler
: IRequestHandler<ReconcileTransactionsCommand, Result<ReconcileTransactionsResult>>
{
private readonly TransactionAggregatorDbContext _dbContext;
private readonly ILogger<ReconcileTransactionsHandler> _logger;
public ReconcileTransactionsHandler(
TransactionAggregatorDbContext dbContext,
ILogger<ReconcileTransactionsHandler> logger)
{
_dbContext = dbContext;
_logger = logger;
}
public async Task<Result<ReconcileTransactionsResult>> Handle(
ReconcileTransactionsCommand request,
CancellationToken cancellationToken)
{
_logger.LogInformation("Starting reconciliation for AccountId: {AccountId}", request.AccountId);
// Fetch internal ledger transactions for date range
var internalTransactions = await _dbContext.Transactions
.Where(t => t.AccountId == request.AccountId
&& t.TransactionDate >= request.StartDate
&& t.TransactionDate <= request.EndDate)
.ToListAsync(cancellationToken);
var discrepancies = new List<ReconciliationDiscrepancy>();
int matchedCount = 0;
decimal matchedTotal = 0m;
// Reconciliation matching logic
var unmatchedInternal = internalTransactions.ToDictionary(t => t.Id);
foreach (var ext in request.ExternalTransactions)
{
// Match rule: Exact amount + date within +/- 2 days + matching FitID or description key
var match = internalTransactions.FirstOrDefault(i =>
unmatchedInternal.ContainsKey(i.Id) &&
i.Amount == ext.Amount &&
Math.Abs((i.TransactionDate - ext.TransactionDate).TotalHours) <= 48);
if (match != null)
{
matchedCount++;
matchedTotal += match.Amount;
unmatchedInternal.Remove(match.Id);
}
else
{
discrepancies.Add(new ReconciliationDiscrepancy(
Type: "UnmatchedExternal",
Description: $"External transaction {ext.ExternalId} has no matching ledger entry.",
DiscrepancyAmount: ext.Amount,
InternalTransactionId: null,
ExternalTransactionId: ext.ExternalId
));
}
}
foreach (var remaining in unmatchedInternal.Values)
{
discrepancies.Add(new ReconciliationDiscrepancy(
Type: "UnmatchedInternal",
Description: $"Ledger transaction {remaining.Id} was not found in bank feed.",
DiscrepancyAmount: remaining.Amount,
InternalTransactionId: remaining.Id,
ExternalTransactionId: null
));
}
var result = new ReconcileTransactionsResult(
BatchId: Guid.NewGuid(),
MatchedCount: matchedCount,
UnmatchedInternalCount: unmatchedInternal.Count,
UnmatchedExternalCount: discrepancies.Count(d => d.Type == "UnmatchedExternal"),
TotalMatchedAmount: matchedTotal,
Discrepancies: discrepancies
);
return Result.Success(result);
}
}Map endpoints explicitly in Endpoint.cs using .NET 10 Minimal API style with OpenAPI metadata and typed results:
namespace FinanceHub.TransactionAggregator.Features.Transactions.ReconcileTransactions;
public static class ReconcileTransactionsEndpoint
{
public static void MapReconcileTransactionsEndpoint(this IEndpointRouteBuilder app)
{
app.MapPost("/api/v1/accounts/{accountId:guid}/reconcile", async (
Guid accountId,
ReconcileTransactionsCommand command,
ISender mediator,
CancellationToken ct) =>
{
if (accountId != command.AccountId)
{
return Results.BadRequest("Route accountId does not match request body.");
}
var result = await mediator.Send(command, ct);
return result.IsSuccess
? Results.Ok(result.Value)
: Results.Problem(detail: result.Error.Message, statusCode: StatusCodes.Status400BadRequest);
})
.WithName("ReconcileTransactions")
.WithTags("Transactions", "Reconciliation")
.Produces<ReconcileTransactionsResult>(StatusCodes.Status200OK)
.ProducesProblem(StatusCodes.Status400BadRequest)
.RequireAuthorization();
}
}Reconciliation endpoints must implement auditability and deterministic state matching:
Idempotency-Key HTTP header to prevent duplicate reconciliation batch creation.ReconciliationBatches and ReconciliationItems tables.Program.cs / MapEndpoints().WebApplicationFactory<Program> in tests/FinanceHub.TransactionAggregator.Tests/.dotnet build and dotnet test.a8dbf2a
If you maintain this skill, you can claim it as your own. Once claimed, you can manage eval scenarios, bundle related skills, attach documentation or rules, and ensure cross-agent compatibility.