skills/architecture/vertical-slice/SKILL.md
Use when implementing Vertical Slice Architecture with feature folders and co-located code.
npx skillsauth add faysilalshareef/dotnet-ai-kit vertical-sliceInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Common/ or Shared/ — extract only when duplication is provensrc/{Company}.{Domain}.WebApi/
Features/
Orders/
CreateOrder.cs # Command + Handler + Validator
GetOrder.cs # Query + Handler
ListOrders.cs # Query + Handler + Filter
OrderEndpoints.cs # Endpoint group registration
OrderResponse.cs # Shared response DTO (if reused)
Products/
CreateProduct.cs
GetProduct.cs
ProductEndpoints.cs
Common/
PagedList.cs
ValidationBehavior.cs
Program.cs
// Features/Orders/CreateOrder.cs
namespace {Company}.{Domain}.Features.Orders;
public static class CreateOrder
{
public sealed record Command(
string CustomerName,
List<LineItem> Items) : IRequest<Result>;
public sealed record LineItem(Guid ProductId, int Quantity);
public sealed record Result(Guid OrderId);
public sealed class Validator : AbstractValidator<Command>
{
public Validator()
{
RuleFor(x => x.CustomerName).NotEmpty().MaximumLength(200);
RuleFor(x => x.Items).NotEmpty();
RuleForEach(x => x.Items).ChildRules(item =>
{
item.RuleFor(x => x.Quantity).GreaterThan(0);
});
}
}
internal sealed class Handler(AppDbContext db)
: IRequestHandler<Command, Result>
{
public async Task<Result> Handle(
Command request, CancellationToken ct)
{
var order = new Order
{
CustomerName = request.CustomerName,
Items = request.Items.Select(i => new OrderItem
{
ProductId = i.ProductId,
Quantity = i.Quantity
}).ToList()
};
db.Orders.Add(order);
await db.SaveChangesAsync(ct);
return new Result(order.Id);
}
}
}
// Features/Orders/ListOrders.cs
namespace {Company}.{Domain}.Features.Orders;
public static class ListOrders
{
public sealed record Query(
string? CustomerName,
int Page = 1,
int PageSize = 20) : IRequest<PagedList<OrderSummary>>;
public sealed record OrderSummary(
Guid Id, string CustomerName, decimal Total, DateTime CreatedAt);
internal sealed class Handler(AppDbContext db)
: IRequestHandler<Query, PagedList<OrderSummary>>
{
public async Task<PagedList<OrderSummary>> Handle(
Query request, CancellationToken ct)
{
var query = db.Orders.AsNoTracking();
if (!string.IsNullOrEmpty(request.CustomerName))
query = query.Where(o =>
o.CustomerName.Contains(request.CustomerName));
var totalCount = await query.CountAsync(ct);
var items = await query
.OrderByDescending(o => o.CreatedAt)
.Skip((request.Page - 1) * request.PageSize)
.Take(request.PageSize)
.Select(o => new OrderSummary(
o.Id, o.CustomerName, o.Total, o.CreatedAt))
.ToListAsync(ct);
return new PagedList<OrderSummary>(
items, totalCount, request.Page, request.PageSize);
}
}
}
// Features/Orders/OrderEndpoints.cs
namespace {Company}.{Domain}.Features.Orders;
public sealed class OrderEndpoints : IEndpointGroup
{
public void MapEndpoints(IEndpointRouteBuilder app)
{
var group = app.MapGroup("/orders")
.WithTags("Orders")
.RequireAuthorization();
group.MapPost("/", async (
CreateOrder.Command cmd, ISender sender) =>
{
var result = await sender.Send(cmd);
return TypedResults.Created(
$"/orders/{result.OrderId}", result);
}).WithSummary("Create a new order");
group.MapGet("/", async (
[AsParameters] ListOrders.Query query, ISender sender) =>
{
var result = await sender.Send(query);
return TypedResults.Ok(result);
}).WithSummary("List orders with filtering");
group.MapGet("/{id:guid}", async (
Guid id, ISender sender) =>
{
var result = await sender.Send(new GetOrder.Query(id));
return result is not null
? TypedResults.Ok(result)
: TypedResults.NotFound();
}).WithSummary("Get order by ID");
}
}
// Extract ONLY when the same logic appears in 3+ slices
// Common/Extensions/QueryableExtensions.cs
public static class QueryableExtensions
{
public static async Task<PagedList<T>> ToPagedListAsync<T>(
this IQueryable<T> query, int page, int pageSize,
CancellationToken ct)
{
var count = await query.CountAsync(ct);
var items = await query
.Skip((page - 1) * pageSize)
.Take(pageSize)
.ToListAsync(ct);
return new PagedList<T>(items, count, page, pageSize);
}
}
internal)Features/ folder structure with operation-named filesCommand, Query, HandlerDbContext directlyIEndpointGroup or similar endpoint grouping patternsFeatures/ folder at the project rootCommon/ for truly cross-cutting utilities only| Question | VSA Answer | |----------|-----------| | Where does the handler go? | Same file as the command/query | | Do I need a repository? | No — use DbContext directly | | When do I extract shared code? | After 3+ duplications | | Can different slices use different data access? | Yes — EF, Dapper, raw SQL per slice | | How do I handle cross-cutting? | MediatR pipeline behaviors | | What about domain logic? | Keep in entity methods, call from handler |
data-ai
Use when about to claim work is complete, fixed, passing, or ready — before committing, creating PRs, or moving to the next task. Requires running verification commands and confirming output before making any success claims.
development
Use when encountering any bug, test failure, build error, or unexpected behavior — before proposing fixes or making changes.
development
Use when checkpointing, wrapping up, or handing off an AI-assisted development session.
development
Use when following the Specification-Driven Development lifecycle from plan through ship.