.agents/skills/add-feature/SKILL.md
A feature is a vertical slice split across two projects: the request/response types live in the
module's .Contracts project (public API); the handler, validator, and endpoint live in the runtime
project. Full conventions: .agents/rules/api-conventions.md.
src/Modules/{X}/Modules.{X}.Contracts/v1/{Area}/{Feature}Command.cs # ICommand<T>/IQuery<T>
src/Modules/{X}/Modules.{X}.Contracts/Dtos/{Entity}Dto.cs # response DTOs (if any)
src/Modules/{X}/Modules.{X}/Features/v1/{Area}/{Feature}/
├── {Feature}CommandHandler.cs # public sealed, injects the DbContext directly
├── {Feature}CommandValidator.cs # required for commands + paginated queries
└── {Feature}Endpoint.cs # internal static extension
Mediator interfaces (using Mediator;). Records. A create command can return the raw Guid.
namespace FSH.Modules.{X}.Contracts.v1.{Area};
public sealed record Create{Entity}Command(string Name, decimal PriceAmount, string PriceCurrency)
: ICommand<Guid>;
Read/list DTOs go in Modules.{X}.Contracts/Dtos/. Paginated queries return PagedResponse<T>
(FSH.Framework.Shared.Persistence) — see query-patterns.
Features/) — inject the DbContext, NOT a repositoryThere is no generic IRepository<T>. Inject the module's {X}DbContext. public sealed, primary
ctor, ValueTask<T>, .ConfigureAwait(false), guard first. Tenant/audit fields are auto-stamped — only
inject ICurrentUser if you need the acting user (GetUserId() / GetTenant()).
public sealed class Create{Entity}CommandHandler(CatalogDbContext dbContext)
: ICommandHandler<Create{Entity}Command, Guid>
{
public async ValueTask<Guid> Handle(Create{Entity}Command command, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(command);
var entity = {Entity}.Create(command.Name, new Money(command.PriceAmount, command.PriceCurrency));
dbContext.{Entities}.Add(entity);
await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
return entity.Id;
}
}
Throw NotFoundException / CustomException(msg, errors, HttpStatusCode) (FSH.Framework.Core.Exceptions) — the global handler maps them to ProblemDetails.
public sealed class Create{Entity}CommandValidator : AbstractValidator<Create{Entity}Command>
{
public Create{Entity}CommandValidator()
{
RuleFor(x => x.Name).NotEmpty().MaximumLength(200);
RuleFor(x => x.PriceCurrency).NotEmpty().Length(3);
}
}
Architecture.Tests fails the build if a command/paginated-query handler has no {Name}Validator.
public static class Create{Entity}Endpoint
{
internal static RouteHandlerBuilder MapCreate{Entity}Endpoint(this IEndpointRouteBuilder endpoints) =>
endpoints.MapPost("/{entities}",
async (Create{Entity}Command command, IMediator mediator, CancellationToken ct) =>
Results.Ok(await mediator.Send(command, ct)))
.WithName("Create{Entity}")
.WithSummary("Create a {entity}")
.RequirePermission({X}Permissions.{Entities}.Create)
.WithIdempotency(); // on replay-safe POSTs
}
{X}Module.MapEndpointsgroup.MapCreate{Entity}Endpoint(); // group = endpoints.MapGroup("api/v{version:apiVersion}/{x}") …
dotnet build src/FSH.Starter.slnx # 0 warnings (TreatWarningsAsErrors)
dotnet test src/Tests/{X}.Tests # + add a handler/validator test (see testing-guide)
using Mediator;), DTOs in Contracts/Dtos/public sealed, injects {X}DbContext (no repository), ValueTask<T> + .ConfigureAwait(false){Name}Validator existsinternal static …Map{Feature}Endpoint, .RequirePermission(...), .WithName/.WithSummary{X}Module.MapEndpoints