Back to Dotnet Starter Kit

Add Feature

.agents/skills/add-feature/SKILL.md

10.0.04.2 KB
Original Source

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.

csharp
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()).

csharp
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)

csharp
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)

csharp
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
}

Step 5 — Wire it in {X}Module.MapEndpoints

csharp
group.MapCreate{Entity}Endpoint();   // group = endpoints.MapGroup("api/v{version:apiVersion}/{x}") …

Step 6 — Verify

bash
dotnet build src/FSH.Starter.slnx          # 0 warnings (TreatWarningsAsErrors)
dotnet test src/Tests/{X}.Tests            # + add a handler/validator test (see testing-guide)

Checklist

  • Command/Query in the Contracts project (using Mediator;), DTOs in Contracts/Dtos/
  • Handler public sealed, injects {X}DbContext (no repository), ValueTask<T> + .ConfigureAwait(false)
  • {Name}Validator exists
  • Endpoint internal static …Map{Feature}Endpoint, .RequirePermission(...), .WithName/.WithSummary
  • Wired in {X}Module.MapEndpoints
  • Build 0 warnings; test added