skills/microservice/gateway/gateway-endpoint/SKILL.md
Use when building REST gateway controllers that delegate to gRPC backend services.
npx skillsauth add faysilalshareef/dotnet-ai-kit gateway-endpointInstall 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.
ControllerBaseV1 abstract class (not raw ControllerBase)ControllerBaseV1 defines shared route constants and OpenAPI doc entriesPaginated<T> wraps list responses with Results, CurrentPage, PageSize, TotalResponse { Message } wrapper[Authorize] or [Authorize(Policy = ...)] or [Authorize(Roles = ...)] applied at class levelEvery versioned controller inherits from this abstract base. It defines the default route constant, route prefix, and an OpenAPI doc entry for Scalar documentation.
namespace {Company}.Gateways.{Domain}.Controllers.V1;
[ApiController]
[ApiExplorerSettings(GroupName = "v1")]
[Authorize]
public abstract class ControllerBaseV1 : ControllerBase
{
public const string DefaultRoute = "api/v1/[controller]";
public const string RoutePrefix = "api/v1/";
public static OpenApiDocEntry GetDocEntry() => new DevelopmentOpenApiDocEntry(
Name: "v1",
new() { Title = "{Domain} Management V1 API's", Version = "v1" });
}
Inject the gRPC client via primary constructor. Map gRPC response to REST DTO inline using Select.
namespace {Company}.Gateways.{Domain}.Controllers.V1;
[Route(DefaultRoute)]
[Authorize(Policy = Policy.Operator)]
public class ProductsController(
ProductManagement.ProductManagementClient productClient) : ControllerBaseV1
{
private readonly ProductManagement.ProductManagementClient _productClient = productClient;
[HttpGet]
public async Task<ActionResult<GetProductsOutput>> GetProductsAsync()
{
var response = await _productClient.FetchAllAsync(new EmptyRequest { });
return Ok(new GetProductsOutput
{
Products = response.Products.Select(p => new ProductModel
{
Id = Guid.Parse(p.Id),
Name = p.Name,
Code = p.Code,
})
});
}
}
Controllers that handle both commands and queries inject multiple gRPC clients. Command methods build gRPC request objects inline and return Response { Message }. Query methods map gRPC responses to output DTOs and wrap lists in Paginated<T>.
namespace {Company}.Gateways.{Domain}.Controllers.V1;
[Route(DefaultRoute)]
[Authorize(Roles = $"{Roles.SuperAdmin},{Roles.Admin}")]
public class OrdersController(
OrderCommands.OrderCommandsClient commandsClient,
OrderManagementQueries.OrderManagementQueriesClient queriesClient,
ILogger<OrdersController> logger) : ControllerBaseV1
{
private readonly OrderCommands.OrderCommandsClient _commandsClient = commandsClient;
private readonly OrderManagementQueries.OrderManagementQueriesClient _queriesClient = queriesClient;
private readonly ILogger<OrdersController> _logger = logger;
[HttpPost("{id}")]
public async Task<ActionResult<Response>> CreateOrder(
Guid id, [FromBody] CreateOrderModel model)
{
var response = await _commandsClient.CreateOrderAsync(new CreateOrderRequest
{
Id = id.ToString(),
Name = model.Name,
Description = model.Description,
StartDate = model.StartDate.ToTimestamp(),
Status = (Proto.OrderStatus)model.Status,
Items = { model.Items.Select(x => x.ToString()) },
});
return new Response { Message = response.Message };
}
[HttpPut("{id}")]
public async Task<ActionResult<Response>> UpdateOrderAsync(
Guid id, [FromBody] UpdateOrderModel model)
{
var response = await _commandsClient.UpdateOrderAsync(new UpdateOrderRequest
{
Id = id.ToString(),
Name = model.Name,
Description = model.Description,
});
return new Response { Message = response.Message };
}
[HttpGet]
public async Task<ActionResult<Paginated<OrderOutput>>> GetOrdersAsync(
[FromQuery] GetOrdersFilterModel filterModel)
{
var response = await _queriesClient.GetOrdersAsync(new GetOrdersRequest
{
PageSize = filterModel.PageSize,
CurrentPage = filterModel.CurrentPage,
StartDateFrom = filterModel.StartDateFrom.ToTimestamp(),
StartDateTo = filterModel.StartDateTo.ToTimestamp(),
Status = (QueryProto.OrderStatus)filterModel.Status,
});
var output = response.Orders.Select(x => new OrderOutput
{
Id = Guid.Parse(x.Id),
Name = x.Name,
Description = x.Description,
StartDate = x.StartDate.ToDateTime(),
Status = (Models.Enums.OrderStatus)x.Status,
});
return new Paginated<OrderOutput>(
output, response.CurrentPage, response.PageSize, response.Total);
}
[HttpGet("{id}")]
public async Task<ActionResult<OrderDetailOutput>> GetOrderAsync(Guid id)
{
var response = await _queriesClient.GetOrderDetailsAsync(
new GetOrderDetailsRequest { Id = id.ToString() });
return new OrderDetailOutput
{
Order = new OrderOutput
{
Id = Guid.Parse(response.Order.Id),
Name = response.Order.Name,
StartDate = response.Order.StartDate.ToDateTime(),
Status = (Models.Enums.OrderStatus)response.Order.Status,
},
Items = response.Items.Select(Guid.Parse),
};
}
}
For specific endpoints needing custom error handling, catch RpcException and return ProblemDetails. The global HttpResponseExceptionFilter handles most cases automatically.
[HttpGet("items")]
[ProducesResponseType(typeof(Paginated<ItemOutput>), StatusCodes.Status200OK)]
public async Task<ActionResult<Paginated<ItemOutput>>> GetItemsAsync(
[FromQuery] int page = 1,
[FromQuery] int pageSize = 20,
[FromQuery] string? searchName = null)
{
try
{
var request = new GetItemsRequest { Page = page, PageSize = pageSize };
if (!string.IsNullOrEmpty(searchName))
request.SearchName = searchName;
var response = await _queriesClient.GetItemsAsync(request);
return Ok(new Paginated<ItemOutput>(
response.Items.Select(i => i.ToItemOutput()),
response.Page, response.PageSize, response.TotalCount));
}
catch (RpcException ex)
{
_logger.LogError(ex,
"gRPC error in {Method} -- StatusCode: {StatusCode}",
nameof(GetItemsAsync), ex.StatusCode);
return StatusCode(StatusCodes.Status502BadGateway, new ProblemDetails
{
Title = "Upstream service error",
Detail = ex.Status.Detail,
Status = StatusCodes.Status502BadGateway
});
}
}
namespace {Company}.Gateways.Common.Models;
public class Paginated<TItem>(
IEnumerable<TItem> results, int currentPage, int pageSize, int total)
{
public IEnumerable<TItem> Results { get; } = results;
public int CurrentPage { get; } = currentPage;
public int PageSize { get; } = pageSize;
public int Total { get; } = total;
public int LastPage => PageSize == 0 ? 0
: Total % PageSize <= 0 ? Total / PageSize
: (Total / PageSize) + 1;
}
namespace {Company}.Gateways.{Domain}.Models;
public class Response
{
public required string Message { get; set; }
}
Request models use class with required properties. Output models follow the same pattern.
namespace {Company}.Gateways.{Domain}.Models.Requests;
public class CreateOrderModel : UpdateOrderModel
{
public required List<Guid> Items { get; init; }
public required List<DateTime> Dates { get; init; }
}
namespace {Company}.Gateways.{Domain}.Models.Responses;
public class OrderOutput
{
public required Guid Id { get; init; }
public required string Name { get; init; }
public required string Description { get; init; }
public required DateTime StartDate { get; init; }
public required OrderStatus Status { get; init; }
}
Separate extension methods only for enum conversions between proto and domain types.
namespace {Company}.Gateways.{Domain}.Extensions;
using CommandProto = {Company}.{Domain}.GrpcClients.Protos.Commands;
using QueryProto = {Company}.{Domain}.GrpcClients.Protos.Queries;
public static class OrderExtensions
{
public static CommandProto.ItemType ToProtoEnum(this Models.Enums.ItemType itemType)
=> itemType switch
{
Models.Enums.ItemType.Physical => CommandProto.ItemType.Physical,
Models.Enums.ItemType.Digital => CommandProto.ItemType.Digital,
_ => throw new ArgumentOutOfRangeException(nameof(itemType))
};
public static Models.Enums.ItemType ToDomainEnum(this QueryProto.ItemType itemType)
=> itemType switch
{
QueryProto.ItemType.Physical => Models.Enums.ItemType.Physical,
QueryProto.ItemType.Digital => Models.Enums.ItemType.Digital,
_ => throw new ArgumentOutOfRangeException(nameof(itemType))
};
}
| Anti-Pattern | Correct Approach |
|---|---|
| Inheriting raw ControllerBase | Inherit ControllerBaseV1 with shared route/docs |
| Separate mapping extension classes for all fields | Map inline in controller actions |
| Business logic in gateway controller | Gateway only maps and delegates to gRPC |
| Exposing gRPC proto types in REST response | Map to REST-specific DTOs |
| Missing [Authorize] on controller class | Apply at class level, use Policy or Roles |
| Constructor injection without backing field | Assign to private readonly field |
| Returning raw gRPC response | Wrap in Response { Message } for commands |
# Find gateway controllers
grep -r "ControllerBaseV1\|: ControllerBase" --include="*.cs" Controllers/
# Find gRPC client injection
grep -r "CommandsClient\|QueriesClient\|ManagementClient" --include="*.cs" Controllers/
# Find Paginated usage
grep -r "Paginated<" --include="*.cs"
# Find authorization attributes
grep -r "\[Authorize" --include="*.cs" Controllers/
ControllerBaseV1 and use [Route(DefaultRoute)]readonly fieldsPaginated<T> for list endpoints using constructor argsResponse { Message } for command endpoints[Authorize] at class level with Policy or Rolesdata-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.