SuperAPI AI Directory

aspnet-rest-apis

Coding & development

View repository

Updated 10-15 21:38

About this skill

Blueprint .NET API Patterns

Feature folder structure

Queries live in Api/Features/{Feature}/, commands and domain logic in Domain/{Feature}/Commands/:

Api/Features/Products/
  GetProducts.cs              // list query — static class
  GetProductDetails.cs        // detail query — static class
  IsProductCodeUnique.cs      // custom query — static class
  ProductsController.cs       // thin — only mediator.Send()

Domain/Products/Commands/
  ProductCreateOrUpdate.cs              // static class: Command, Result, Validator
  ProductCreateOrUpdateCommandHandler.cs // separate file
  RemoveProduct.cs

Queries — static class in Api/Features/

public static class GetProductDetails
{
    [PublicAPI]
    public class Request : IQuery<Response>
    {
        public Guid Id { get; set; }
    }

    [PublicAPI]
    public class Response
    {
        public Guid Id { get; set; }
        public string Name { get; set; } = String.Empty;
        public ProductType Type { get; set; }
    }

    [UsedImplicitly]
    public class MappingProfile : Profile
    {
        public MappingProfile() => CreateMap<Product, Response>();
    }

    [UsedImplicitly]
    public class RequestHandler(IRepository<Product> productRepository, IMapper mapper)
        : IRequestHandler<Request, Response>
    {
        public async Task<Response> Handle(Request request, CancellationToken cancellationToken)
        {
            var response = await productRepository.QueryAll()
                .QueryById(request.Id)
                .SingleOrDefaultMappedAsync<Product, Response>(mapper, cancellationToken: cancellationToken);
            return response;
        }
    }
}

For list queries, use PagedRequest and IPagedRequestHandler:

public static class GetProducts
{
    [PublicAPI]
    public class Request : PagedRequest<Response.Item>, IQuery<PagedResponse<Response.Item>>
    {
        public string? Name { get; set; }
    }

    [PublicAPI]
    public static class Response
    {
        [PublicAPI]
        public class Item { public Guid Id { get; set; } ... }

        [UsedImplicitly]
        public class MappingProfile : Profile
        {
            public MappingProfile() => CreateMap<Product, Item>();
        }
    }

    [UsedImplicitly]
    public class RequestHandler : IPagedRequestHandler<Request, Response.Item>
    {
        public async Task<PagedResponse<Response.Item>> Handle(Request request, CancellationToken cancellationToken) =>
            await _productRepository.QueryAll()
                .ProjectTo<Response.Item>(_mapper.ConfigurationProvider, cancellationToken)
                .ToPagedResponseAsync(request, cancellationToken);
    }
}

Commands — static class in Domain/{Feature}/Commands/

public static class ProductCreateOrUpdate
{
    [PublicAPI]
    public class Command : IRequest<Result>
    {
        public Guid? Id { get; set; }
        public string Name { get; set; } = String.Empty;
        public ProductType Type { get; set; }
    }

    [PublicAPI]
    public class Result
    {
        public Guid Id { get; set; }
    }

    [UsedImplicitly]
    public class Validator : AbstractValidator<Command>
    {
        public Validator()
        {
            RuleFor(x => x.Name).NotEmpty().MaximumLength(Product.NameMaxLength);
            When(x => x.Type is ProductType.Food, () =>
            {
                RuleFor(x => x.ExpiresOn).NotNull();
            });
        }
    }
}

The command handler lives in a separate file in the same folder:

[UsedImplicitly]
public class ProductCreateOrUpdateCommandHandler
    : IRequestHandler<ProductCreateOrUpdate.Command, ProductCreateOrUpdate.Result>
{
    private readonly IRepository<Product, Guid> _productRepository;

    public ProductCreateOrUpdateCommandHandler(IRepository<Product, Guid> productRepository)
    {
        _productRepository = productRepository;
    }

    public async Task<ProductCreateOrUpdate.Result> Handle(
        ProductCreateOrUpdate.Command request, CancellationToken cancellationToken)
    {
        Product result;
        if (request.Id.HasValue)
        {
            result = await _productRepository.FindByIdAsync(request.Id.Value)
                     ?? throw new InvalidOperationException("Could not find product by Id");
            result.Update(request);
        }
        else
        {
            result = Product.Create(request);
            _productRepository.Add(result);
        }

        return new ProductCreateOrUpdate.Result { Id = result.Id };
    }
}

Domain entities

Entities inherit from EntityWithCreatedUpdated, use private setters, expose a Create(Command) factory and an Update(Command) method, and raise domain events via AddDomainEvent():

public class Product : EntityWithCreatedUpdated
{
    public string Name { get; private set; } = String.Empty;
    public ProductStatus Status { get; private set; } = ProductStatus.Active;

    public static Product Create(ProductCreateOrUpdate.Command request)
    {
        var product = new Product { Name = request.Name, Status = ProductStatus.Active };
        product.AddDomainEvent(new ProductCreatedDomainEvent(product));
        return product;
    }

    public void Update(ProductCreateOrUpdate.Command request)
    {
        Name = request.Name;
        AddDomainEvent(new ProductUpdatedDomainEvent(this));
    }
}

Controllers — always thin

[Produces(MediaTypeNames.Application.Json)]
[Route("api/[controller]")]
public class ProductsController(IMediator mediator) : Controller
{
    [HttpGet]
    [ProducesResponseType(StatusCodes.Status200OK)]
    [UserHasPermission(PermissionId.ProductsRead)]
    public async Task<ActionResult<PagedResponse<GetProducts.Response.Item>>> Search(
        [FromQuery] GetProducts.Request query)
    {
        var response = await mediator.Send(query);
        return response.ToActionResult();
    }

    [HttpPost]
    [ProducesResponseType(typeof(ValidationProblemDetails), StatusCodes.Status400BadRequest)]
    [UserHasPermission(PermissionId.ProductsWrite)]
    public async Task<ActionResult<ProductCreateOrUpdate.Result>> Post(
        ProductCreateOrUpdate.Command command)
    {
        var result = await mediator.Send(command);
        return result;
    }
}
  • Use primary-constructor injection for IMediator.
  • Return response.ToActionResult() for queries; return result directly for commands.
  • Decorate every endpoint with [UserHasPermission(PermissionId.X)].
  • Never put business logic in a controller.

Autofac modules

Register services in Autofac modules, not via IServiceCollection:

public class MyFeatureModule : Module
{
    protected override void Load(ContainerBuilder builder)
    {
        builder.RegisterType<MyService>().As<IMyService>().InstancePerLifetimeScope();
    }
}

Any class whose name ends with Service is auto-registered by ServiceModule — no manual registration needed for those.

EF Core configurations

public class ProductConfiguration : IEntityTypeConfiguration<Product>
{
    public void Configure(EntityTypeBuilder<Product> builder)
    {
        builder.Property(x => x.Name).IsRequired().HasMaxLength(Product.NameMaxLength);
        builder.Property(x => x.Status)
            .HasSentinel(ProductStatus.Active)
            .HasDefaultValue(ProductStatus.Active);
        builder.HasIndex(x => x.Code).IsUnique();
        builder.HasCreatedByAndUpdatedBy();
    }
}

What NOT to do

  • Do not put logic in controllers — use IRequestHandler<T>.
  • Do not use services.AddSingleton/Scoped for services — use Autofac modules.
  • Do not add ! null-forgiving operators.
  • Do not use static Log.Information(...) — inject ILogger<T>.
  • Do not create a command/query without a validator when it has user inputs.
  • Do not add Version= to a <PackageReference> in a .csproj — all versions go in Directory.Packages.props.
  • Do not omit [PublicAPI] on request/response/DTO types or [UsedImplicitly] on handlers and validators.