Практикум — сервис заказов на C#
Практикум, шаг 5 из 8. orders-api оркестрирует резерв в catalog-api. DTO — шаг 3.
Создание проекта
cd OrderDesk
dotnet new web -n orders-api -o orders-api
cd orders-api
dotnet add package Microsoft.EntityFrameworkCore.Sqlite
dotnet add package Microsoft.AspNetCore.Authentication.JwtBearer
appsettings.json:
{
"Catalog": {
"BaseUrl": "http://localhost:8100",
"ApiKey": "dev-catalog-key-change-me"
},
"Jwt": {
"Issuer": "orderdesk",
"Audience": "orderdesk-clients",
"SigningKey": "DEV_ONLY_32_CHAR_MINIMUM_SECRET!!"
}
}
HttpClient к каталогу
Services/CatalogClient.cs:
using System.Net.Http.Json;
using System.Text.Json;
public sealed class CatalogClient
{
private readonly HttpClient _http;
private static readonly JsonSerializerOptions JsonOpts = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true,
};
public CatalogClient(HttpClient http) => _http = http;
public async Task<CatalogProductDto?> GetProductAsync(string productId, CancellationToken ct)
{
var resp = await _http.GetAsync($"/api/v1/products/{productId}", ct);
if (resp.StatusCode == System.Net.HttpStatusCode.NotFound) return null;
resp.EnsureSuccessStatusCode();
return await resp.Content.ReadFromJsonAsync<CatalogProductDto>(JsonOpts, ct);
}
public async Task<CatalogReservationDto> ReserveAsync(
string productId, int quantity, string orderRef, string idempotencyKey, CancellationToken ct)
{
using var req = new HttpRequestMessage(HttpMethod.Post, "/api/v1/reservations");
req.Headers.Add("Idempotency-Key", idempotencyKey);
req.Content = JsonContent.Create(new
{
productId,
quantity,
orderRef,
}, options: JsonOpts);
var resp = await _http.SendAsync(req, ct);
if ((int)resp.StatusCode == 409)
throw new InsufficientStockException(productId);
resp.EnsureSuccessStatusCode();
return (await resp.Content.ReadFromJsonAsync<CatalogReservationDto>(JsonOpts, ct))!;
}
}
Регистрация в Program.cs:
builder.Services.AddHttpClient<CatalogClient>((sp, client) =>
{
var cfg = sp.GetRequiredService<IConfiguration>().GetSection("Catalog");
client.BaseAddress = new Uri(cfg["BaseUrl"]!);
client.DefaultRequestHeaders.Add("X-Api-Key", cfg["ApiKey"]);
client.Timeout = TimeSpan.FromSeconds(5);
});
Домен и EF Core
Models/Order.cs — статусы Draft, Reserved, Confirmed, Cancelled, Failed.
Data/OrdersDbContext.cs — таблицы Orders, OrderLines.
Services/OrderService.cs — создание заказа:
public async Task<Order> CreateOrderAsync(string userId, CreateOrderRequest request, CancellationToken ct)
{
if (request.Lines.Count == 0)
throw new ValidationException("Order must contain at least one line");
var order = new Order
{
Id = $"ord_{Guid.NewGuid():N}"[..12],
UserId = userId,
Status = OrderStatus.Draft,
CreatedAt = DateTimeOffset.UtcNow,
};
foreach (var line in request.Lines)
{
var product = await _catalog.GetProductAsync(line.ProductId, ct)
?? throw new ValidationException($"Unknown product {line.ProductId}");
var reservation = await _catalog.ReserveAsync(
line.ProductId,
line.Quantity,
order.Id,
idempotencyKey: $"{order.Id}:{line.ProductId}",
ct);
order.Lines.Add(new OrderLine
{
ProductId = line.ProductId,
Quantity = line.Quantity,
UnitPrice = product.Price,
ReservationId = reservation.ReservationId,
});
}
order.Status = OrderStatus.Reserved;
order.Total = order.Lines.Sum(l => l.UnitPrice * l.Quantity);
_db.Orders.Add(order);
await _db.SaveChangesAsync(ct);
await _events.PublishAsync(new OrderStatusEvent(order.Id, order.Status.ToString()));
return order;
}
OrderEventHub подключим в шаге 7.
Minimal API
Program.cs (фрагмент):
var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
app.MapPost("/api/v1/auth/token", (LoginRequest req, IConfiguration cfg) =>
{
// учебный логин: demo / demo
if (req.Username != "demo" || req.Password != "demo")
return Results.Unauthorized();
var token = JwtHelper.IssueToken(req.Username, cfg);
return Results.Ok(new { access_token = token, token_type = "Bearer", expires_in = 3600 });
});
var orders = app.MapGroup("/api/v1/orders").RequireAuthorization();
orders.MapGet("/", async (HttpContext ctx, OrderService svc) =>
{
var userId = ctx.User.Identity!.Name!;
var list = await svc.ListForUserAsync(userId);
return Results.Ok(list.Select(OrderMapper.ToResponse));
});
orders.MapPost("/", async (CreateOrderRequest body, HttpContext ctx, OrderService svc, CancellationToken ct) =>
{
try
{
var order = await svc.CreateOrderAsync(ctx.User.Identity!.Name!, body, ct);
return Results.Created($"/api/v1/orders/{order.Id}", OrderMapper.ToResponse(order));
}
catch (InsufficientStockException ex)
{
return Results.Conflict(new { title = ex.Message });
}
catch (HttpRequestException)
{
return Results.Json(new { title = "Catalog unavailable" }, statusCode: 502);
}
});
app.Run("http://localhost:5200");
Запуск вместе с каталогом
Терминал 1:
cd OrderDesk\catalog-api
uvicorn app.main:app --port 8100
Терминал 2:
cd OrderDesk\orders-api
dotnet run
Получите JWT и создайте заказ — подробный сценарий в Postman. Безопасность вынесена в шаг 6.
Отмена заказа и компенсация
OrderService.CancelAsync для каждой строки с ReservationId вызывает CatalogClient.DeleteReservationAsync, затем ставит статус cancelled и шлёт WebSocket-событие (см. шаг 7):
public async Task<Order?> CancelAsync(string orderId, string userId, CancellationToken ct)
{
var order = await _db.Orders.Include(o => o.Lines).FirstOrDefaultAsync(o => o.Id == orderId, ct);
if (order is null || order.UserId != userId) return null;
foreach (var line in order.Lines.Where(l => l.ReservationId is not null))
await _catalog.DeleteReservationAsync(line.ReservationId!, ct);
order.Status = OrderStatus.Cancelled;
await _db.SaveChangesAsync(ct);
await _events.PublishAsync(new OrderStatusEvent(order.Id, "cancelled"));
return order;
}
Это пример саги с компенсацией в миниатюре: прямой оркестрации без брокера достаточно для учебного стенда.
Health-check для оркестратора
app.MapGet("/health/live", () => Results.Ok(new { status = "live" }));
app.MapGet("/health/ready", async (CatalogClient catalog, CancellationToken ct) =>
{
try
{
await catalog.PingAsync(ct);
return Results.Ok(new { status = "ready", catalog = "up" });
}
catch
{
return Results.Json(new { status = "degraded", catalog = "down" }, statusCode: 503);
}
});
В CatalogClient добавьте GET /api/v1/products?pageSize=1 как лёгкий ping.
Чек-лист orders-api
-
POST /ordersпри остановленном каталоге →502 -
POST /cancelвозвращает остаток через catalogDELETE - JWT обязателен на
/api/v1/orders - В логах виден тот же
X-Request-Id, что прислал клиент
См. также
Другие статьи этого же раздела в боковом меню (как на странице "О разделе"). Два сервиса OrderDesk: каталог на Python и заказы на C#, границы ответственности, потоки REST и WebSocket. Ресурсы OrderDesk, таблица методов HTTP, коды ответов и фрагмент OpenAPI для catalog-api и orders-api. Доменные сущности OrderDesk, DTO для REST, маппинг Python (Pydantic) и C# (record + ручной маппер). FastAPI, SQLite, эндпоинты товаров и резервирования, Pydantic и проверка через uvicorn. JWT, API-ключ между сервисами, HTTPS, таймауты, идемпотентность и заголовок X-Request-Id в OrderDesk. Протокол JSON-сообщений, hub в ASP.NET Core, heartbeat и подписка клиента на статусы OrderDesk. Коллекция Postman, переменные окружения и сквозной сценарий OrderDesk — товар, заказ, WebSocket.Практикум — сценарий и архитектура OrderDesk
Практикум — проектирование контракта API
Практикум — модели данных и маппинг DTO
Практикум — сервис каталога на Python
Практикум — безопасность и устойчивость
Практикум — WebSocket и события заказов
Практикум — проверка в Postman