name: add-feature
description: Add a vertical-slice feature (command/query + handler + validator + endpoint) to an existing FSH module. Use when adding an API endpoint or business operation to a module that already exists.
argument-hint: [ModuleName] [Area] [FeatureName]
Add Feature
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.
Layout (real)
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
Step 1 — Command/Query (Contracts project)
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.
Step 2 — Handler (runtime Features/) — inject the DbContext, NOT a repository
There 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.
Step 3 — Validator (required; same folder)
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.
Step 4 — Endpoint (same folder)
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();
}
Step 5 — Wire it in {X}Module.MapEndpoints
group.MapCreate{Entity}Endpoint();
Step 6 — Verify
dotnet build src/FSH.Starter.slnx
dotnet test src/Tests/{X}.Tests
Checklist