08API Design и Communication

Уровень 1: Foundation

REST API Best Practices

# REST API Best Practices

Resource-Oriented Design

Nouns vs Verbs

REST APIs should use nouns (resources) in URLs, with HTTP methods defining the action:

# Correct (Nouns)
        GET    /api/orders           -- list orders
        POST   /api/orders           -- create order
        GET    /api/orders/123       -- get order 123
        PUT    /api/orders/123       -- fully update order
        DELETE /api/orders/123       -- delete order

        # Incorrect (Verbs)
        GET    /api/getOrders
        POST   /api/createOrder

Resource Naming Conventions

RuleExample
Use plural nouns/api/users, not /api/user
Lowercase with hyphens/api/product-categories
Nested navigation/api/users/123/orders
No actions in URL/api/orders/123/cancel (use POST to resource)
Query params for filters/api/products?category=electronics&min-price=100

Nested Resources

GET    /api/users/123/orders           -- user 123's orders
        POST   /api/users/123/orders           -- create order for user 123
        GET    /api/users/123/orders/456       -- order 456 of user 123
        DELETE /api/users/123/orders/456       -- delete order 456 of user 123

HTTP Methods Semantics

Semantics Table

MethodSafeIdempotentBodySemantics
GETYesYesNoRetrieve resource without side effects
POSTNoNoOptionalCreate resource or perform operation
PUTNoYesYesFully replace resource
PATCHNoNoYesPartial update
DELETENoYesNoDelete resource

Idempotency

  • GET, PUT, DELETE -- idempotent (multiple calls produce same result)
  • POST, PATCH -- not idempotent

For idempotent POST operations, use Idempotency-Key header:

POST /api/payments
        Idempotency-Key: unique-key-12345
        Content-Type: application/json

        {"amount": 100, "currency": "USD"}

Status Codes

xx -- Success

CodeMeaningWhen to Use
200 OKSuccessGET, PUT, PATCH succeed
201 CreatedCreatedPOST creates new resource
202 AcceptedAcceptedAsync processing
204 No ContentNo ContentDELETE or PUT without response body

xx -- Redirection

CodeMeaningWhen to Use
301 Moved PermanentlyPermanent redirectResource moved permanently
302 FoundTemporary redirectTemporary redirection
304 Not ModifiedNot ModifiedCaching, ETag matched
307 Temporary RedirectTemporary redirectPreserves request method

xx -- Client Errors

CodeMeaningWhen to Use
400 Bad RequestBad RequestInvalid format, validation
401 UnauthorizedUnauthorizedMissing or invalid token
403 ForbiddenForbiddenAuthenticated but no permissions
404 Not FoundNot FoundResource does not exist
405 Method Not AllowedMethod Not AllowedWrong HTTP method
409 ConflictConflictDuplicate, state conflict
410 GoneGoneResource permanently removed (sunset)
415 Unsupported Media TypeWrong Content-TypeInvalid Content-Type
422 Unprocessable EntityUnprocessableSemantic validation error
429 Too Many RequestsRate LimitedRate limiting

xx -- Server Errors

CodeMeaningWhen to Use
500 Internal Server ErrorInternal ErrorUnexpected server error
502 Bad GatewayBad GatewayUpstream service error
503 Service UnavailableUnavailableService temporarily unavailable
504 Gateway TimeoutGateway TimeoutUpstream did not respond in time

HATEOAS

HATEOAS (Hypermedia as the Engine of Application State) -- REST constraint where clients navigate APIs through links in responses.

Example HATEOAS Response

{
          "id": 123,
          "name": "Order #123",
          "status": "pending",
          "total": 250.00,
          "_links": {
            "self": {
              "href": "/api/orders/123"
            },
            "cancel": {
              "href": "/api/orders/123/cancel",
              "method": "POST",
              "title": "Cancel this order"
            },
            "pay": {
              "href": "/api/orders/123/pay",
              "method": "POST",
              "title": "Pay for this order"
            },
            "parent": {
              "href": "/api/orders"
            }
          }
        }

State-dependent Links

Links depend on resource state:

// Order status: "pending"
        {
          "_links": {
            "self": { "href": "/api/orders/123" },
            "cancel": { "href": "/api/orders/123/cancel", "method": "POST" },
            "pay": { "href": "/api/orders/123/pay", "method": "POST" },
            "update": { "href": "/api/orders/123", "method": "PUT" }
          }
        }

        // Order status: "confirmed"
        {
          "_links": {
            "self": { "href": "/api/orders/123" },
            "ship": { "href": "/api/orders/123/ship", "method": "POST" },
            "cancel": { "href": "/api/orders/123/cancel", "method": "POST" }
          }
        }

API Entry Point (Root)

{
          "_links": {
            "self": { "href": "/api" },
            "orders": { "href": "/api/orders", "title": "Order management" },
            "customers": { "href": "/api/customers", "title": "Customer management" },
            "products": { "href": "/api/products", "title": "Product catalog" },
            "docs": { "href": "/api/docs", "title": "API documentation" }
          }
        }

Hypermedia Formats

FormatMedia TypeDescription
HALapplication/hal+jsonStandard with _links and _embedded
Sirenapplication/vnd.siren+jsonIncludes actions and entities
JSON:APIapplication/vnd.api+jsonStructured format with links
Customapplication/jsonCustom _links

Pagination

Offset Pagination

GET /api/posts?page=3&limit=20
{
          "data": [ /* 20 posts */ ],
          "pagination": {
            "page": 3,
            "limit": 20,
            "total": 15420,
            "totalPages": 771
          }
        }

Pros: Simple implementation, supports jump to any page, total count Cons: O(n) at large offsets, unstable under data changes

Cursor-Based Pagination (Recommended)

GET /api/posts?limit=20&cursor=eyJpZCI6MTIzfQ==
{
          "data": [ /* 20 posts */ ],
          "pagination": {
            "limit": 20,
            "hasNextPage": true,
            "nextCursor": "eyJpZCI6MTAwfQ==",
            "prevCursor": "eyJpZCI6NjB9"
          }
        }

Pros: O(1) performance, stable under changes, ideal for infinite scroll Cons: No random page access, cursor is opaque

Keyset Pagination (Database Level)

-- Cursor = {created_at: '2026-01-15', id: 100}
        SELECT * FROM posts
        WHERE (created_at, id) < ('2026-01-15', 100)
        ORDER BY created_at DESC, id DESC
        LIMIT 20;

Comparison Table

PatternJump to pageStable under writesPerformance at depthTotal count
OffsetYesNoDegrades (O(n))Easy
CursorNoYesConstant (O(1))Hard
KeysetNoYesConstant (O(1))Hard

Implementation: Cursor-Based Pagination in C#

// Cursor encoding/decoding
        public static class CursorHelper
        {
            public static string Encode(int id, DateTime createdAt)
            {
                var json = JsonSerializer.Serialize(new { id, createdAt });
                return Convert.ToBase64UrlString(Encoding.UTF8.GetBytes(json));
            }

            public static (int id, DateTime createdAt)? Decode(string cursor)
            {
                var bytes = Convert.FromBase64UrlString(cursor);
                var json = Encoding.UTF8.GetString(bytes);
                return JsonSerializer.Deserialize<(int id, DateTime createdAt)>(json);
            }
        }

        // Endpoint implementation
        app.MapGet("/api/posts", async (
            ApplicationDb db,
            int limit = 20,
            string? cursor = null) =>
        {
            limit = Math.Min(limit, 100); // max page size

            var query = db.Posts
                .OrderByDescending(p => p.CreatedAt)
                .ThenByDescending(p => p.Id);

            if (cursor is not null)
            {
                var (lastId, lastCreatedAt) = CursorHelper.Decode(cursor)
                    ?? throw new InvalidOperationException("Invalid cursor");

                query = query.Where(p =>
                    (p.CreatedAt, p.Id) < (lastCreatedAt, lastId));
            }

            var posts = await query
                .Take(limit + 1)
                .Select(p => new { p.Id, p.Title, p.CreatedAt })
                .ToListAsync();

            var hasNext = posts.Count > limit;
            if (hasNext) posts.RemoveAt(limit);

            var nextCursor = hasNext
                ? CursorHelper.Encode(posts.Last().Id, posts.Last().CreatedAt)
                : null;

            var prevCursor = cursor; // For previous, reverse the query

            return Results.Ok(new
            {
                data = posts,
                pagination = new
                {
                    limit,
                    hasNextPage = hasNext,
                    nextCursor,
                    prevCursor
                }
            });
        });

Filtering, Sorting, Field Selection

Filtering

GET /api/products?category=electronics&min-price=100&max-price=1000&in-stock=true

Sorting

GET /api/products?sort=-price,name    # sort by price desc, then name asc
        GET /api/products?sort=created_at     # sort by created_at asc (default)

Field Selection (Sparse Fieldsets)

GET /api/products?fields=id,name,price
        GET /api/users?fields=id,name,email,profile(bio,avatar)

Combined Example

GET /api/products?category=electronics&sort=-price&fields=id,name,price&limit=20&cursor=abc123

Practice: Blog Platform REST API Design

Resource Model

/api/posts                  GET  (list), POST (create)
        /api/posts/{id}             GET  (get), PUT (update), PATCH (partial), DELETE
        /api/posts/{id}/comments    GET  (list comments)
        /api/comments/{id}          GET  (get), PUT, DELETE
        /api/users                  GET  (list), POST (register)
        /api/users/{id}             GET  (get), PUT, PATCH, DELETE
        /api/users/{id}/posts       GET  (get user posts)
        /api/tags                   GET  (list)
        /api/tags/{slug}/posts      GET  (posts by tag)
        /api/categories             GET  (list)
        /api/categories/{slug}/posts GET (posts by category)

Example Responses

// GET /api/posts
        {
          "data": [
            {
              "id": 1,
              "title": "Getting Started with .NET",
              "slug": "getting-started-dotnet",
              "createdAt": "2026-01-15T10:00:00Z",
              "author": { "id": 1, "name": "John Doe" }
            }
          ],
          "pagination": {
            "limit": 20,
            "hasNextPage": true,
            "nextCursor": "eyJpZCI6MTkzfQ=="
          },
          "_links": {
            "self": { "href": "/api/posts" },
            "next": { "href": "/api/posts?cursor=eyJpZCI6MTkzfQ==" },
            "create": { "href": "/api/posts", "method": "POST" }
          }
        }

References

  • [REST API Design - Paul Ammann](https://restfulapi.net/)
  • [HTTP Status Codes - MDN](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status)
  • [RFC 7807 - Problem Details for HTTP APIs](https://www.rfc-editor.org/rfc/rfc7807)
  • [HATEOAS Best Practices](https://asoasis.tech/articles/2026-03-22-0253-rest-api-hateoas-implementation/)

Практика


ASP.NET Core Web API

# ASP.NET Core Web API

Minimal APIs vs Controllers

When to Use Each

CriterionMinimal APIsControllers
Project sizeSmall to mediumLarge, complex
VerbosityMinimal codeMore boilerplate
DI supportFull DI supportFull DI support
Model bindingSupportedFull support + extensibility
ValidationManual or FluentValidationBuilt-in + FluentValidation
FiltersLimited (middleware pattern)Action, Result, Exception filters
ODataNot supportedSupported
API versioningManual setupBuilt-in with Microsoft.AspNetCore.Mvc.Versioning
TestingSimpleWell-established patterns
Learning curveLowerStandard ASP.NET Core

Recommendation

> Start with Minimal APIs for new projects. Use Controllers when you need advanced features: model binding extensibility, form binding (IFormFile), OData, or sophisticated validation.


Minimal APIs

Basic Setup (.NET 8+)

`csharp var builder = WebApplication.CreateBuilder(args);

// DI builder.Services.AddDbContext(); builder.Services.AddValidatorsFromAssemblyContaining(); builder.Services.AddProblemDetails();

var app = builder.Build();

app.UseProblemDetails();

// POST with validation app.MapPost("/api/posts", async ( [FromBody] CreatePostRequest request, [FromServices] IValidator validator, [FromServices] ApplicationDb db) => { var result = await validator.ValidateAsync(request); if (!result.IsValid) return Results.ValidationProblem(result.ToDictionary());

var post = new Post { Title = request.Title, Content = request.Content, AuthorId = request.AuthorId };

db.Posts.Add(post); await db.SaveChangesAsync();

return Results.Created($"/api/posts/{post.Id}", post); });

// GET with pagination app.MapGet("/api/posts", async ( [FromQuery] int limit = 20, [FromQuery] string? cursor = null, [FromServices] ApplicationDb db) => { var query = db.Posts .OrderByDescending(p => p.CreatedAt) .ThenByDescending(p => p.Id);

if (cursor is not null) { var (lastId, lastCreatedAt) = CursorHelper.Decode(cursor) ?? throw new InvalidOperationException("Invalid cursor"); query = query.Where(p => (p.CreatedAt, p.Id) < (lastCreatedAt, lastId)); }

var posts = await query .Take(limit + 1) .Select(p => new { p.Id, p.Title, p.CreatedAt }) .ToListAsync();

var hasNext = posts.Count > limit; if (hasNext) posts.RemoveAt(limit);

return Results.Ok(new { data = posts, pagination = new { hasNextPage = hasNext, nextCursor = hasNext ? CursorHelper.Encode(posts.Last().Id, posts.Last().CreatedAt) : null } }); });

app.Run(); `

Typed Results (.NET 8+)

`csharp // Instead of returning IActionResult, use typed results app.MapGet("/api/posts/{id}", async (int id, ApplicationDb db) => { var post = await db.Posts.FindAsync(id); return post is null ? Results.NotFound() : Results.Ok(post); });

// With ProblemDetails app.MapDelete("/api/posts/{id}", async (int id, ApplicationDb db) => { var post = await db.Posts.FindAsync(id); if (post is null) return Results.Problem( detail: $"Post with id {id} not found", statusCode: 404);

db.Posts.Remove(post); await db.SaveChangesAsync(); return Results.NoContent(); }); `

Grouping and Ordering

`csharp var posts = app.MapGroup("/api/posts");

posts.MapGet("/", GetPosts); posts.MapGet("/{id}", GetPost); posts.MapPost("/", CreatePost); posts.MapPut("/{id}", UpdatePost); posts.MapPatch("/{id}", PatchPost); posts.MapDelete("/{id}", DeletePost);

// Apply common filters posts.WithMetadata(new AuthorizeAttribute("Admin")); posts.WithOpenApi(); // OpenAPI documentation `


Controllers

Basic Controller

`csharp [ApiController] [Route("api/[controller]")] public class PostsController : ControllerBase { private readonly ApplicationDb _db;

public PostsController(ApplicationDb db) { _db = db; }

[HttpGet] public async Task GetPosts( [FromQuery] int limit = 20, [FromQuery] string? cursor = null) { var query = _db.Posts .OrderByDescending(p => p.CreatedAt) .ThenByDescending(p => p.Id);

if (cursor is not null) { var (lastId, lastCreatedAt) = CursorHelper.Decode(cursor); if (lastId.HasValue) query = query.Where(p => (p.CreatedAt, p.Id) < (lastCreatedAt.Value, lastId.Value)); }

var posts = await query .Take(limit + 1) .Select(p => new { p.Id, p.Title, p.CreatedAt }) .ToListAsync();

var hasNext = posts.Count > limit; if (hasNext) posts.RemoveAt(limit);

return Ok(new { data = posts, hasNext }); }

[HttpGet("{id}")] public async Task GetPost(int id) { var post = await _db.Posts.FindAsync(id); if (post is null) return NotFound(); return Ok(post); }

[HttpPost] public async Task CreatePost([FromBody] CreatePostRequest request) { // [ApiController] auto-validates ModelState if (!ModelState.IsValid) return ValidationProblem(ModelState);

var post = new Post { Title = request.Title, Content = request.Content }; _db.Posts.Add(post); await _db.SaveChangesAsync();

return CreatedAtAction(nameof(GetPost), new { id = post.Id }, post); } } `

ControllerBase vs Controller

Base ClassUse When
ControllerBaseWeb API only (no view rendering)
ControllerMVC with views + API

Model Binding

Binding Sources

AttributeSourceExample
[FromQuery]Query string?id=123
[FromRoute]Route parameters/api/users/123
[FromBody]Request bodyJSON body
[FromHeader]Request headersAuthorization: Bearer ...
[FromForm]Form datamultipart/form-data
[FromServices]DI container[FromServices] ILogger log

Binding Examples

`csharp // Query string binding app.MapGet("/api/posts", async ( [FromQuery] int page, [FromQuery] int limit, [FromQuery] string? search) => { ... });

// Route binding app.MapGet("/api/posts/{id}", async ( [FromRoute] int id) => { ... });

// Header binding app.MapGet("/api/posts", async ( [FromHeader(Name = "Accept-Language")] string language) => { ... });

// Body binding (complex types) app.MapPost("/api/posts", async ( [FromBody] CreatePostRequest request) => { ... });

// Multiple sources app.MapPut("/api/posts/{id}", async ( [FromRoute] int id, [FromBody] UpdatePostRequest request) => { ... }); `

Complex Model Binding

csharp public record CreatePostRequest( [Required, MinLength(5), MaxLength(200)] string Title, [Required, MinLength(10)] string Content, [Required] int AuthorId, [Required] List<int> TagIds, [FromQuery] bool IsDraft = false );


Validation

Built-in Validation (Data Annotations)

`csharp public class CreatePostRequest { [Required, MinLength(5), MaxLength(200)] public string Title { get; set; } = string.Empty;

[Required, MinLength(10)] public string Content { get; set; } = string.Empty;

[Required] public int AuthorId { get; set; } } `

FluentValidation Integration

`csharp // Register validators builder.Services.AddValidatorsFromAssemblyContaining();

// Create validator public class CreatePostRequestValidator : AbstractValidator { public CreatePostRequestValidator() { RuleFor(x => x.Title) .NotEmpty().WithMessage("Title is required") .MinimumLength(5).WithMessage("Title must be at least 5 characters") .MaximumLength(200).WithMessage("Title cannot exceed 200 characters");

RuleFor(x => x.Content) .NotEmpty().WithMessage("Content is required") .MinimumLength(10).WithMessage("Content must be at least 10 characters");

RuleFor(x => x.AuthorId) .GreaterThan(0).WithMessage("Author must be valid");

RuleFor(x => x.TagIds) .NotEmpty().WithMessage("At least one tag is required"); } }

// Use in Minimal API app.MapPost("/api/posts", async ( CreatePostRequest request, IValidator validator) => { var result = await validator.ValidateAsync(request); if (!result.IsValid) return Results.ValidationProblem(result.ToDictionary());

// ... create post }); `

Custom Validators

`csharp public class UniqueTitleValidator : PropertyValidator { private readonly ApplicationDb _db;

public UniqueTitleValidator(ApplicationDb db) { _db = db; }

protected override bool IsValid(PropertyValidatorContext context) { var title = context.PropertyValue; return !_db.Posts.Any(p => p.Title.ToLower() == title.ToLower()); } }

// Or with FluentValidation async public class UniqueSlugValidator : IAsyncValidator { private readonly ApplicationDb _db;

public UniqueSlugValidator(ApplicationDb db) { _db = db; }

public async Task ValidateAsync( ValidationContext context, string value, CancellationToken cancellation = default) { if (await _db.Posts.AnyAsync(p => p.Slug == value, cancellation)) return new ValidationResult($"Slug '{value}' is already taken"); return ValidationResult.Success; } } `


Filters

Action Filter

`csharp public class LogExecutionTimeFilter : IAsyncActionFilter { private readonly ILogger _logger; private readonly Stopwatch _sw = Stopwatch.StartNew();

public async Task OnActionExecutionAsync( ActionExecutingContext context, ActionExecutionDelegate next) { _sw.Restart(); var executed = await next(); _sw.Stop();

_logger.LogInformation( "Action {Action} executed in {Elapsed}ms", executed.ActionDescriptor.DisplayName, _sw.ElapsedMilliseconds); } }

// Register builder.Services.AddControllers(options => options.Filters.Add()); `

Exception Filter

`csharp public class GlobalExceptionFilter : IExceptionFilter { private readonly ILogger _logger;

public GlobalExceptionFilter(ILogger logger) { _logger = logger; }

public void OnException(ExceptionContext context) { _logger.LogError(context.Exception, "Unhandled exception");

context.Result = new InternalServerErrorObjectResult(context.Exception.Message); context.ExceptionHandled = true; } } `

IExceptionHandler (.NET 9+)

`csharp public class GlobalExceptionHandler : IExceptionHandler { private readonly ILogger _logger;

public GlobalExceptionHandler(ILogger logger) { _logger = logger; }

public async ValueTask TryHandleAsync( HttpContext httpContext, Exception exception, CancellationToken cancellationToken) { _logger.LogError(exception, "Unhandled exception");

var problemDetails = exception switch { NotFoundException => new ProblemDetails { Status = 404, Title = "Resource not found", Detail = exception.Message }, ValidationException vex => new ValidationProblemDetails { Status = 400, Title = "Validation error", Errors = vex.Errors.ToDictionary(e => e.PropertyName, e => new[] { e.Message }) }, _ => new ProblemDetails { Status = 500, Title = "An unexpected error occurred", Type = "https://httpstatuses.com/500" } };

httpContext.Response.StatusCode = problemDetails.Status.Value; await httpContext.Response.WriteAsJsonAsync(problemDetails, cancellationToken);

return true; } }

// Register builder.Services.AddExceptionHandler(); builder.Services.AddProblemDetails(); app.UseExceptionHandler(); `


Middleware Pipeline

Pipeline Order

`csharp var app = builder.Build();

// 1. Exception handling (must be early) app.UseExceptionHandler();

// 2. HTTP Strict Transport Security if (!app.Environment.IsDevelopment()) app.UseHsts();

// 3. HTTPS redirect app.UseHttpsRedirection();

// 4. Routing (required for UseRouting) app.UseRouting();

// 5. Authentication app.UseAuthentication();

// 6. Authorization app.UseAuthorization();

// 7. Response compression app.UseResponseCompression();

// 8. Custom middleware app.UseRequestLogging();

// 9. Endpoints app.MapControllers(); app.MapGroup("/api").MapApiEndpoints();

app.Run(); `

Short-Circuiting

`csharp public class CacheMiddleware { private readonly RequestDelegate _next; private readonly IDistributedCache _cache;

public CacheMiddleware(RequestDelegate next, IDistributedCache cache) { _next = next; _cache = cache; }

public async Task InvokeAsync(HttpContext context) { if (context.Request.Method != "GET") { await _next(context); return; }

var cacheKey = context.Request.Path; var cached = await _cache.GetStringAsync(cacheKey);

if (cached is not null) { context.Response.Headers.Cache-Control = "public, max-age=60"; await context.Response.WriteAsync(cached); return; // Short-circuit }

// Store original body var originalBody = context.Response.Body; using var memoryStream = new MemoryStream(); context.Response.Body = memoryStream;

await _next(context); context.Response.Body = originalBody;

memoryStream.Seek(0, SeekOrigin.Begin); var response = await new StreamReader(memoryStream).ReadToEndAsync();

await _cache.SetStringAsync(cacheKey, response, new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(1) });

await context.Response.WriteAsync(response); } } `

Custom Request/Response Logging Middleware

`csharp public class RequestResponseLoggingMiddleware { private readonly RequestDelegate _next; private readonly ILogger _logger;

public RequestResponseLoggingMiddleware(RequestDelegate next, ILogger logger) { _next = next; _logger = logger; }

public async Task InvokeAsync(HttpContext context) { // Log request var requestStart = DateTime.UtcNow; _logger.LogInformation( "{Method} {Path} from {IP}", context.Request.Method, context.Request.Path, context.Connection.RemoteIpAddress);

// Capture response body var originalBodyStream = context.Response.Body; using var responseBody = new MemoryStream(); context.Response.Body = responseBody;

try { await _next(context); } catch (Exception ex) { _logger.LogError(ex, "Request failed: {Method} {Path}", context.Request.Method, context.Request.Path); throw; } finally { // Log response var duration = DateTime.UtcNow - requestStart; responseBody.Seek(0, SeekOrigin.Begin); var responseText = await new StreamReader(responseBody).ReadToEndAsync();

_logger.LogInformation( "{Method} {Path} -> {StatusCode} in {Elapsed}ms | {Body}", context.Request.Method, context.Request.Path, context.Response.StatusCode, duration.TotalMilliseconds, string.IsNullOrWhiteSpace(responseText) ? "(empty)" : responseText[..Math.Min(200, responseText.Length)]);

responseBody.Seek(0, SeekOrigin.Begin); await responseBody.CopyToAsync(originalBodyStream); } } }

// Extension method public static class RequestResponseLoggingExtensions { public static IApplicationBuilder UseRequestResponseLogging( this IApplicationBuilder builder) { return builder.UseMiddleware(); } } `


Practice Tasks

Minimal API with DI

`csharp // Program.cs var builder = WebApplication.CreateBuilder(args); builder.Services.AddScoped(); builder.Services.AddDbContext();

var app = builder.Build();

app.MapPost("/api/notifications/email", async ( SendEmailRequest request, IEmailService emailService) => { await emailService.SendAsync(request.To, request.Subject, request.Body); return Results.Ok(new { sent = true }); });

app.Run(); `

Validation Middleware with Detailed Error Responses

`csharp public class ValidationMiddleware { private readonly RequestDelegate _next;

public ValidationMiddleware(RequestDelegate next) { _next = next; }

public async Task InvokeAsync(HttpContext context, IValidatorFactory validatorFactory) { if (context.Request.Body.Length > 0 && context.Request.ContentType?.Contains("application/json") == true) { var body = await new StreamReader(context.Request.Body).ReadToEndAsync(); context.Request.Body.Position = 0;

try { var obj = JsonSerializer.Deserialize(body); // Custom validation logic } catch (JsonException ex) { context.Response.StatusCode = 400; await context.Response.WriteAsJsonAsync(new ProblemDetails { Status = 400, Title = "Invalid JSON", Detail = ex.Message }); return; } }

await _next(context); } } `

Custom Middleware for Request/Response Logging

See RequestResponseLoggingMiddleware above — register with pp.UseRequestResponseLogging().


References

  • [ASP.NET Core Web API docs](https://learn.microsoft.com/en-us/aspnet/core/web-api/)
  • [Minimal APIs in ASP.NET Core](https://learn.microsoft.com/en-us/aspnet/core/fundamentals/minimal-apis)
  • [FluentValidation for ASP.NET Core](https://fluentvalidation.net/)
  • [RFC 9457 - Problem Details for HTTP APIs](https://www.rfc-editor.org/rfc/rfc9457)

Практика


OpenAPI / Swagger

# OpenAPI / Swagger

OpenAPI 3.0/3.1 Specification

Structure

openapi: 3.1.0
        info:
          title: Blog Platform API
          description: REST API for blog platform
          version: 1.0.0
          contact:
            name: API Support
            email: support@example.com
          license:
            name: MIT
            url: https://opensource.org/licenses/MIT

        servers:
          - url: https://api.example.com/v1
            description: Production
          - url: https://staging-api.example.com/v1
            description: Staging

        paths:
          /posts:
            get:
              summary: List posts
              operationId: getPosts
              tags: [Posts]
              parameters:
                - name: limit
                  in: query
                  schema:
                    type: integer
                    default: 20
                    maximum: 100
                - name: cursor
                  in: query
                  schema:
                    type: string
              responses:
                "200":
                  description: Success
                  content:
                    application/json:
                      schema:
                        type: object
                        properties:
                          data:
                            type: array
                            items:
                              ref_yaml: "#/components/schemas/Post"
                          pagination:
                            ref_yaml: "#/components/schemas/Pagination"
                "400":
                  description: Bad request
                  content:
                    application/problem+json:
                      schema:
                        ref_yaml: "#/components/schemas/ProblemDetails"

        components:
          schemas:
            Post:
              type: object
              required: [id, title, createdAt]
              properties:
                id:
                  type: integer
                  format: int32
                title:
                  type: string
                  minLength: 5
                  maxLength: 200
                content:
                  type: string
                createdAt:
                  type: string
                  format: date-time
            Pagination:
              type: object
              properties:
                hasNextPage:
                  type: boolean
                nextCursor:
                  type: string
            ProblemDetails:
              type: object
              properties:
                type:
                  type: string
                title:
                  type: string
                status:
                  type: integer
                detail:
                  type: string
                instance:
                  type: string

          securitySchemes:
            BearerAuth:
              type: http
              scheme: bearer
              bearerFormat: JWT

> Note: In actual YAML, replace ref_yaml: with $ref: — the $ is escaped here for PowerShell compatibility.

Key Components

ComponentPurpose
infoAPI metadata (title, version, contact, license)
serversBase URLs for different environments
pathsAll API endpoints with operations
componentsReusable schemas, security schemes, parameters
securityGlobal security requirements

Swashbuckle — Automatic Documentation

Setup

builder.Services.AddEndpointsApiExplorer();
        builder.Services.AddSwaggerGen(c =>
        {
            c.SwaggerDoc("v1", new OpenApiInfo
            {
                Title = "Blog Platform API",
                Version = "v1",
                Description = "REST API for blog platform",
                Contact = new OpenApiContact
                {
                    Name = "API Support",
                    Email = "support@example.com"
                }
            });

            // XML comments
            var xmlFile = $"{Assembly.GetExecutingAssembly().GetName().Name}.xml";
            var xmlPath = Path.Combine(AppContext.BaseDirectory, xmlFile);
            c.IncludeXmlComments(xmlPath);

            // Security scheme
            c.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme
            {
                Name = "Authorization",
                Type = SecuritySchemeType.Http,
                Scheme = "bearer",
                BearerFormat = "JWT",
                In = ParameterLocation.Header,
                Description = "JWT Authorization header"
            });

            c.AddSecurityRequirement(new OpenApiSecurityRequirement
            {
                {
                    new OpenApiSecurityScheme
                    {
                        Reference = new OpenApiReference { Type = ReferenceType.SecurityScheme, Id = "Bearer" }
                    },
                    Array.Empty<string>()
                }
            });
        });

        var app = builder.Build();

        app.UseSwagger();
        app.UseSwaggerUI(c =>
        {
            c.SwaggerEndpoint("/swagger/v1/swagger.json", "Blog Platform API v1");
            c.RoutePrefix = "swagger";
        });

Custom Document Filters

public class AddVersionHeaderFilter : IDocumentFilter
        {
            public void Apply(OpenApiDocument doc, DocumentFilterContext context)
            {
                doc.Info.Description += "\n\n**Note:** All API requests require the `X-API-Version` header.";

                // Add global parameter
                foreach (var path in doc.Paths)
                {
                    foreach (var operation in path.Value.Operations)
                    {
                        operation.Value.Parameters ??= new List<OpenApiParameter>();
                        operation.Value.Parameters.Add(new OpenApiParameter
                        {
                            Name = "X-API-Version",
                            In = ParameterLocation.Header,
                            Required = true,
                            Schema = new OpenApiSchema { Type = "string", Default = "v1" }
                        });
                    }
                }
            }
        }

        // Register
        builder.Services.AddSwaggerGen(c =>
        {
            c.DocumentFilter<AddVersionHeaderFilter>();
        });

Custom Operation Filters

public class RequireAuthorizationFilter : IOperationFilter
        {
            public void Apply(OpenApiOperation operation, OperationFilterContext context)
            {
                var hasAuthorize = context.MethodInfo.DeclaringType != null &&
                    (context.MethodInfo.DeclaringType.GetCustomAttributes(true).Any() ||
                     context.MethodInfo.GetCustomAttributes(true).Any());

                if (hasAuthorize)
                {
                    operation.Security.Add(new OpenApiSecurityRequirement
                    {
                        {
                            new OpenApiSecurityScheme
                            {
                                Reference = new OpenApiReference { Type = ReferenceType.SecurityScheme, Id = "Bearer" }
                            },
                            Array.Empty<string>()
                        }
                    });
                }

                // Add standard error responses
                operation.Responses.TryAdd("400", new OpenApiResponse { Description = "Bad Request" });
                operation.Responses.TryAdd("401", new OpenApiResponse { Description = "Unauthorized" });
                operation.Responses.TryAdd("404", new OpenApiResponse { Description = "Not Found" });
                operation.Responses.TryAdd("500", new OpenApiResponse { Description = "Internal Server Error" });
            }
        }

API Versioning

Strategy Comparison

StrategyURL ExampleProsCons
URL Path/api/v1/postsSimple, cacheable, visibleURL changes
Query String/api/posts?api-version=1Clean URLsCache issues
HeaderAPI-Version: 1Clean URLsNot visible in docs
Media TypeAccept: application/vnd.myapi.v1+jsonRESTfulComplex

URL Path Versioning (Recommended)

// Install: Microsoft.AspNetCore.Mvc.Versioning
        builder.Services.AddApiVersioning(options =>
        {
            options.AssumeDefaultVersionWhenUnspecified = true;
            options.DefaultApiVersion = new ApiVersion(1, 0);
            options.ApiVersionReader = ApiVersionReader.Combine(
                new UrlSegmentApiVersionReader(),
                new HeaderApiVersionReader("X-API-Version"),
                new QueryStringApiVersionReader("api-version"));
        }).AddApiExplorer(options =>
        {
            options.GroupNameFormat = "'v'VVV";
            options.SubstituteApiVersionInUrl = true;
        });

        // Versioned Minimal APIs
        var api = app.MapGroup("/api/v{version:apiVersion}/posts");
        api.MapGet("/", GetPostsV1)
           .MapToApiVersion(new ApiVersion(1, 0));
        api.MapGet("/enhanced", GetPostsV2)
           .MapToApiVersion(new ApiVersion(2, 0));

Deprecation Middleware (RFC 8594)

public class DeprecationMiddleware
        {
            private readonly RequestDelegate _next;

            public DeprecationMiddleware(RequestDelegate next)
            {
                _next = next;
            }

            public async Task InvokeAsync(HttpContext context)
            {
                var apiVersion = context.GetApiVersion()?.ToString();

                if (apiVersion == "1.0")
                {
                    context.Response.Headers.Deprecation = "true";
                    context.Response.Headers.Sunset = "Sat, 31 Dec 2026 23:59:59 GMT";
                    context.Response.Headers.Link = "<https://api.example.com/v2/>; rel=\"successor-version\"";
                    context.Response.Headers.Link += ", <https://docs.example.com/migration/v1-to-v2>; rel=\"deprecation\"";
                }

                await _next(context);
            }
        }

Schema Design

Reusable Models with Annotations

public class Post
        {
            [Required]
            [OpenApiSchema(Description = "Unique identifier", Example = 123)]
            public int Id { get; set; }

            [Required, MinLength(5), MaxLength(200)]
            [OpenApiSchema(Description = "Post title", Example = "Getting Started")]
            public string Title { get; set; } = string.Empty;

            [Required]
            public string Content { get; set; } = string.Empty;

            [OpenApiSchema(Format = "date-time")]
            public DateTime CreatedAt { get; set; }
        }

Polymorphism with Discriminator

public abstract class Notification
        {
            public string Type { get; set; } = string.Empty;
            public string Message { get; set; } = string.Empty;
        }

        public class EmailNotification : Notification
        {
            public string Email { get; set; } = string.Empty;
        }

        public class SmsNotification : Notification
        {
            public string PhoneNumber { get; set; } = string.Empty;
        }

        // OpenAPI oneOf schema with discriminator
        c.Map<Notification>(() => new OpenApiSchema
        {
            OneOf = new List<OpenApiSchema>
            {
                new() { Reference = new OpenApiReference { Type = ReferenceType.Schema, Id = "EmailNotification" } },
                new() { Reference = new OpenApiReference { Type = ReferenceType.Schema, Id = "SmsNotification" } }
            },
            Discriminator = new OpenApiDiscriminator
            {
                PropertyName = "Type",
                Mapping = new Dictionary<string, string>
                {
                    ["email"] = "EmailNotification",
                    ["sms"] = "SmsNotification"
                }
            }
        });

NSwag — Spec-First Workflow

CLI Commands

# Install
        dotnet tool install --global NSwag.ConsoleCore

        # Generate spec from ASP.NET Core assembly
        nswag aspnetcore2openapi \
          /assembly:./bin/Debug/net8.0/MyApi.dll \
          /output:openapi.json

        # Generate C# client
        nswag openapi2csclient \
          /input:openapi.json \
          /classname:BlogApiClient \
          /namespace:MyApp.Services \
          /output:BlogApiClient.cs \
          /generateClientInterfaces:true \
          /injectHttpClient:true

        # Generate TypeScript client
        nswag openapi2tsclient \
          /input:openapi.json \
          /output:src/api/blog-client.ts \
          /template:Fetch

        # Generate controller stubs (contract-first)
        nswag openapi2cscontroller \
          /input:openapi.json \
          /ControllerTarget:AspNetCore

nswag.json Configuration

{
          "runtime": "Net80",
          "defaultVariables": "Configuration=Debug",
          "documentGenerator": {
            "aspNetCoreToOpenApi": {
              "project": "MyApi.csproj",
              "output": "wwwroot/openapi.json",
              "outputType": "OpenApi3"
            }
          },
          "codeGenerators": {
            "openApiToCSharpClient": {
              "generateClientClasses": true,
              "generateClientInterfaces": true,
              "generateDtoTypes": true,
              "injectHttpClient": true,
              "disposeHttpClient": false,
              "exceptionClass": "ApiException",
              "className": "{controller}Client",
              "namespace": "MyApp.Services",
              "output": "Services/BlogApiClient.cs"
            },
            "openApiToTypeScriptClient": {
              "className": "{controller}Client",
              "template": "Fetch",
              "generateClientInterfaces": true,
              "generateDtoTypes": true,
              "output": "src/api/generated-client.ts"
            }
          }
        }

Multiple API Versions with NSwag

services.AddApiVersioning(options =>
        {
            options.AssumeDefaultVersionWhenUnspecified = true;
            options.ApiVersionReader = new UrlSegmentApiVersionReader();
        }).AddMvcCore()
        .AddVersionedApiExplorer(options =>
        {
            options.GroupNameFormat = "VVV";
            options.SubstituteApiVersionInUrl = true;
        });

        services.AddOpenApiDocument(doc =>
        {
            doc.DocumentName = "v1";
            doc.ApiGroupNames = new[] { "1" };
            doc.Title = "My API v1";
        });

        services.AddOpenApiDocument(doc =>
        {
            doc.DocumentName = "v2";
            doc.ApiGroupNames = new[] { "2" };
            doc.Title = "My API v2";
        });

        app.UseOpenApi();   // /swagger/v1/swagger.json, /swagger/v2/swagger.json
        app.UseSwaggerUI(); // single UI shows both

Practice Tasks

Swagger with Custom Document & Operation Filters

Implement IDocumentFilter and IOperationFilter as shown above.

API Versioning Strategy

Use Microsoft.AspNetCore.Mvc.Versioning with URL path versioning.

OpenAPI Spec-First with NSwag

  1. Write OpenAPI spec manually or generate from code
  2. Generate C# client: nswag openapi2csclient
  3. Generate TypeScript client: nswag openapi2tsclient
  4. Generate controller stubs for contract-first development

References

  • [OpenAPI Specification 3.1](https://spec.openapis.org/oas/v3.1.0)
  • [Swashbuckle ASP.NET Core](https://github.com/domaindrivendev/Swashbuckle.AspNetCore)
  • [NSwag documentation](https://github.com/RicoSuter/NSwag)
  • [RFC 8594 - Deprecation and Sunset Headers](https://www.rfc-editor.org/rfc/rfc8594)
  • [API Versioning with ASP.NET Core](https://learn.microsoft.com/en-us/aspnet/core/web-api/api-versioning)

Практика


gRPC

# gRPC

Protocol Buffers

Message Definition

Protocol Buffers (protobuf) is a serialization format from Google. ~3-10x smaller and ~20-100x faster than JSON.

syntax = "proto3";

        package ordermanagement;

        option csharp_namespace = "OrderManagement.Grpc";

        message Order {
          int32 id = 1;
          string customer_email = 2;
          repeated OrderItem items = 3;
          OrderStatus status = 4;
          Money total_amount = 5;
          string shipping_address = 6;
          google.protobuf.Timestamp created_at = 7;
          google.protobuf.Timestamp updated_at = 8;
        }

        message OrderItem {
          string product_id = 1;
          string product_name = 2;
          int32 quantity = 3;
          Money unit_price = 4;
        }

        message Money {
          int64 units = 1;
          int32 nanos = 2;
          string currency_code = 3;
        }

        enum OrderStatus {
          ORDER_STATUS_UNSPECIFIED = 0;
          PENDING = 1;
          CONFIRMED = 2;
          SHIPPED = 3;
          DELIVERED = 4;
          CANCELLED = 5;
        }

        import "google/protobuf/timestamp.proto";
        import "google/protobuf/empty.proto";

Field Numbering Rules

  • Numbers 1-15 for frequently used fields (1 byte in wire format)
  • Numbers 16-2045 for rarely used fields (2 bytes)
  • DO NOT delete field numbers - this breaks compatibility
  • Can mark removed fields as reserved
message User {
          reserved 7, 9;
          reserved "foo", "bar";

          int32 id = 1;
          string name = 2;
          string email = 3;
        }

gRPC Service Methods

Four Method Types

service OrderService {
          // Unary - single request, single response
          rpc CreateOrder(CreateOrderRequest) returns (Order);

          // Server Streaming - single request, stream of responses
          rpc GetOrderUpdates(OrderId) returns (stream OrderUpdate);

          // Client Streaming - stream of requests, single response
          rpc BatchCreateOrders(stream CreateOrderRequest) returns (BatchOrderResponse);

          // Bidirectional Streaming - streams both directions
          rpc TrackOrders(stream OrderQuery) returns (stream OrderTracking);
        }

        message OrderId { int32 order_id = 1; }

        message CreateOrderRequest {
          string customer_email = 1;
          repeated OrderItem items = 2;
          string shipping_address = 3;
        }

        message Order {
          int32 id = 1;
          string customer_email = 2;
          OrderStatus status = 3;
          google.protobuf.Timestamp created_at = 4;
        }

        message OrderUpdate {
          int32 order_id = 1;
          OrderStatus new_status = 2;
          google.protobuf.Timestamp updated_at = 3;
        }

        message BatchOrderResponse {
          repeated Order orders = 1;
        }

        message OrderQuery { int32 order_id = 1; }

        message OrderTracking {
          int32 order_id = 1;
          string location = 2;
          string status = 3;
          google.protobuf.Timestamp timestamp = 4;
        }

Method Type Comparison

TypeRequestResponseUse Case
Unary11CRUD operations, simple calls
Server Streaming1ManyReal-time updates, feeds
Client StreamingMany1Batch operations, file upload
BidirectionalManyManyChat, live collaboration

gRPC Interceptors

Server-Side Logging Interceptor

public class LoggingInterceptor : Interceptor
        {
            private readonly ILogger<LoggingInterceptor> _logger;

            public LoggingInterceptor(ILogger<LoggingInterceptor> logger)
            {
                _logger = logger;
            }

            public override async Task<TResponse> UnaryServerHandler<TRequest, TResponse>(
                TRequest request,
                ServerCallContext context,
                UnaryServerMethod<TRequest, TResponse> continuation)
            {
                _logger.LogInformation(
                    "Unary call: {Method} | CorrelationId: {CorrelationId}",
                    context.Method,
                    context.GetCorrelationId());

                var stopwatch = Stopwatch.StartNew();
                var response = await continuation(request, context);
                stopwatch.Stop();

                _logger.LogInformation(
                    "Completed: {Method} in {Elapsed}ms",
                    context.Method,
                    stopwatch.ElapsedMilliseconds);

                return response;
            }
        }

        public static class ServerCallContextExtensions
        {
            public static string GetCorrelationId(this ServerCallContext context)
            {
                var header = context.RequestHeaders
                    .FirstOrDefault(h => h.Key == "x-correlation-id");
                return header?.Value ?? Guid.NewGuid().ToString();
            }
        }

Authentication Interceptor

public class AuthInterceptor : Interceptor
        {
            public override async Task<TResponse> UnaryServerHandler<TRequest, TResponse>(
                TRequest request,
                ServerCallContext context,
                UnaryServerMethod<TRequest, TResponse> continuation)
            {
                var authHeader = context.RequestHeaders
                    .FirstOrDefault(h => h.Key == "authorization")?.Value;

                if (string.IsNullOrEmpty(authHeader) || !authHeader.StartsWith("Bearer "))
                {
                    throw new RpcException(
                        new Status(StatusCode.Unauthenticated, "Missing or invalid auth header"));
                }

                var token = authHeader.Substring("Bearer ".Length);
                if (!await ValidateTokenAsync(token))
                {
                    throw new RpcException(
                        new Status(StatusCode.Unauthenticated, "Invalid token"));
                }

                var user = await GetUserFromTokenAsync(token);
                context.Items["UserId"] = user.Id;
                context.Items["Roles"] = user.Roles;

                return await continuation(request, context);
            }

            private async Task<bool> ValidateTokenAsync(string token) => true;
            private async Task<User> GetUserFromTokenAsync(string token)
                => new User { Id = 1, Roles = new[] { "User", "Admin" } };
        }

        public record User(int Id, string[] Roles);

Error Handling Interceptor

public class ErrorHandlingInterceptor : Interceptor
        {
            private readonly ILogger<ErrorHandlingInterceptor> _logger;

            public ErrorHandlingInterceptor(ILogger<ErrorHandlingInterceptor> logger)
            {
                _logger = logger;
            }

            public override async Task<TResponse> UnaryServerHandler<TRequest, TResponse>(
                TRequest request,
                ServerCallContext context,
                UnaryServerMethod<TRequest, TResponse> continuation)
            {
                try
                {
                    return await continuation(request, context);
                }
                catch (NotFoundException ex)
                {
                    _logger.LogWarning(ex, "Not found: {Method}", context.Method);
                    throw new RpcException(new Status(StatusCode.NotFound, ex.Message));
                }
                catch (ValidationException ex)
                {
                    _logger.LogWarning(ex, "Validation error: {Method}", context.Method);
                    throw new RpcException(new Status(StatusCode.InvalidArgument, "Validation failed"));
                }
                catch (Exception ex)
                {
                    _logger.LogError(ex, "Unexpected error: {Method}", context.Method);
                    throw new RpcException(new Status(StatusCode.Internal, "An unexpected error occurred"));
                }
            }
        }

        public class NotFoundException : Exception
        {
            public NotFoundException(string message) : base(message) { }
        }

        public class ValidationException : Exception
        {
            public ValidationException(List<ValidationError> errors) { Errors = errors; }
            public List<ValidationError> Errors { get; }
        }

        public class ValidationError
        {
            public string PropertyName { get; set; } = string.Empty;
            public string Message { get; set; } = string.Empty;
        }

Register Interceptors

builder.Services.AddGrpc(options =>
        {
            options.Interceptors.Add<LoggingInterceptor>();
            options.Interceptors.Add<AuthInterceptor>();
            options.Interceptors.Add<ErrorHandlingInterceptor>();
        });

gRPC vs REST Performance

Benchmark Comparison

MetricgRPC (protobuf)REST (JSON)
Payload size~200 bytes~500 bytes
Serialization time~0.1ms~0.5ms
P95 latency (local)5-10ms20-50ms
P95 latency (network)10-20ms30-80ms
Throughput50,000+ req/s10,000-20,000 req/s
HTTP versionHTTP/2HTTP/1.1 or HTTP/2
MultiplexingYesYes (HTTP/2)

When to Use Each

ScenarioRecommended
Internal microservice communicationgRPC
High-performance real-time updatesgRPC
Public API with web/mobile clientsREST
Browser-based clientsREST (or GraphQL)
File uploads/downloadsREST
Server streaming (feeds, updates)gRPC
Complex query patternsGraphQL

HTTP/2 Multiplexing

HTTP/2 advantages for gRPC:

  • Multiplexing: Multiple requests/responses over a single TCP connection
  • Header compression: HPACK reduces overhead
  • Binary framing: More efficient than text-based HTTP/1.1
  • Server push: Server can send data without client request
  • Prioritization: Streams can have priorities
// Configure Kestrel for HTTP/2
        builder.WebHost.ConfigureKestrel(options =>
        {
            options.ListenAnyIP(5001, listenOptions =>
            {
                listenOptions.Protocols = HttpProtocols.Http2;
                listenOptions.UseHttps(); // HTTP/2 requires TLS
            });
        });

Practice Tasks

Order Management Service (.proto)

See service OrderService section above - full contract for order management.

Bidirectional Streaming for Real-Time Updates

The TrackOrders method in proto: client sends OrderQuery, server streams OrderTracking.

public override async IAsyncEnumerable<OrderTracking> TrackOrders(
            IAsyncEnumerable<OrderQuery> requestStream,
            ServerCallContext context)
        {
            await foreach (var query in requestStream)
            {
                var tracking = await GetTrackingAsync(query.OrderId);
                yield return tracking;
            }
        }

gRPC vs REST Benchmark

Use BenchmarkDotNet:

[MemoryDiagnoser]
        public class GrpcVsRestBenchmark
        {
            private GrpcClient _grpcClient;
            private HttpClient _httpClient;

            [GlobalSetup]
            public void Setup()
            {
                _grpcClient = new GrpcClient(
                    new Channel("localhost:5001", ChannelCredentials.Insecure));
                _httpClient = new HttpClient
                {
                    BaseAddress = new Uri("http://localhost:5000")
                };
            }

            [Benchmark]
            public async Task<Order> Grpc_GetOrder()
            {
                return await _grpcClient.OrderService.GetOrderAsync(
                    new OrderId { OrderId = 1 });
            }

            [Benchmark]
            public async Task<OrderDto> Rest_GetOrder()
            {
                var json = await _httpClient.GetStringAsync("/api/orders/1");
                return JsonSerializer.Deserialize<OrderDto>(json);
            }
        }

References

  • [gRPC C# Quick Start](https://learn.microsoft.com/en-us/aspnet/core/grpc/)
  • [Protocol Buffers Language Guide](https://protobuf.dev/programming-guides/proto3/)
  • [gRPC Concepts](https://grpc.io/docs/what-is-grpc/core-concepts/)
  • [ASP.NET Core gRPC tutorial](https://learn.microsoft.com/en-us/aspnet/core/tutorials/grpc/)

Практика


GraphQL

# GraphQL

Schema Definition

Types, Queries, Mutations, Subscriptions

GraphQL schema defines the contract between client and server. It uses Schema Definition Language (SDL) or code-first approach.

# Schema Definition Language (SDL)
        type Query {
          posts(first: Int, after: String): PostConnection!
          post(id: ID!): Post
          comments(postId: ID!, first: Int): CommentConnection!
        }

        type Mutation {
          createPost(input: CreatePostInput!): CreatePostPayload!
          updatePost(input: UpdatePostInput!): UpdatePostPayload!
          deletePost(id: ID!): DeletePayload!
        }

        type Subscription {
          postCreated: Post!
          postUpdated(id: ID!): Post!
          commentAdded(postId: ID!): Comment!
        }

        type Post {
          id: ID!
          title: String!
          content: String!
          slug: String!
          author: User!
          comments: [Comment!]!
          tags: [Tag!]!
          createdAt: DateTime!
          updatedAt: DateTime!
        }

        type User {
          id: ID!
          name: String!
          email: String!
          avatar: String
          posts(first: Int): [Post!]!
        }

        input CreatePostInput {
          title: String!
          content: String!
          tagIds: [ID!]
        }

        type PostConnection {
          edges: [PostEdge!]!
          pageInfo: PageInfo!
          totalCount: Int!
        }

        type PostEdge {
          node: Post!
          cursor: String!
        }

        type PageInfo {
          hasNextPage: Boolean!
          hasPreviousPage: Boolean!
          startCursor: String
          endCursor: String
        }

Code-First Approach (Hot Chocolate)

// Type definition
        public class PostType : ObjectType<Post>
        {
            protected override void Configure(IObjectTypeDescriptor<Post> descriptor)
            {
                descriptor
                    .Description("A blog post")
                    .Implements<NodeType<Post>>(); // Relay-compatible

                descriptor.Field(p => p.Id).Description("Unique identifier");
                descriptor.Field(p => p.Title).Description("Post title");
                descriptor.Field(p => p.Content).Description("Post body");
                descriptor.Field(p => p.Author).ResolveWith<PostResolvers>(r => r.ResolveAuthor(default!));
                descriptor.Field(p => p.CreatedAt).Type<NonNullType<DateTimeType>>();
            }
        }

        // Query type
        public class QueryType
        {
            [UsePaging<Post>(MaxPageSize = 50, IncludeTotalCount = true)]
            [UseProjection]
            [UseFiltering]
            [UseSorting]
            public IQueryable<Post> GetPosts([Service] ApplicationDb db)
                => db.Posts.Include(p => p.Author);

            public Post? GetPost([Service] ApplicationDb db, [Required] int id)
                => db.Posts.Include(p => p.Author).FirstOrDefault(p => p.Id == id);
        }

        // Mutation type
        public class MutationType
        {
            public async Task<CreatePostPayload> CreatePostAsync(
                [Service] ApplicationDb db,
                [Service] IValidator<CreatePostInput> validator,
                CreatePostInput input,
                CancellationToken ct)
            {
                var result = await validator.ValidateAsync(input, ct);
                if (!result.IsValid)
                    return CreatePostPayload.WithValidationErrors(result.ToErrors());

                var post = new Post
                {
                    Title = input.Title,
                    Content = input.Content,
                    Slug = input.Title.ToSlug(),
                    AuthorId = 1 // from auth context
                };

                if (input.TagIds != null)
                {
                    foreach (var tagId in input.TagIds)
                    {
                        var tag = await db.Tags.FindAsync(tagId, ct);
                        if (tag != null) post.Tags.Add(tag);
                    }
                }

                db.Posts.Add(post);
                await db.SaveChangesAsync(ct);

                return CreatePostPayload.WithPost(post);
            }
        }

Registration

builder.Services
            .AddGraphQLServer()
            .AddQueryType<QueryType>()
            .AddMutationType<MutationType>()
            .AddSubscriptionType<SubscriptionType>()
            .AddType<PostType>()
            .AddType<UserType>()
            .AddType<TagType>()
            .AddFiltering()
            .AddSorting()
            .AddProjections()
            .AddDataLoaderRegistry()
            .AddMaxExecutionDepthRule(10)
            .AddComplexityAnalysis();

Resolver Pattern

Data Fetching Strategies

public class PostResolvers
        {
            // Resolve with DataLoader (batching)
            public async Task<User> ResolveAuthorAsync(int postId, PostByIdDataLoader postLoader)
            {
                var post = await postLoader.LoadAsync(postId);
                return post?.Author;
            }

            // Direct resolver
            public async Task<IReadOnlyList<Comment>> ResolveCommentsAsync(
                Post post,
                [Service] ApplicationDb db,
                CancellationToken ct)
            {
                return await db.Comments
                    .Where(c => c.PostId == post.Id)
                    .OrderByDescending(c => c.CreatedAt)
                    .ToListAsync(ct);
            }
        }

Resolver Context

public class Resolvers
        {
            public async Task<Post> GetPostAsync(
                [Service] ApplicationDb db,
                [Service] IDocumentContext context,
                int id)
            {
                // Access document context for query info
                var query = context.Request.Query;
                var selections = context.SelectionSet.Selections;

                return await db.Posts
                    .Include(p => p.Author)
                    .FirstOrDefaultAsync(p => p.Id == id);
            }
        }

N+1 Problem & DataLoader

The N+1 Problem

# Client query
        {
          posts(first: 10) {
            edges {
              node {
                title
                author {
                  name
                  avatar
                }
                comments {
                  text
                  author {
                    name
                  }
                }
              }
            }
          }
        }

Without DataLoader, this generates:

  • 1 query for posts
  • 10 queries for authors (one per post)
  • N queries for comments (one per post)
  • M queries for comment authors

Total: 1 + 10 + N + M queries (N+1 problem)

DataLoader Solution

// Batch DataLoader for Posts
        public class PostByIdDataLoader : BatchDataLoader<int, Post>
        {
            private readonly IDbContextFactory<ApplicationDb> _dbFactory;

            public PostByIdDataLoader(IDbContextFactory<ApplicationDb> dbFactory)
                : base(dbFactory) { }

            protected override async Task<IReadOnlyDictionary<int, Post>> LoadBatchAsync(
                IReadOnlyList<int> keys,
                CancellationToken cancellationToken)
            {
                using var db = await _dbFactory.CreateDbContextAsync(cancellationToken);
                return await db.Posts
                    .AsNoTracking()
                    .Include(p => p.Author)
                    .Where(p => keys.Contains(p.Id))
                    .ToDictionaryAsync(p => p.Id, cancellationToken);
            }
        }

        // Group DataLoader for Comments (one post -> many comments)
        public class CommentsByPostIdDataLoader : GroupDataLoader<int>
        {
            private readonly IDbContextFactory<ApplicationDb> _dbFactory;

            public CommentsByPostIdDataLoader(IDbContextFactory<ApplicationDb> dbFactory)
                : base(dbFactory) { }

            protected override async Task<ILookup<int, Comment>> LoadBatchAsync(
                IReadOnlyList<int> postIds,
                CancellationToken cancellationToken)
            {
                using var db = await _dbFactory.CreateDbContextAsync(cancellationToken);
                return await db.Comments
                    .AsNoTracking()
                    .Include(c => c.Author)
                    .Where(c => postIds.Contains(c.PostId))
                    .ToLookupAsync(c => c.PostId, cancellationToken);
            }
        }

        // Usage in resolver
        public async Task<IReadOnlyList<Comment>> GetCommentsAsync(
            Post post,
            CommentsByPostIdDataLoader commentsLoader)
        {
            var comments = await commentsLoader.LoadAsync(post.Id);
            return comments.ToList();
        }

Register DataLoaders

builder.Services.AddDataLoaderRegistry();
        // Or explicitly:
        builder.Services.AddSingleton<PostByIdDataLoader>();
        builder.Services.AddSingleton<CommentsByPostIdDataLoader>();
        builder.Services.AddSingleton<AuthorByIdDataLoader>();

Query Complexity Analysis & Protection

Complexity Analysis

// Configure complexity analysis
        builder.Services.AddGraphQLServer()
            .AddComplexityAnalysis(options =>
            {
                options.MaxAllowedComplexity = 1000;
                options.ThrowOnExceeded = true;
            });

        // Custom cost per field
        builder.Services.Configure<ComplexityConfiguration>(options =>
        {
            options.FieldCosts["Post.author"] = 5;
            options.FieldCosts["Post.comments"] = 10;
            options.FieldCosts["Comment.author"] = 3;
        });

Depth Limiting

builder.Services.AddGraphQLServer()
            .AddMaxExecutionDepthRule(10); // Max nesting depth

Query Cost Calculator Middleware

public class QueryCostMiddleware
        {
            private readonly RequestDelegate _next;
            private readonly ILogger<QueryCostMiddleware> _logger;

            public QueryCostMiddleware(RequestDelegate next, ILogger<QueryCostMiddleware> logger)
            {
                _next = next;
                _logger = logger;
            }

            public async Task InvokeAsync(HttpContext context)
            {
                if (context.Request.Path.StartsWithSegments("/graphql") &&
                    context.Request.HasJsonContentType())
                {
                    var body = await new StreamReader(context.Request.Body).ReadToEndAsync();
                    context.Request.Body.Position = 0;

                    var query = JsonSerializer.Deserialize<GraphQlRequest>(body);
                    if (query?.Query != null)
                    {
                        var cost = CalculateQueryCost(query.Query);
                        _logger.LogInformation("Query cost: {Cost} | Operation: {Operation}",
                            cost, query.OperationName);

                        if (cost > 5000)
                        {
                            context.Response.StatusCode = 422;
                            await context.Response.WriteAsJsonAsync(new
                            {
                                errors = new[]
                                {
                                    new { message = "Query too complex", extensions = new { code = "QUERY_TOO_COMPLEX" } }
                                }
                            });
                            return;
                        }
                    }
                }

                await _next(context);
            }

            private int CalculateQueryCost(string query)
            {
                // Simple heuristic: count field occurrences, multiply by depth
                var lines = query.Split('\n');
                var depth = 0;
                var maxDepth = 0;
                var fieldCount = 0;

                foreach (var line in lines)
                {
                    var trimmed = line.Trim();
                    if (trimmed.StartsWith('{')) depth++;
                    if (trimmed.EndsWith('}')) depth--;
                    maxDepth = Math.Max(maxDepth, depth);

                    if (!string.IsNullOrEmpty(trimmed) &&
                        !trimmed.StartsWith('{') && !trimmed.EndsWith('}') &&
                        !trimmed.StartsWith('#') && !trimmed.Contains(':') == false)
                    {
                        fieldCount++;
                    }
                }

                return fieldCount * maxDepth;
            }
        }

        public record GraphQlRequest(string? Query, string? OperationName, object? Variables);

Federation

Subgraph (Hot Chocolate)

// Product subgraph
        builder.Services.AddGraphQLServer()
            .AddQueryType<ProductQuery>()
            .AddFederatedQueryType() // Federation-aware query type
            .AddFederatedType<Product>()
            .AddFederatedEntity();

        // Product entity with key
        public class ProductType : FederatedEntityType<Product>
        {
            protected override object GetEntityKey(Product product) => product.Id;
        }

Gateway (Supergraph)

builder.Services.AddGraphQLServer()
            .AddQueryType<Query>()
            .AddRemoteSchema(
                name: "products",
                url: new Uri("http://products-service:4000/graphql"))
            .AddRemoteSchema(
                name: "orders",
                url: new Uri("http://orders-service:4001/graphql"))
            .AddRemoteSchema(
                name: "users",
                url: new Uri("http://users-service:4002/graphql"));

Federation Schema Stitching

# Gateway query type
        type Query {
          _entities(representations: [_Any!]!): [_Entity]!
          products: [Product!]!
          orders: [Order!]!
          user(id: ID!): User
        }

Subscriptions (Real-time)

Server-Side

public class SubscriptionType
        {
            private readonly Channel<Post> _postCreatedChannel;
            private readonly Channel<Comment> _commentAddedChannel;

            public SubscriptionType()
            {
                _postCreatedChannel = Channel.CreateUnbounded<Post>();
                _commentAddedChannel = Channel.CreateUnbounded<Comment>();
            }

            [Subscribe]
            [Topic("PostCreated")]
            public async IAsyncEnumerable<Post> PostCreated([EventMessage] Post post)
            {
                yield return post;
            }

            [Subscribe]
            [Topic("CommentAdded")]
            public async IAsyncEnumerable<Comment> CommentAdded(
                [Service] ApplicationDb db,
                int postId)
            {
                // Real-time comment stream for a specific post
                await foreach (var comment in GetCommentStream(postId))
                {
                    yield return comment;
                }
            }
        }

        // Emit events
        public class CommentService
        {
            private readonly ITopicPublisher _publisher;

            public async Task AddCommentAsync(int postId, Comment comment)
            {
                // Save comment
                await _db.Comments.AddAsync(comment);
                await _db.SaveChangesAsync();

                // Emit subscription event
                await _publisher.PublishAsync("CommentAdded", postId, comment);
            }
        }

Client-Side (Subscription)

subscription OnCommentAdded($postId: ID!) {
          commentAdded(postId: $postId) {
            id
            text
            author {
              name
            }
            createdAt
          }
        }

Practice Tasks

E-Commerce GraphQL Schema

Design a schema for an e-commerce platform with:

  • Products (with categories, variants, reviews)
  • Orders (line items, payments, shipping)
  • Users (profiles, addresses, favorites)
  • Search (full-text, filters, sorting)
type Query {
          searchProducts(query: String!, filters: ProductFilters): ProductSearchResult!
          product(id: ID!): Product
          category(slug: String!): Category
          orders(first: Int, after: String): OrderConnection!
        }

        type Mutation {
          addToCart(input: AddToCartInput!): CartPayload!
          checkout(input: CheckoutInput!): OrderPayload!
          cancelOrder(id: ID!): CancelOrderPayload!
        }

        input ProductFilters {
          categoryId: ID
          minPrice: Float
          maxPrice: Float
          inStock: Boolean
          sortBy: ProductSortField
        }

DataLoader for Batching Database Queries

Implement DataLoaders for:

  • ProductByIdDataLoader -- batch product lookups
  • ReviewsByProductIdDataLoader -- batch reviews per product
  • UserByIdDataLoader -- batch user lookups

Query Depth Limiter

Implement query depth limiting to prevent abusive queries:

  • Configure max depth via AddMaxExecutionDepthRule(10)
  • Add custom cost analysis middleware
  • Return meaningful errors when limits are exceeded

References

  • [Hot Chocolate Documentation](https://chillicream.com/docs/hotchocolate)
  • [GraphQL Specification](https://graphql.org/spec/)
  • [Relay Cursor Connections Specification](https://relay.dev/graphql/connections.htm)
  • [Apollo Federation Specification](https://www.apollographql.com/docs/federation/)
  • [GreenDonut (DataLoader for .NET)](https://github.com/ChilliCream/GreenDonut)

Практика


API Gateway Patterns

# API Gateway Patterns

YARP (Yet Another Reverse Proxy)

Overview

YARP (Yet Another Reverse Proxy) is a high-performance reverse proxy toolkit for .NET, built on ASP.NET Core. It provides routing, load balancing, health checks, and request/response transformation capabilities.

Installation

dotnet add package Yarp.ReverseProxy

Basic Configuration

// Program.cs
        builder.Services.AddReverseProxy()
            .LoadFromConfig(builder.Configuration.GetSection("ReverseProxy"));

        var app = builder.Build();

        app.MapReverseProxy();

Configuration via appsettings.json

{
          "ReverseProxy": {
            "Routes": {
              "blog-api": {
                "ClusterId": "blog-cluster",
                "Match": {
                  "Path": "/api/blog/{**remainder}"
                },
                "Transforms": [
                  { "PathRemovePrefix": "/api/blog" },
                  { "PathAppend": true }
                ],
                "RateLimiterPolicy": "blog-limit"
              },
              "order-api": {
                "ClusterId": "order-cluster",
                "Match": {
                  "Path": "/api/orders/{**remainder}"
                },
                "Transforms": [
                  { "PathRemovePrefix": "/api/orders" }
                ]
              },
              "graphql": {
                "ClusterId": "graphql-cluster",
                "Match": {
                  "Path": "/graphql"
                }
              }
            },
            "Clusters": {
              "blog-cluster": {
                "Destinations": {
                  "destination1": {
                    "Address": "http://blog-service:5001"
                  },
                  "destination2": {
                    "Address": "http://blog-service:5002"
                  }
                },
                "HealthCheck": {
                  "Active": {
                    "Enabled": true,
                    "Interval": "00:00:10",
                    "Timeout": "00:00:05",
                    "Path": "/health"
                  }
                },
                "LoadBalancingPolicy": "round-robin"
              },
              "order-cluster": {
                "Destinations": {
                  "destination1": {
                    "Address": "http://order-service:5003"
                  }
                }
              },
              "graphql-cluster": {
                "Destinations": {
                  "destination1": {
                    "Address": "http://graphql-service:5004"
                  }
                }
              }
            }
          }
        }

Routing

Path Matching

{
          "ReverseProxy": {
            "Routes": {
              "exact-match": {
                "ClusterId": "exact-cluster",
                "Match": { "Path": "/api/exact" }
              },
              "prefix-match": {
                "ClusterId": "prefix-cluster",
                "Match": { "Path": "/api/prefix/{**remainder}" }
              },
              "wildcard": {
                "ClusterId": "wildcard-cluster",
                "Match": { "Path": "*" }
              },
              "host-based": {
                "ClusterId": "host-cluster",
                "Match": {
                  "Path": "/api/{**remainder}",
                  "Hosts": [ "api.example.com", "v1.example.com" ]
                }
              },
              "method-based": {
                "ClusterId": "read-cluster",
                "Match": {
                  "Path": "/api/data/{**remainder}",
                  "Methods": [ "GET", "HEAD" ]
                }
              },
              "header-based": {
                "ClusterId": "header-cluster",
                "Match": {
                  "Path": "/api/{**remainder}",
                  "Headers": [
                    { "Name": "X-Api-Version", "Values": [ "v1", "v2" ] }
                  ]
                }
              }
            }
          }
        }

Programmatic Routing

builder.Services.AddReverseProxy()
            .InitializeAsync(async (config) =>
            {
                config.routes.Add("api-route", new RouteConfig
                {
                    ClusterId = "api-cluster",
                    Match = new RouteMatch
                    {
                        Path = "/api/{**remainder}"
                    },
                    Transforms = new[]
                    {
                        new PathRemovePrefixTransform("/api"),
                        new PathAppendTransform(true)
                    }
                });

                config.clusters.Add("api-cluster", new ClusterConfig
                {
                    Destinations = new Dictionary<string, DestinationConfig>
                    {
                        ["dest1"] = new DestinationConfig { Address = "http://api1:5000" },
                        ["dest2"] = new DestinationConfig { Address = "http://api2:5000" }
                    }
                });
            });

Load Balancing

Strategies

StrategyDescriptionUse Case
round-robinDistribute evenly across healthy destinationsGeneral purpose
least-requestsSend to destination with fewest active requestsVariable response times
randomRandom selectionSimple distribution
power-of-two-choicesPick two random, choose least loadedHigh throughput
firstFirst healthy destinationSingle-primary setups

Configuration

{
          "ReverseProxy": {
            "Clusters": {
              "high-traffic-cluster": {
                "LoadBalancingPolicy": "power-of-two-choices",
                "Destinations": { ... }
              },
              "variable-latency-cluster": {
                "LoadBalancingPolicy": "least-requests",
                "Destinations": { ... }
              }
            }
          }
        }

Health-Based Load Balancing

{
          "ReverseProxy": {
            "Clusters": {
              "health-checked-cluster": {
                "LoadBalancingPolicy": "round-robin",
                "HealthCheck": {
                  "Active": {
                    "Enabled": true,
                    "Interval": "00:00:10",
                    "Timeout": "00:00:05",
                    "Path": "/health",
                    "Policy": "consecutive-errors",
                    "HostHeader": "health-check"
                  },
                  "Passive": {
                    "Enabled": true,
                    "Policy": "failures",
                    "Interval": "00:00:30",
                    "Timeout": "00:00:05"
                  }
                },
                "Destinations": {
                  "dest1": {
                    "Address": "http://service1:5000",
                    "Health": { "Active": { "Host": "service1" } }
                  },
                  "dest2": {
                    "Address": "http://service2:5000",
                    "Health": { "Active": { "Host": "service2" } }
                  }
                }
              }
            }
          }
        }

Active Health Check

builder.Services.AddReverseProxy()
            .LoadFromConfig(builder.Configuration.GetSection("ReverseProxy"))
            .ConfigureHttpClient((context, client) =>
            {
                client.Timeout = TimeSpan.FromSeconds(30);
                client.DefaultRequestHeaders.Add("X-Proxy", "YARP");
            });

Request/Response Transformation

Transforms

{
          "ReverseProxy": {
            "Routes": {
              "transformed": {
                "ClusterId": "backend-cluster",
                "Match": { "Path": "/api/v1/{**remainder}" },
                "Transforms": [
                  { "PathRemovePrefix": "/api/v1" },
                  { "PathAppend": true },
                  { "RequestHeader": { "Set": [ { "Name": "X-Forwarded-For", "Value": "{ClientIpAddress}" } ] } },
                  { "RequestHeader": { "Set": [ { "Name": "X-Proxy-Request", "Value": "true" } ] } },
                  { "ResponseHeader": { "Set": [ { "Name": "X-Proxy", "Value": "YARP" } ] } },
                  { "ResponseHeader": { "Delete": [ "Server" ] } }
                ]
              }
            }
          }
        }

Protocol Translation

// Custom transform for gRPC-to-HTTP translation
        public class GrpcToHttpTransform : IEndpointFilter
        {
            private readonly IGrpcChannelFactory _channelFactory;

            public GrpcToHttpTransform(IGrpcChannelFactory channelFactory)
            {
                _channelFactory = channelFactory;
            }

            public async ValueTask<object?> InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next)
            {
                var grpcChannel = _channelFactory.GetChannel("order-service");
                var orderClient = new Order.OrderClient(grpcChannel);

                // Translate HTTP request to gRPC call
                var httpRequest = context.Arguments[0] as HttpRequest;
                var orderRequest = DeserializeFromHttp(httpRequest);

                var response = await orderClient.CreateOrderAsync(orderRequest);

                return SerializeToHttp(response);
            }

            private OrderRequest DeserializeFromHttp(HttpRequest request) => throw new NotImplementedException();
            private HttpResponse SerializeToHttp(OrderResponse response) => throw new NotImplementedException();
        }

Payload Shaping

public class PayloadShapingMiddleware
        {
            private readonly RequestDelegate _next;

            public PayloadShapingMiddleware(RequestDelegate next)
            {
                _next = next;
            }

            public async Task InvokeAsync(HttpContext context)
            {
                var originalBody = context.Response.Body;
                using var responseBody = new MemoryStream();
                context.Response.Body = responseBody;

                await _next(context);

                responseBody.Seek(0, SeekOrigin.Begin);
                var responseText = await new StreamReader(responseBody).ReadToEndAsync();

                // Shape response for mobile clients
                if (context.Request.Headers["X-Client-Type"] == "mobile")
                {
                    var shaped = ShapeForMobile(responseText);
                    await context.Response.WriteAsync(shaped);
                }
                else
                {
                    responseBody.CopyTo(originalBody);
                }
            }

            private string ShapeForMobile(string jsonResponse)
            {
                // Remove expensive fields for mobile
                var json = JsonSerializer.Deserialize<JsonElement>(jsonResponse);
                // ... shape the response
                return JsonSerializer.Serialize(json);
            }
        }

API Composition

Aggregating Multiple Services

// Composition service that aggregates data from multiple backends
        public class DashboardCompositionService
        {
            private readonly HttpClient _httpClient;
            private readonly ILogger<DashboardCompositionService> _logger;

            public DashboardCompositionService(IHttpClientFactory factory, ILogger<DashboardCompositionService> logger)
            {
                _httpClient = factory.CreateClient("composition");
                _logger = logger;
            }

            public async Task<DashboardData> GetDashboardAsync(string userId)
            {
                // Parallel requests to multiple services
                var userTask = _httpClient.GetFromJsonAsync<UserDto>($"http://user-service/api/users/{userId}");
                var ordersTask = _httpClient.GetFromJsonAsync<OrderDto[]>($"http://order-service/api/orders?userId={userId}");
                var analyticsTask = _httpClient.GetFromJsonAsync<AnalyticsDto>($"http://analytics-service/api/analytics/user/{userId}");

                await Task.WhenAll(userTask, ordersTask, analyticsTask);

                return new DashboardData
                {
                    User = userTask.Result,
                    RecentOrders = ordersTask.Result?.Take(5).ToArray() ?? [],
                    Analytics = analyticsTask.Result,
                    GeneratedAt = DateTime.UtcNow
                };
            }
        }

        // YARP route for composition
        builder.Services.AddReverseProxy()
            .InitializeAsync(async config =>
            {
                config.routes.Add("dashboard", new RouteConfig
                {
                    ClusterId = "composition-cluster",
                    Match = new RouteMatch { Path = "/api/dashboard/{**remainder}" }
                });

                config.clusters.Add("composition-cluster", new ClusterConfig
                {
                    Destinations = new Dictionary<string, DestinationConfig>
                    {
                        ["composition-service"] = new DestinationConfig { Address = "http://composition-service:5005" }
                    }
                });
            });

Composition Endpoint

// Minimal API for dashboard composition
        app.MapGroup("/api/composition").RequireAuthorization()
            .MapGet("/dashboard", async (
                string userId,
                DashboardCompositionService compositionService) =>
            {
                try
                {
                    var data = await compositionService.GetDashboardAsync(userId);
                    return Results.Ok(data);
                }
                catch (HttpRequestException ex)
                {
                    _logger.LogWarning(ex, "Service call failed for user {UserId}", userId);
                    // Return partial data
                    return Results.Problem(
                        detail: "Some services are unavailable, returning partial data",
                        statusCode: 207); // Multi-Status
                }
            });

BFF (Backend for Frontend) Pattern

BFF Architecture

                    +-----------+
                            |   Web     |
                            |  Client   |
                            +-----+-----+
                                  |
                            +-----v-----+
                            |    BFF    |
                            |  Layer    |
                            +-----+-----+
                                  |
                      +-----------+-----------+
                      |           |           |
                +-----v---+ +-----v---+ +-----v---+
                |  REST   | |  gRPC   | | GraphQL |
                |  API    | | Service | |  API  |
                +---------+ +---------+ +-------+

BFF Implementation

// Mobile BFF
        public class MobileBffEndpoints
        {
            public static void MapMobileBff(WebApplication app)
            {
                var bff = app.MapGroup("/bff/mobile").RequireAuthorization();

                // Aggregated dashboard for mobile
                bff.MapGet("/dashboard", async (HttpContext ctx, HttpClient httpClient) =>
                {
                    var userId = ctx.GetUserId();

                    // Fetch and shape data specifically for mobile
                    var user = await httpClient.GetFromJsonAsync<UserDto>($"http://user-service/api/users/{userId}");
                    var recentOrders = await httpClient.GetFromJsonAsync<OrderSummaryDto[]>($"http://order-service/api/orders/{userId}/recent?count=5");
                    var notifications = await httpClient.GetFromJsonAsync<NotificationDto[]>($"http://notification-service/api/notifications/{userId}?unread=true");

                    return Results.Ok(new
                    {
                        user = new { user.Id, user.Name, user.Avatar }, // Minimal user data
                        recentOrders,
                        notifications,
                        summary = new
                        {
                            unreadCount = notifications?.Length ?? 0,
                            recentOrderCount = recentOrders?.Length ?? 0
                        }
                    });
                });

                // Optimized product listing for mobile
                bff.MapGet("/products", async (HttpContext ctx, HttpClient httpClient,
                    [FromQuery] int page = 1, [FromQuery] int pageSize = 20) =>
                {
                    var userId = ctx.GetUserId();

                    var products = await httpClient.GetFromJsonAsync<PaginatedResponse<ProductDto>>(
                        $"http://product-service/api/products?page={page}&pageSize={pageSize}&userId={userId}");

                    return Results.Ok(new
                    {
                        data = products?.Items?.Select(p => new
                        {
                            p.Id,
                            p.Name,
                            p.Price,
                            p.ImageUrl,
                            p.InStock
                        }),
                        pagination = products?.Pagination
                    });
                });
            }
        }

Desktop BFF

// Desktop BFF - more data-rich responses
        public class DesktopBffEndpoints
        {
            public static void MapDesktopBff(WebApplication app)
            {
                var bff = app.MapGroup("/bff/desktop").RequireAuthorization();

                bff.MapGet("/dashboard", async (HttpContext ctx, HttpClient httpClient) =>
                {
                    var userId = ctx.GetUserId();

                    // Full data for desktop clients
                    var user = await httpClient.GetFromJsonAsync<UserDto>($"http://user-service/api/users/{userId}");
                    var allOrders = await httpClient.GetFromJsonAsync<OrderDto[]>($"http://order-service/api/orders/{userId}");
                    var analytics = await httpClient.GetFromJsonAsync<FullAnalyticsDto>($"http://analytics-service/api/analytics/user/{userId}");
                    var recommendations = await httpClient.GetFromJsonAsync<RecommendationDto[]>($"http://recommendation-service/api/recommendations/{userId}");

                    return Results.Ok(new
                    {
                        user,
                        orders = allOrders,
                        analytics,
                        recommendations,
                        trends = analytics?.Trends
                    });
                });
            }
        }

Rate Limiting on Gateway

// Register rate limiter
        builder.Services.AddRateLimiter(options =>
        {
            options.AddPolicy("api-limit", context =>
            {
                var apiKey = context.HttpContext.Request.Headers["X-Api-Key"].ToString();

                return apiKey switch
                {
                    "premium-key" => RateLimitPartition.GetFixedWindowLimiter(
                        apiKey, _ => new FixedWindowRateLimiterOptions
                        {
                            PermitLimit = 1000,
                            Window = TimeSpan.FromMinutes(1)
                        }),
                    _ => RateLimitPartition.GetFixedWindowLimiter(
                        apiKey, _ => new FixedWindowRateLimiterOptions
                        {
                            PermitLimit = 100,
                            Window = TimeSpan.FromMinutes(1)
                        })
                };
            });
        });

        // Apply to YARP
        builder.Services.AddReverseProxy()
            .LoadFromConfig(builder.Configuration.GetSection("ReverseProxy"))
            .ConfigureLimiter((context, config) =>
            {
                config.AddRateLimiter(new RateLimiterOptions
                {
                    RejectionStatusCode = 429,
                    OnRejected = async (context, token) =>
                    {
                        context.HttpContext.Response.Headers.RetryAfter = "60";
                        await context.HttpContext.Response.WriteAsJsonAsync(new ProblemDetails
                        {
                            Status = 429,
                            Title = "Too Many Requests",
                            Detail = "Rate limit exceeded. Please retry after the specified time."
                        }, cancellationToken: token);
                    }
                });
            });

Circuit Breaking

builder.Services.AddReverseProxy()
            .LoadFromConfig(builder.Configuration.GetSection("ReverseProxy"))
            .ConfigureHttpClient((context, client) =>
            {
                // Add Polly circuit breaker
                var policyRegistry = context.ServiceProvider.GetRequiredService<IPolicyRegistry>();
                var circuitBreakerPolicy = Policy
                    .Handle<HttpRequestException>()
                    .Or<TimeoutException>()
                    .CircuitBreakerAsync(
                        exceptionsAllowedBeforeBreaking: 5,
                        durationOfBreak: TimeSpan.FromMinutes(1),
                        onBreak: (outcome, timespan) =>
                        {
                            // Circuit opened
                        },
                        onReset: () =>
                        {
                            // Circuit closed
                        });

                client.Timeout = TimeSpan.FromSeconds(30);
            });

Practice Tasks

YARP as API Gateway with Health-Based Load Balancing

Set up YARP with:

  • Multiple routes for different services
  • Health-based load balancing with active health checks
  • Rate limiting per route

Request Aggregation for Reducing Client Round Trips

Implement a composition service that aggregates:

  • User profile from User Service
  • Recent orders from Order Service
  • Notifications from Notification Service

into a single API call.

BFF Layer for Mobile Client with Custom Payload Shaping

Create a BFF layer that:

  • Aggregates data from multiple backend services
  • Shapes responses specifically for mobile clients
  • Minimizes payload size by removing unnecessary fields
  • Implements pagination and field selection

References

  • [YARP Documentation](https://microsoft.github.io/reverse-proxy/)
  • [YARP GitHub Repository](https://github.com/dotnet/yarp)
  • [Backend for Frontend Pattern](https://docs.microsoft.com/architecture/patterns/backends-for-frontends/)
  • [API Composition Pattern](https://microservices.io/patterns/apigateway.html)

Практика


API Evolution и Versioning

# API Evolution and Versioning

Backward Compatibility Rules

Additive Changes Only

The golden rule of API evolution: only make additive changes to maintain backward compatibility.

Compatible Changes (Safe)

ChangeSafe?Reason
Adding a new field to a responseYesClients ignore unknown fields
Adding a new optional parameterYesExisting clients don't send it
Adding a new endpointYesNo impact on existing clients
Adding a new enum valueYesClients that don't know it can ignore
Adding a new optional headerYesExisting clients don't send it
Widening a field type (int -> long)YesSuperset of original values

Breaking Changes (Dangerous)

ChangeImpactMigration
Removing a fieldClients may fail parsingDeprecate first, then remove
Renaming a fieldClients break on accessUse alias during transition
Changing field typeParsing errorsAdd new field, deprecate old
Making optional field requiredValidation errorsMake optional first
Removing an endpointClients can't call itRedirect or provide alternative
Changing enum valuesLogic errorsAdd new values, deprecate old
Changing response structureParsing errorsProvide migration path

Example: Safe Evolution

// v1 response
        public class UserDtoV1
        {
            public int Id { get; set; }
            public string Name { get; set; } = string.Empty;
            public string Email { get; set; } = string.Empty;
        }

        // v2 response (backward compatible - additive only)
        public class UserDtoV2
        {
            public int Id { get; set; }
            public string Name { get; set; } = string.Empty;
            public string Email { get; set; } = string.Empty;
            public string? AvatarUrl { get; set; }      // NEW: optional field
            public DateTime? LastLoginAt { get; set; }   // NEW: optional field
            public AddressDto? Address { get; set; }     // NEW: optional nested object
        }

Deprecation Strategy

RFC 8594 Headers

public class DeprecationMiddleware
        {
            private readonly RequestDelegate _next;
            private readonly ILogger<DeprecationMiddleware> _logger;

            public DeprecationMiddleware(RequestDelegate next, ILogger<DeprecationMiddleware> logger)
            {
                _next = next;
                _logger = logger;
            }

            public async Task InvokeAsync(HttpContext context)
            {
                var apiVersion = context.GetApiVersion()?.ToString();

                // Check if the requested version is deprecated
                if (IsDeprecated(apiVersion))
                {
                    var sunsetDate = GetSunsetDate(apiVersion);

                    // RFC 8594: Deprecation header
                    context.Response.Headers.Deprecation = "true";

                    // RFC 8594: Sunset header
                    context.Response.Headers.Sunset = sunsetDate.ToString("R");

                    // Link header for successor version
                    context.Response.Headers.Link = $"<https://api.example.com/v2/>; rel=\"successor-version\"";
                    context.Response.Headers.Link += $", <https://docs.example.com/migration/v{apiVersion}-to-v2>; rel=\"deprecation\"";

                    // X-API-Deprecation header (custom, widely adopted)
                    context.Response.Headers["X-API-Deprecation"] = "true";

                    // X-API-Sunset-Date header
                    context.Response.Headers["X-API-Sunset-Date"] = sunsetDate.ToString("yyyy-MM-dd");

                    _logger.LogInformation(
                        "Deprecated API accessed: v{Version} at {Path}",
                        apiVersion, context.Request.Path);
                }

                await _next(context);
            }

            private bool IsDeprecated(string? version)
            {
                return version?.StartsWith("1.") == true;
            }

            private DateTime GetSunsetDate(string? version)
            {
                return new DateTime(2026, 12, 31);
            }
        }

Automatic Client Notification

public class DeprecationController : ControllerBase
        {
            /// <summary>
            /// Get deprecation status for all API versions
            /// </summary>
            [HttpGet("/api/deprecation")]
            [ApiExplorerSettings(IgnoreApi = false)]
            public IActionResult GetDeprecationStatus()
            {
                return Ok(new
                {
                    currentVersion = "2.0",
                    deprecatedVersions = new[]
                    {
                        new
                        {
                            version = "1.0",
                            status = "deprecated",
                            sunsetDate = new DateTime(2026, 12, 31).ToString("yyyy-MM-dd"),
                            migrationGuide = "https://docs.example.com/migration/v1-to-v2",
                            successorVersion = "2.0",
                            daysUntilSunset = (new DateTime(2026, 12, 31) - DateTime.UtcNow).Days
                        }
                    },
                    recommendedVersion = "2.0"
                });
            }
        }

Deprecation in OpenAPI

// Mark endpoints as deprecated in OpenAPI
        app.MapGet("/api/v1/posts", GetPosts)
            .WithOpenApi(op =>
            {
                op.Deprecated = true;
                op.Extensions.Add("x-sunset-date", new OpenApiString("2026-12-31"));
                op.Extensions.Add("x-migration-guide", new OpenApiString("https://docs.example.com/migration/v1-to-v2"));
                return op;
            });

Contract Testing with Pact

Consumer-Driven Contracts

Pact is a tool for testing consumer-driven contracts. It allows consumers to define expectations (contracts) and providers to verify they meet those expectations.

Setup

dotnet add package PactNet
        dotnet add package Pact.Verifier

Consumer Side — Defining Contracts

public class UserApiConsumerTests
        {
            private PactFlow _pact;

            [SetUp]
            public void SetUp()
            {
                _pact = new PactFlow(
                    consumerName: "my-app",
                    providerName: "user-service");
            }

            [Test]
            public void TestGetUserReturnsValidResponse()
            {
                _pact
                    .Given("User exists with id 123")
                    .UponReceiving("A request for user 123")
                    .WithRequest(new PactRequest
                    {
                        Method = "GET",
                        Path = "/api/users/123",
                        Headers = new Dictionary<string, string>
                        {
                            { "Accept", "application/json" }
                        }
                    })
                    .WillRespondWith(new PactResponse
                    {
                        Status = 200,
                        Headers = new Dictionary<string, string>
                        {
                            { "Content-Type", "application/json" }
                        },
                        Body = new
                        {
                            id = 123,
                            name = "John Doe",
                            email = "john@example.com"
                        }
                    })
                    .Verify();
            }

            [Test]
            public void TestGetUserReturns404WhenNotFound()
            {
                _pact
                    .Given("User does not exist with id 999")
                    .UponReceiving("A request for non-existent user 999")
                    .WithRequest(new PactRequest
                    {
                        Method = "GET",
                        Path = "/api/users/999"
                    })
                    .WillRespondWith(new PactResponse
                    {
                        Status = 404,
                        Body = new
                        {
                            error = "User not found",
                            code = "USER_NOT_FOUND"
                        }
                    })
                    .Verify();
            }
        }

Provider Side — Verifying Contracts

public class UserApiProviderTests
        {
            [Test]
            public async Task VerifyContracts()
            {
                var verifier = new PactVerifier(
                    "user-service",
                    opts => opts
                        .ServiceRoot("http://localhost:5000")
                        .InMemoryPact()
                        .PublishToPactFlow("https://pactflow.io/pacts")
                        .ConsumerVersionSelector(new ConsumerVersionSelector
                        {
                            Branch = "main",
                            Latest = true
                        }));

                // Verify against all consumer contracts
                var result = await verifier
                    .WithHost(new TestServerBuilder()
                        .ConfigureServices(services => services.AddMvc())
                        .Configure(app => app.MapUserEndpoints())
                        .Build());

                var verificationResult = await verifier.Verify();

                Assert.That(verificationResult.Success, Is.True);
            }
        }

Pact Broker Integration

// Publish pact to Pact Broker
        builder.Services.AddPact(options =>
        {
            options.BrokerUrl = "https://pact-broker.example.com";
            options.ConsumerVersion = "1.0.0";
            options.ProviderVersion = "2.0.0";
            options.ProviderBranch = "main";
        });

        // Verify against Pact Broker
        var pactVerifier = new PactBrokerVerifier(
            brokerUrl: "https://pact-broker.example.com",
            consumerVersionSelector: new ConsumerVersionSelector
            {
                Branch = "main",
                Latest = true
            });

        var result = await pactVerifier.Verify();

Semantic Versioning for APIs

API Version Semantics

VersionMeaningBreaking Changes
1.0.0Initial stable releaseN/A
1.1.0New features addedNo
1.1.1Bug fixesNo
2.0.0Breaking changesYes

API Version vs Product Version

API Version: 1.2.3
        ├── Major (1): Breaking changes introduced
        ├── Minor (2): New features, backward compatible
        └── Patch (3): Bug fixes, no new features

Version in Different Strategies

// URL Path Versioning
        app.MapGroup("/api/v1/users").MapUserEndpointsV1();
        app.MapGroup("/api/v2/users").MapUserEndpointsV2();

        // Header Versioning
        app.MapGet("/api/users", GetUsers)
            .WithMetadata(new ApiVersionMetadata { AcceptedApiVersions = ["1.0", "2.0"] });

        // Query String Versioning
        app.MapGet("/api/users", GetUsers)
            .Accepts<GetUsersRequest>("1.0")
            .Accepts<GetUsersRequestV2>("2.0");

Migration Patterns

Parallel Versions

// Run both versions simultaneously
        app.MapGroup("/api/v1/users").MapUserEndpointsV1();
        app.MapGroup("/api/v2/users").MapUserEndpointsV2();

        // Feature flag for gradual rollout
        app.MapGet("/api/users", async (HttpContext ctx, IUserService service) =>
        {
            var useV2 = ctx.Request.Headers["X-Use-V2"] == "true" ||
                        ctx.User.HasClaim("feature:v2-api", "enabled");

            if (useV2)
                return Results.Ok(await service.GetUsersV2Async());

            return Results.Ok(await service.GetUsersV1Async());
        });

Adapter Pattern for Migration

// v1 adapter that maps v2 data to v1 shape
        public class UserV1Adapter
        {
            public UserDtoV1 Adapt(UserDtoV2 v2)
            {
                return new UserDtoV1
                {
                    Id = v2.Id,
                    Name = v2.FullName,        // Mapped from renamed field
                    Email = v2.EmailAddress,   // Mapped from renamed field
                    // AvatarUrl and LastLoginAt intentionally omitted for v1
                };
            }
        }

        // Controller that uses adapter
        app.MapGet("/api/v1/users/{id}", async (
            int id,
            IUserService userService,
            UserV1Adapter adapter) =>
        {
            var userV2 = await userService.GetUserV2Async(id);
            var userV1 = adapter.Adapt(userV2);
            return Results.Ok(userV1);
        });

Migration Guide Generator

// Generate migration guide from OpenAPI diff
        public class MigrationGuideGenerator
        {
            public async Task<string> GenerateAsync(string oldSpecPath, string newSpecPath)
            {
                var oldSpec = await LoadOpenApiSpec(oldSpecPath);
                var newSpec = await LoadOpenApiSpec(newSpecPath);

                var changes = OpenApiDiff.Compare(oldSpec, newSpec);

                var guide = new StringBuilder();
                guide.AppendLine("# API Migration Guide\n");

                foreach (var change in changes.BreakingChanges)
                {
                    guide.AppendLine($"## Breaking: {change.Summary}\n");
                    guide.AppendLine($"**Location:** {change.Path}\n");
                    guide.AppendLine($"**Old:** {change.OldValue}\n");
                    guide.AppendLine($"**New:** {change.NewValue}\n");
                    guide.AppendLine($"**Migration:** {change.Suggestion}\n");
                }

                foreach (var change in changes.AdditiveChanges)
                {
                    guide.AppendLine($"## Added: {change.Summary}\n");
                    guide.AppendLine($"**Location:** {change.Path}\n");
                    guide.AppendLine($"**Details:** {change.Description}\n");
                }

                return guide.ToString();
            }
        }

Practice Tasks

Deprecation Workflow with Automatic Client Notification

Implement:

  • Deprecation middleware with RFC 8594 headers (Deprecation, Sunset, Link)
  • /api/deprecation endpoint showing all deprecated versions
  • OpenAPI annotations marking deprecated endpoints
  • X-API-Deprecation and X-API-Sunset-Date custom headers

Contract Test Suite for Multi-Version API Compatibility

Implement:

  • Pact consumer tests for v1 and v2 APIs
  • Pact provider verification tests
  • Pact Broker integration for CI/CD
  • Automated contract publishing

Migration Guide Generator from OpenAPI Diff

Implement:

  • OpenAPI spec comparison tool
  • Breaking change detection
  • Additive change documentation
  • Automated migration guide generation

References

  • [RFC 8594 - Deprecation and Sunset Headers](https://www.rfc-editor.org/rfc/rfc8594)
  • [RFC 9457 - Problem Details for HTTP APIs](https://www.rfc-editor.org/rfc/rfc9457)
  • [Pact Documentation](https://docs.pact.io/)
  • [API Versioning with ASP.NET Core](https://learn.microsoft.com/en-us/aspnet/core/web-api/api-versioning)
  • [Semantic Versioning 2.0.0](https://semver.org/)

Практика


Event-Driven APIs

# Event-Driven APIs

Webhooks

Overview

Webhooks allow external systems to receive real-time notifications when events occur. The server makes HTTP callbacks to registered URLs.

Webhook Registration

public class WebhookRegistration
        {
            public Guid Id { get; set; }
            public string Url { get; set; } = string.Empty;
            public string Secret { get; set; } = string.Empty;
            public string[] Events { get; set; } = Array.Empty<string>();
            public bool IsActive { get; set; } = true;
            public int RetryCount { get; set; }
            public int MaxRetries { get; set; } = 5;
            public DateTime? LastDeliveryAt { get; set; }
            public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
        }

        // Register webhook endpoint
        app.MapPost("/api/webhooks", async (
            RegisterWebhookRequest request,
            [FromServices] IWebhookService webhookService) =>
        {
            var webhook = await webhookService.RegisterAsync(
                request.Url,
                request.Events,
                request.Secret);

            return Results.Created($"/api/webhooks/{webhook.Id}", webhook);
        });

        public record RegisterWebhookRequest(
            [Required, Url] string Url,
            [Required] string[] Events,
            string? Secret = null);

Webhook Delivery with Retry

public class WebhookDeliveryService
        {
            private readonly HttpClient _httpClient;
            private readonly ILogger<WebhookDeliveryService> _logger;
            private readonly ApplicationDb _db;

            public WebhookDeliveryService(
                IHttpClientFactory factory,
                ILogger<WebhookDeliveryService> logger,
                ApplicationDb db)
            {
                _httpClient = factory.CreateClient("webhook");
                _logger = logger;
                _db = db;
            }

            public async Task DeliverAsync(WebhookRegistration webhook, WebhookEvent @event)
            {
                var delivery = new WebhookDelivery
                {
                    WebhookId = webhook.Id,
                    Event = @event.Type,
                    Payload = JsonSerializer.Serialize(@event.Data),
                    Timestamp = DateTime.UtcNow,
                    Status = DeliveryStatus.Pending,
                    AttemptCount = 0
                };

                _db.Deliveries.Add(delivery);
                await _db.SaveChangesAsync();

                await RetryDeliverAsync(webhook, @event, delivery, 0);
            }

            private async Task RetryDeliverAsync(
                WebhookRegistration webhook,
                WebhookEvent @event,
                WebhookDelivery delivery,
                int attempt)
            {
                try
                {
                    var signature = GenerateSignature(webhook.Secret, delivery.Id.ToString(), @event);

                    var content = new StringContent(delivery.Payload, Encoding.UTF8, "application/json");
                    var response = await _httpClient.PostAsync(webhook.Url, content);

                    if (response.IsSuccessStatusCode)
                    {
                        delivery.Status = DeliveryStatus.Success;
                        delivery.ResponseStatusCode = (int)response.StatusCode;
                        delivery.DeliveredAt = DateTime.UtcNow;
                        delivery.LastError = null;
                    }
                    else
                    {
                        throw new HttpRequestException(
                            $"Webhook delivery failed with status {(int)response.StatusCode}");
                    }
                }
                catch (Exception ex)
                {
                    delivery.AttemptCount = attempt + 1;
                    delivery.LastError = ex.Message;
                    delivery.Status = attempt >= webhook.MaxRetries
                        ? DeliveryStatus.Failed
                        : DeliveryStatus.Retrying;

                    if (attempt < webhook.MaxRetries)
                    {
                        // Exponential backoff: 1s, 2s, 4s, 8s, 16s
                        var delay = TimeSpan.FromSeconds(Math.Pow(2, attempt));
                        await Task.Delay(delay);
                        await RetryDeliverAsync(webhook, @event, delivery, attempt + 1);
                    }
                }
                finally
                {
                    _db.Deliveries.Update(delivery);
                    await _db.SaveChangesAsync();
                }
            }

            private string GenerateSignature(string secret, string deliveryId, WebhookEvent @event)
            {
                var payload = $"{deliveryId}.{@event.Timestamp:O}.{JsonSerializer.Serialize(@event.Data)}";
                using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
                var hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(payload));
                return Convert.ToBase64String(hash);
            }
        }

        public class WebhookEvent
        {
            public string Type { get; set; } = string.Empty;
            public string Id { get; set; } = Guid.NewGuid().ToString();
            public DateTime Timestamp { get; set; } = DateTime.UtcNow;
            public object Data { get; set; } = new();
        }

        public enum DeliveryStatus
        {
            Pending,
            Retrying,
            Success,
            Failed
        }

Signature Verification on Receiver Side

public class WebhookVerificationMiddleware
        {
            private readonly RequestDelegate _next;
            private readonly ILogger<WebhookVerificationMiddleware> _logger;

            public WebhookVerificationMiddleware(RequestDelegate next, ILogger<WebhookVerificationMiddleware> logger)
            {
                _next = next;
                _logger = logger;
            }

            public async Task InvokeAsync(HttpContext context)
            {
                if (context.Request.Path == "/webhooks/receiver" &&
                    context.Request.Method == "POST")
                {
                    var signature = context.Request.Headers["X-Webhook-Signature"].ToString();
                    var timestamp = context.Request.Headers["X-Webhook-Timestamp"].ToString();

                    if (string.IsNullOrEmpty(signature) || string.IsNullOrEmpty(timestamp))
                    {
                        context.Response.StatusCode = 401;
                        await context.Response.WriteAsJsonAsync(new ProblemDetails
                        {
                            Status = 401,
                            Title = "Missing authentication headers",
                            Detail = "X-Webhook-Signature and X-Webhook-Timestamp headers are required"
                        });
                        return;
                    }

                    // Verify timestamp is not too old (tolerance: 5 minutes)
                    if (!VerifyTimestamp(timestamp))
                    {
                        context.Response.StatusCode = 401;
                        await context.Response.WriteAsJsonAsync(new ProblemDetails
                        {
                            Status = 401,
                            Title = "Timestamp expired",
                            Detail = "Webhook timestamp is older than 5 minutes"
                        });
                        return;
                    }

                    // Verify signature
                    var body = await new StreamReader(context.Request.Body).ReadToEndAsync();
                    context.Request.Body.Position = 0;

                    var expectedSignature = ComputeSignature(body, timestamp, _webhookSecret);
                    if (!CompareHashes(signature, expectedSignature))
                    {
                        _logger.LogWarning("Invalid webhook signature received");
                        context.Response.StatusCode = 401;
                        await context.Response.WriteAsJsonAsync(new ProblemDetails
                        {
                            Status = 401,
                            Title = "Invalid signature",
                            Detail = "Webhook signature verification failed"
                        });
                        return;
                    }

                    await _next(context);
                }
                else
                {
                    await _next(context);
                }
            }

            private bool VerifyTimestamp(string timestamp)
            {
                if (!DateTime.TryParse(timestamp, out var ts)) return false;
                return (DateTime.UtcNow - ts).TotalMinutes <= 5;
            }

            private string ComputeSignature(string body, string timestamp, string secret)
            {
                var payload = $"{timestamp}.{body}";
                using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
                var hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(payload));
                return Convert.ToBase64String(hash);
            }

            private bool CompareHashes(string a, string b)
            {
                return CryptographicOperations.FixedTimeEqual(
                    Encoding.UTF8.GetBytes(a),
                    Encoding.UTF8.GetBytes(b));
            }
        }

Server-Sent Events (SSE)

Overview

SSE provides a simple way to push real-time text data from server to client over HTTP. It is simpler than WebSocket and only supports server-to-client communication.

SSE Endpoint

app.MapGet("/api/events/stream", async (HttpContext context, CancellationToken ct) =>
        {
            context.Response.Headers.ContentType = "text/event-stream";
            context.Response.Headers.CacheControl = "no-cache";
            context.Response.Headers.Connection = "Keep-Alive";

            var eventSource = context.RequestServices.GetRequiredService<ISseEventSource>();

            await foreach (var eventData in eventSource.SubscribeAsync(ct))
            {
                if (context.RequestAborted.IsCancellationRequested) break;

                await context.Response.WriteAsync(
                    $"id: {eventData.Id}\n", ct);
                await context.Response.WriteAsync(
                    $"event: {eventData.Type}\n", ct);
                await context.Response.WriteAsync(
                    $"data: {JsonSerializer.Serialize(eventData.Payload)}\n\n", ct);
            }
        });

        public interface ISseEventSource
        {
            IAsyncEnumerable<SseEventData> SubscribeAsync(CancellationToken ct);
        }

        public class SseEventSource : ISseEventSource
        {
            private readonly Channel<SseEventData> _channel;
            private readonly ILogger<SseEventSource> _logger;

            public SseEventSource(ILogger<SseEventSource> logger)
            {
                _channel = Channel.CreateBounded<SseEventData>(new BoundedChannelOptions(1000)
                {
                    FullMode = BoundedChannelFullMode.Wait
                });
                _logger = logger;
            }

            public async IAsyncEnumerable<SseEventData> SubscribeAsync([EnumeratorCancellation] CancellationToken ct)
            {
                await foreach (var eventItem in _channel.Reader.ReadAllAsync(ct))
                {
                    yield return eventItem;
                }
            }

            public Task PublishAsync(SseEventData eventData)
            {
                _logger.LogInformation("Publishing SSE event: {Type}", eventData.Type);
                return _channel.Writer.WriteAsync(eventData, ct: CancellationToken.None);
            }
        }

        public record SseEventData(
            string Id,
            string Type,
            object Payload,
            DateTime Timestamp = default);

Heartbeat for SSE

app.MapGet("/api/events/stream-with-heartbeat", async (HttpContext context, CancellationToken ct) =>
        {
            context.Response.Headers.ContentType = "text/event-stream";
            context.Response.Headers.CacheControl = "no-cache";

            var eventSource = context.RequestServices.GetRequiredService<ISseEventSource>();
            var heartbeatTimer = new PeriodicTimer(TimeSpan.FromSeconds(15));

            try
            {
                while (!ct.IsCancellationRequested)
                {
                    var hasEvent = await Task.WhenAny(
                        ConsumeEventsAsync(eventSource, context, ct),
                        WaitHeartbeatAsync(heartbeatTimer, context)
                    ).ConfigureAwait(false);

                    if (!hasEvent) break;
                }
            }
            catch (TaskCanceledException) { /* Expected on disconnect */ }
        });

        private static async Task<bool> WaitHeartbeatAsync(
            PeriodicTimer timer, HttpContext context)
        {
            var completed = await timer.WaitForNextTickAsync(context.RequestAborted);
            if (!completed) return false;

            await context.Response.WriteAsync(": heartbeat\n\n", CancellationToken.None);
            return true;
        }

        private static async Task<bool> ConsumeEventsAsync(
            ISseEventSource eventSource, HttpContext context, CancellationToken ct)
        {
            await foreach (var eventData in eventSource.SubscribeAsync(ct))
            {
                if (context.RequestAborted.IsCancellationRequested) return false;

                await context.Response.WriteAsync(
                    $"id: {eventData.Id}\nevent: {eventData.Type}\ndata: {JsonSerializer.Serialize(eventData.Payload)}\n\n",
                    ct);
                await context.Response.Body.FlushAsync(ct);
            }
            return false;
        }

SSE Client (JavaScript)

// Connect to SSE stream
        const eventSource = new EventSource('/api/events/stream');

        eventSource.addEventListener('post.created', (event) => {
            const post = JSON.parse(event.data);
            console.log('New post:', post.title);
            addPostToUI(post);
        });

        eventSource.addEventListener('order.updated', (event) => {
            const order = JSON.parse(event.data);
            console.log('Order updated:', order.status);
            updateOrderUI(order);
        });

        eventSource.onerror = (error) => {
            console.error('SSE connection error:', error);
            eventSource.close();
            // Reconnect after delay
            setTimeout(() => connectSSE(), 3000);
        };

WebSocket API

WebSocket Handler

public class ChatWebSocketHandler : WebSocketHandler
        {
            private static readonly ConcurrentDictionary<string, WebSocket> _clients = new();
            private static readonly ConcurrentDictionary<string, List<string>> _rooms = new();

            private readonly ILogger<ChatWebSocketHandler> _logger;
            private readonly IAuthTokenService _authService;

            public ChatWebSocketHandler(ILogger<ChatWebSocketHandler> logger, IAuthTokenService authService)
            {
                _logger = logger;
                _authService = authService;
            }

            public override async Task OnConnectedAsync(WebSocket webSocket)
            {
                var userId = ExtractUserId(webSocket);
                _clients[userId] = webSocket;

                _logger.LogInformation("User {UserId} connected. Total clients: {Count}",
                    userId, _clients.Count);

                await SendToClient(userId, new WebSocketMessage
                {
                    Type = MessageType.Connected,
                    Payload = new { userId, timestamp = DateTime.UtcNow }
                });

                await HandleMessages(webSocket, userId);
            }

            public override async Task OnDisconnectedAsync(WebSocket webSocket)
            {
                var userId = _clients.FirstOrDefault(c => c.Value == webSocket).Key;
                if (userId != null)
                {
                    _clients.TryRemove(userId, out _);

                    // Notify room members
                    foreach (var roomId in _rooms.Keys.Where(r => _rooms[r].Contains(userId)))
                    {
                        await BroadcastToRoom(roomId, new WebSocketMessage
                        {
                            Type = MessageType.UserLeft,
                            Payload = new { userId }
                        }, excludeUserId: userId);
                    }

                    _logger.LogInformation("User {UserId} disconnected. Total clients: {Count}",
                        userId, _clients.Count);
                }
            }

            private async Task HandleMessages(WebSocket webSocket, string userId)
            {
                var buffer = new byte[1024 * 4];

                try
                {
                    while (webSocket.State == WebSocketState.Open)
                    {
                        var result = await webSocket.ReceiveAsync(
                            new ArraySegment<byte>(buffer), CancellationToken.None);

                        if (result.MessageType == WebSocketMessageType.Close)
                        {
                            await webSocket.CloseAsync(
                                WebSocketCloseStatus.NormalClosure, "Closing", CancellationToken.None);
                            break;
                        }

                        var message = Encoding.UTF8.GetString(buffer, 0, result.Count);
                        await ProcessMessage(userId, message);
                    }
                }
                catch (Exception ex)
                {
                    _logger.LogError(ex, "Error handling messages for user {UserId}", userId);
                }
            }

            private async Task ProcessMessage(string userId, string messageJson)
            {
                var message = JsonSerializer.Deserialize<WebSocketMessage>(messageJson);
                if (message == null) return;

                switch (message.Type)
                {
                    case MessageType.JoinRoom:
                        await JoinRoom(userId, message.Payload);
                        break;
                    case MessageType.SendMessage:
                        await SendMessage(userId, message.Payload);
                        break;
                    case MessageType.Heartbeat:
                        await SendToClient(userId, new WebSocketMessage
                        {
                            Type = MessageType.Pong,
                            Payload = new { timestamp = DateTime.UtcNow }
                        });
                        break;
                }
            }

            private async Task SendMessage(string fromUserId, object payload)
            {
                var messageData = (dynamic)payload;
                var roomId = messageData.roomId;
                var content = messageData.content;

                var chatMessage = new WebSocketMessage
                {
                    Type = MessageType.ChatMessage,
                    Payload = new
                    {
                        fromUserId,
                        roomId,
                        content,
                        timestamp = DateTime.UtcNow
                    }
                };

                await BroadcastToRoom(roomId, chatMessage, excludeUserId: fromUserId);
            }

            private async Task BroadcastToRoom(string roomId, WebSocketMessage message, string? excludeUserId = null)
            {
                if (!_rooms.TryGetValue(roomId, out var users)) return;

                foreach (var userId in users.Where(u => u != excludeUserId))
                {
                    if (_clients.TryGetValue(userId, out var socket) &&
                        socket.State == WebSocketState.Open)
                    {
                        var json = JsonSerializer.Serialize(message);
                        await socket.SendAsync(
                            new ArraySegment<byte>(Encoding.UTF8.GetBytes(json)),
                            WebSocketMessageType.Text,
                            true,
                            CancellationToken.None);
                    }
                }
            }

            // Heartbeat and reconnection
            private async Task SendHeartbeatAsync(string userId)
            {
                if (_clients.TryGetValue(userId, out var socket) &&
                    socket.State == WebSocketState.Open)
                {
                    var message = new WebSocketMessage
                    {
                        Type = MessageType.Heartbeat,
                        Payload = new { timestamp = DateTime.UtcNow }
                    };
                    var json = JsonSerializer.Serialize(message);
                    await socket.SendAsync(
                        new ArraySegment<byte>(Encoding.UTF8.GetBytes(json)),
                        WebSocketMessageType.Text,
                        true,
                        CancellationToken.None);
                }
            }
        }

        public enum MessageType
        {
            Connected,
            UserJoined,
            UserLeft,
            ChatMessage,
            Heartbeat,
            Pong,
            JoinRoom,
            SendMessage
        }

        public record WebSocketMessage
        {
            public MessageType Type { get; set; }
            public object? Payload { get; set; }
            public string? Id { get; set; } = Guid.NewGuid().ToString();
            public DateTime Timestamp { get; set; } = DateTime.UtcNow;
        }

WebSocket with Reconnection Logic

// Server-side heartbeat
        app.UseWebSockets(new WebSocketOptions
        {
            KeepAliveInterval = TimeSpan.FromSeconds(30),
            ReceiveBufferSize = 4 * 1024
        });

        app.MapWebSocketManager("/ws/chat", new WebSocketOptions
        {
            KeepAliveInterval = TimeSpan.FromSeconds(30)
        }, manager => new ChatWebSocketHandler(logger, authService));

        // Client-side reconnection (JavaScript)
        function connectWebSocket() {
            const ws = new WebSocket('wss://api.example.com/ws/chat');

            ws.onopen = () => {
                console.log('Connected');
                sendHeartbeat(ws);
            };

            ws.onmessage = (event) => {
                const message = JSON.parse(event.data);
                handleMessage(message);

                if (message.type === 'heartbeat') {
                    // Send pong
                    ws.send(JSON.stringify({ type: 'pong' }));
                }
            };

            ws.onclose = (event) => {
                console.log(`Disconnected: code=${event.code}, reason=${event.reason}`);
                // Exponential backoff reconnection
                const delay = Math.min(1000 * Math.pow(2, reconnectAttempts), 30000);
                reconnectAttempts++;
                setTimeout(() => connectWebSocket(), delay);
            };

            ws.onerror = (error) => {
                console.error('WebSocket error:', error);
            };

            return ws;
        }

        function sendHeartbeat(ws) {
            setInterval(() => {
                if (ws.readyState === WebSocket.OPEN) {
                    ws.send(JSON.stringify({ type: 'heartbeat' }));
                }
            }, 25000); // Send every 25s (server expects every 30s)
        }

Async API Specification

Async API Overview

AsyncAPI is to event-driven APIs what OpenAPI is to REST APIs. It provides a machine-readable format for describing event-driven interfaces.

AsyncAPI Specification Example

asyncapi: 3.0.0
        info:
          title: Order Service Events
          version: 1.0.0
          description: Event-driven API for order management

        servers:
          production:
            url: mqtt://events.example.com
            protocol: mqtt

        channels:
          order.created:
            address: orders/events/created
            messages:
              OrderCreated:
                $ref: '#/components/messages/OrderCreated'

          order.updated:
            address: orders/events/updated
            messages:
              OrderUpdated:
                $ref: '#/components/messages/OrderUpdated'

          order.cancelled:
            address: orders/events/cancelled
            messages:
              OrderCancelled:
                $ref: '#/components/messages/OrderCancelled'

        components:
          messages:
            OrderCreated:
              name: OrderCreated
              contentType: application/json
              payload:
                type: object
                properties:
                  orderId:
                    type: string
                    format: uuid
                  customerId:
                    type: string
                    format: uuid
                  total:
                    type: number
                  items:
                    type: array
                    items:
                      type: object
              examples:
                - name: SampleOrder
                  payload:
                    orderId: "550e8400-e29b-41d4-a716-446655440000"
                    customerId: "650e8400-e29b-41d4-a716-446655440001"
                    total: 150.00
                    items:
                      - productId: "prod-123"
                        quantity: 2
                        price: 75.00

            OrderUpdated:
              name: OrderUpdated
              contentType: application/json
              payload:
                type: object
                properties:
                  orderId:
                    type: string
                    format: uuid
                  status:
                    type: string
                    enum: [confirmed, shipped, delivered, cancelled]
                  updatedAt:
                    type: string
                    format: date-time

            OrderCancelled:
              name: OrderCancelled
              contentType: application/json
              payload:
                type: object
                properties:
                  orderId:
                    type: string
                    format: uuid
                  reason:
                    type: string
                  cancelledAt:
                    type: string
                    format: date-time

Practice Tasks

Webhook System with Reliable Delivery and Signature Verification

Implement:

  • Webhook registration endpoint
  • Delivery with exponential backoff retry (up to 5 attempts)
  • HMAC-SHA256 signature generation and verification
  • X-Webhook-Signature and X-Webhook-Timestamp headers
  • Delivery status tracking

SSE Endpoint for Real-Time Notification Stream

Implement:

  • SSE endpoint with proper headers (text/event-stream)
  • Heartbeat mechanism (every 15 seconds)
  • Event typing (post.created, order.updated, etc.)
  • Client reconnection handling

WebSocket API with Custom Message Protocol and Reconnection Logic

Implement:

  • WebSocket handler with authentication
  • Custom message protocol (type, payload, id, timestamp)
  • Heartbeat/pong mechanism
  • Room-based messaging
  • Exponential backoff reconnection on client side

References

  • [RFC 8594 - Deprecation and Sunset Headers](https://www.rfc-editor.org/rfc/rfc8594)
  • [Server-Sent Events Specification](https://html.spec.whatwg.org/multipage/server-sent-events.html)
  • [WebSocket API](https://developer.mozilla.org/en-US/docs/Web/API/WebSocket)
  • [AsyncAPI Specification](https://www.asyncapi.com/docs/specifications/v3.0.0)
  • [Pact Documentation](https://docs.pact.io/)

Практика


API Performance Optimization

# API Performance Optimization

Response Compression

Configuration

// Program.cs
        builder.Services.AddResponseCompression(options =>
        {
            options.EnableForHttps = true;
            options.MimeTypes = ResponseCompressionDefaults.MimeTypes.Concat(new[]
            {
                "application/json",
                "application/xml",
                "text/plain",
                "application/javascript",
                "text/css"
            });
            options.Level = CompressionLevel.Optimal; // NoCompression, Fastest, Optimal
        });

        var app = builder.Build();
        app.UseResponseCompression();

appsettings.json Configuration

{
          "ResponseCompression": {
            "EnableForHttps": true,
            "Level": "Optimal",
            "MimeTypes": [
              "application/json",
              "application/xml",
              "text/plain",
              "application/javascript",
              "text/css"
            ]
          }
        }

Content Negotiation

// Client requests specific encoding
        GET /api/posts HTTP/1.1
        Accept-Encoding: gzip, br, deflate

        // Server responds with best available encoding
        HTTP/1.1 200 OK
        Content-Encoding: gzip
        Vary: Accept-Encoding
        Content-Type: application/json
        Content-Length: 1234

Brotli vs Gzip Comparison

AlgorithmCompression RatioCompression SpeedDecompression Speed
Brotli~20% better than gzipSlowerSimilar
GzipBaselineFasterFaster
Deflate~10% worse than gzipFastestFastest

Custom Compression Middleware

public class CompressionMiddleware
        {
            private readonly RequestDelegate _next;
            private readonly ResponseCompressionService _compression;

            public CompressionMiddleware(RequestDelegate next, IResponseCompressionService compression)
            {
                _next = next;
                _compression = compression;
            }

            public async Task InvokeAsync(HttpContext context)
            {
                var acceptEncoding = context.Request.Headers["Accept-Encoding"].ToString();

                if (acceptEncoding.Contains("br"))
                {
                    context.Response.Headers.Vary = "Accept-Encoding";
                    var compressed = await _compression.CompressAsync(context.Response.Body, CompressionFormat.Brotli);
                    context.Response.Headers.ContentEncoding = "br";
                    await _next(context);
                    return;
                }

                if (acceptEncoding.Contains("gzip"))
                {
                    context.Response.Headers.Vary = "Accept-Encoding";
                    var compressed = await _compression.CompressAsync(context.Response.Body, CompressionFormat.Gzip);
                    context.Response.Headers.ContentEncoding = "gzip";
                    await _next(context);
                    return;
                }

                await _next(context);
            }
        }

Caching Headers

Cache-Control

// Cacheable response
        app.MapGet("/api/products/{id}", async (int id, ApplicationDb db) =>
        {
            var product = await db.Products.FindAsync(id);
            if (product == null) return Results.NotFound();

            return Results.Ok(product)
                .WithHeaders(
                    ResponseHeadersExtensions.SetCacheControl("public, max-age=3600, stale-while-revalidate=86400"),
                    ResponseHeadersExtensions.SetETag(product.ETag),
                    ResponseHeadersExtensions.SetLastModified(product.UpdatedAt));
        });

        // Non-cacheable response
        app.MapPost("/api/orders", async (CreateOrderRequest request, ApplicationDb db) =>
        {
            // ... create order
            return Results.Created($"/api/orders/{order.Id}", order)
                .WithHeaders(
                    ResponseHeadersExtensions.SetCacheControl("no-store, no-cache, must-revalidate"),
                    ResponseHeadersExtensions.SetPragma("no-cache"));
        });

ETag Implementation

public class ETagMiddleware
        {
            private readonly RequestDelegate _next;
            private readonly ILogger<ETagMiddleware> _logger;

            public ETagMiddleware(RequestDelegate next, ILogger<ETagMiddleware> logger)
            {
                _next = next;
                _logger = logger;
            }

            public async Task InvokeAsync(HttpContext context)
            {
                if (context.Request.Method == "GET")
                {
                    var cacheKey = GenerateCacheKey(context.Request);
                    var etag = await GetEtagAsync(cacheKey);

                    // Check If-None-Match header
                    var ifNoneMatch = context.Request.Headers["If-None-Match"].ToString();
                    if (!string.IsNullOrEmpty(ifNoneMatch) && etag != null)
                    {
                        if (ifNoneMatch == $"\"{etag}\"" || ifNoneMatch == "*")
                        {
                            context.Response.StatusCode = 304; // Not Modified
                            context.Response.Headers.ETag = $"\"{etag}\"";
                            return;
                        }
                    }

                    // Capture response body for ETag generation
                    var originalBody = context.Response.Body;
                    using var responseBody = new MemoryStream();
                    context.Response.Body = responseBody;

                    await _next(context);

                    if (context.Response.StatusCode == 200)
                    {
                        responseBody.Seek(0, SeekOrigin.Begin);
                        var bodyBytes = await ReadAllBytesAsync(responseBody);
                        responseBody.Seek(0, SeekOrigin.Begin);

                        var newEtag = ComputeEtag(bodyBytes);
                        context.Response.Headers.ETag = $"\"{newEtag}\"";

                        await responseBody.CopyToAsync(originalBody);
                    }
                    else
                    {
                        await responseBody.CopyToAsync(originalBody);
                    }
                }
                else
                {
                    await _next(context);
                }
            }

            private string GenerateCacheKey(HttpRequest request)
            {
                var builder = new StringBuilder();
                builder.Append(request.Method);
                builder.Append(request.Path);
                foreach (var (key, value) in request.Query.OrderBy(k => k.Key))
                {
                    builder.Append(key);
                    builder.Append(value);
                }
                return builder.ToString();
            }

            private async Task<string?> GetEtagAsync(string cacheKey)
            {
                // Check distributed cache
                var cachedEtag = await _distributedCache.GetStringAsync($"etag:{cacheKey}");
                return cachedEtag;
            }

            private string ComputeEtag(byte[] body)
            {
                using var sha256 = SHA256.Create();
                var hash = sha256.ComputeHash(body);
                return Convert.ToBase64String(hash);
            }

            private async Task<byte[]> ReadAllBytesAsync(MemoryStream stream)
            {
                var bytes = new byte[stream.Length];
                await stream.ReadAsync(bytes, 0, bytes.Length);
                return bytes;
            }
        }

Last-Modified / If-Modified-Since

app.MapGet("/api/posts", async (int limit, string? cursor, ApplicationDb db) =>
        {
            var ifModifiedSince = context.Request.Headers["If-Modified-Since"].ToString();
            if (!string.IsNullOrEmpty(ifModifiedSince) && DateTime.TryParse(ifModifiedSince, out var modifiedSince))
            {
                var latestPost = await db.Posts.OrderByDescending(p => p.UpdatedAt).FirstOrDefaultAsync();
                if (latestPost != null && latestPost.UpdatedAt <= modifiedSince)
                {
                    return Results.StatusCode(304); // Not Modified
                }
            }

            // ... fetch posts
            return results.WithLastModified(latestUpdatedAt);
        });

Distributed Caching

// Register distributed cache (Redis)
        builder.Services.AddStackExchangeRedisCache(options =>
        {
            options.Configuration = builder.Configuration.GetConnectionString("Redis");
            options.InstanceName = "ApiCache:";
        });

        // Cache middleware
        public class CacheMiddleware
        {
            private readonly RequestDelegate _next;
            private readonly IDistributedCache _cache;
            private readonly ILogger<CacheMiddleware> _logger;

            public CacheMiddleware(RequestDelegate next, IDistributedCache cache, ILogger<CacheMiddleware> logger)
            {
                _next = next;
                _cache = cache;
                _logger = logger;
            }

            public async Task InvokeAsync(HttpContext context)
            {
                if (context.Request.Method == "GET" && ShouldCache(context.Request))
                {
                    var cacheKey = GenerateCacheKey(context.Request);
                    var cached = await _cache.GetStringAsync(cacheKey);

                    if (cached != null)
                    {
                        _logger.LogDebug("Cache hit: {Key}", cacheKey);
                        await context.Response.WriteAsync(cached);
                        return;
                    }

                    // Cache miss - capture response
                    var originalBody = context.Response.Body;
                    using var responseBody = new MemoryStream();
                    context.Response.Body = responseBody;

                    await _next(context);

                    if (context.Response.StatusCode == 200)
                    {
                        responseBody.Seek(0, SeekOrigin.Begin);
                        var response = await new StreamReader(responseBody).ReadToEndAsync();

                        await _cache.SetStringAsync(cacheKey, response, new DistributedCacheEntryOptions
                        {
                            AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5),
                            SlidingExpiration = TimeSpan.FromMinutes(2),
                            Priority = CacheItemPriority.Default
                        });

                        _logger.LogDebug("Cached: {Key}", cacheKey);
                        await responseBody.CopyToAsync(originalBody);
                    }
                    else
                    {
                        await responseBody.CopyToAsync(originalBody);
                    }
                }
                else
                {
                    await _next(context);
                }
            }

            private bool ShouldCache(HttpRequest request)
            {
                // Don't cache if authenticated or has specific headers
                return !request.Headers.ContainsKey("Authorization") &&
                       !request.Headers.ContainsKey("Cookie");
            }
        }

Partial Responses

HTTP Range Requests

app.MapGet("/api/files/{id}", async (int id, ApplicationDb db, HttpContext context) =>
        {
            var file = await db.Files.FindAsync(id);
            if (file == null) return Results.NotFound();

            var fileSize = file.Size;
            var rangeHeader = context.Request.Headers["Range"].ToString();

            if (!string.IsNullOrEmpty(rangeHeader))
            {
                // Parse range header: bytes=0-499, bytes=500-999, bytes=-500
                var range = ParseRange(rangeHeader, fileSize);

                context.Response.StatusCode = 206; // Partial Content
                context.Response.Headers.AcceptRanges = "bytes";
                context.Response.Headers.ContentRange = $"bytes {range.Start}-{range.End}/{fileSize}";
                context.Response.Headers.ContentLength = range.End - range.Start + 1;

                await SendPartialContent(file, range.Start, range.End);
            }
            else
            {
                context.Response.Headers.AcceptRanges = "bytes";
                context.Response.Headers.ContentLength = fileSize;
                await SendFullContent(file);
            }
        });

        private (long Start, long End) ParseRange(string rangeHeader, long fileSize)
        {
            var range = rangeHeader.Substring("bytes=".Length).Split('-');
            var start = range.Length > 0 && !string.IsNullOrEmpty(range[0])
                ? long.Parse(range[0]) : 0;
            var end = range.Length > 1 && !string.IsNullOrEmpty(range[1])
                ? long.Parse(range[1]) : fileSize - 1;

            return (Math.Max(0, start), Math.Min(fileSize - 1, end));
        }

Field-Level Filtering

// GraphQL-style field filtering for REST
        app.MapGet("/api/users", async (HttpContext context, ApplicationDb db) =>
        {
            var fields = context.Request.Query["fields"].ToString();
            var userId = context.GetUserId();

            var user = await db.Users.FindAsync(userId);
            if (user == null) return Results.NotFound();

            if (string.IsNullOrEmpty(fields))
            {
                // Return full user
                return Results.Ok(user);
            }

            // Parse field selection (supports nested: id,name,email,profile(bio,avatar))
            var selectedFields = ParseFieldSelection(fields);
            var filtered = FilterUserFields(user, selectedFields);

            return Results.Ok(filtered);
        });

        private Dictionary<string, object> FilterUserFields(User user, HashSet<string> fields)
        {
            var result = new Dictionary<string, object>();

            foreach (var field in fields)
            {
                if (field == "id") result["id"] = user.Id;
                else if (field == "name") result["name"] = user.Name;
                else if (field == "email") result["email"] = user.Email;
                else if (field == "profile")
                {
                    result["profile"] = new Dictionary<string, object>
                    {
                        ["bio"] = user.Profile?.Bio,
                        ["avatar"] = user.Profile?.AvatarUrl
                    };
                }
            }

            return result;
        }

Connection Optimization

Keep-Alive Configuration

// Configure Kestrel for optimal connections
        builder.WebHost.ConfigureKestrel(options =>
        {
            options.Limits.KeepAliveTimeout = TimeSpan.FromMinutes(2);
            options.Limits.RequestHeadersTimeout = TimeSpan.FromSeconds(30);
            options.Limits.MaxConcurrentConnections = 100;
            options.Limits.MaxConcurrentUpgradedConnections = 100;
            options.Limits.MaxRequestBodySize = 10 * 1024 * 1024; // 10MB
        });

HTTP/2 Configuration

builder.WebHost.ConfigureKestrel(options =>
        {
            options.ListenAnyIP(5001, listenOptions =>
            {
                listenOptions.Protocols = HttpProtocols.Http2;
                listenOptions.UseHttps();
            });

            // HTTP/2 settings
            options.Protocols = HttpProtocols.Http2;
            options.Http2.MaxStreamsPerConnection = 100;
            options.Http2.KeepAliveTimeout = TimeSpan.FromMinutes(2);
        });

HTTP/3 Configuration

builder.WebHost.ConfigureKestrel(options =>
        {
            options.ListenAnyIP(5002, listenOptions =>
            {
                listenOptions.Protocols = HttpProtocols.Http3;
                listenOptions.UseHttps();
            });
        });

GraphQL Query Cost Analysis & Rate Limiting

Query Cost Calculator

public class QueryCostCalculator
        {
            private readonly Dictionary<string, int> _fieldCosts;
            private readonly int _defaultFieldCost;

            public QueryCostCalculator()
            {
                _defaultFieldCost = 1;
                _fieldCosts = new Dictionary<string, int>
                {
                    ["Query.posts"] = 5,
                    ["Query.searchProducts"] = 10,
                    ["Post.comments"] = 3,
                    ["Comment.author"] = 2,
                    ["User.posts"] = 5
                };
            }

            public QueryCostResult Calculate(string query, Dictionary<string, object>? variables = null)
            {
                var document = Parser.Parse(query);
                var cost = CalculateCost(document.Operations.First(), variables);

                return new QueryCostResult
                {
                    TotalCost = cost,
                    Depth = GetDepth(document),
                    FieldCount = GetFieldCount(document)
                };
            }

            private int CalculateCost(SelectionSet selectionSet, Dictionary<string, object>? variables)
            {
                var totalCost = 0;

                foreach (var selection in selectionSet.Selections)
                {
                    if (selection is Field field)
                    {
                        var fieldCost = _fieldCosts.GetValueOrDefault(
                            $"{field.Name}", _defaultFieldCost);

                        // Multiply by list multiplier if applicable
                        var listMultiplier = GetListMultiplier(field);
                        fieldCost *= listMultiplier;

                        // Recursively calculate nested fields
                        if (field.SelectionSet != null)
                        {
                            fieldCost += CalculateCost(field.SelectionSet, variables);
                        }

                        totalCost += fieldCost;
                    }
                }

                return totalCost;
            }

            private int GetListMultiplier(Field field)
            {
                // Check if field returns a list type
                return 1; // Simplified
            }

            private int GetDepth( Document document)
            {
                var maxDepth = 0;
                foreach (var op in document.Operations)
                {
                    var depth = CalculateDepth(op.SelectionSet);
                    maxDepth = Math.Max(maxDepth, depth);
                }
                return maxDepth;
            }

            private int CalculateDepth(SelectionSet selectionSet)
            {
                var maxDepth = 1;
                foreach (var selection in selectionSet.Selections)
                {
                    if (selection is Field field && field.SelectionSet != null)
                    {
                        maxDepth = Math.Max(maxDepth, 1 + CalculateDepth(field.SelectionSet));
                    }
                }
                return maxDepth;
            }

            private int GetFieldCount(Document document)
            {
                var count = 0;
                foreach (var op in document.Operations)
                {
                    count += CountFields(op.SelectionSet);
                }
                return count;
            }

            private int CountFields(SelectionSet selectionSet)
            {
                var count = 0;
                foreach (var selection in selectionSet.Selections)
                {
                    if (selection is Field field)
                    {
                        count++;
                        if (field.SelectionSet != null)
                            count += CountFields(field.SelectionSet);
                    }
                }
                return count;
            }
        }

        public record QueryCostResult(int TotalCost, int Depth, int FieldCount);

Per-User Rate Limiting

public class GraphQLRateLimiterMiddleware
        {
            private readonly RequestDelegate _next;
            private readonly IDistributedCache _cache;
            private readonly ILogger<GraphQLRateLimiterMiddleware> _logger;
            private readonly QueryCostCalculator _costCalculator;

            public GraphQLRateLimiterMiddleware(
                RequestDelegate next,
                IDistributedCache cache,
                ILogger<GraphQLRateLimiterMiddleware> logger,
                QueryCostCalculator costCalculator)
            {
                _next = next;
                _cache = cache;
                _logger = logger;
                _costCalculator = costCalculator;
            }

            public async Task InvokeAsync(HttpContext context)
            {
                if (context.Request.Path.StartsWithSegments("/graphql") &&
                    context.Request.HasJsonContentType())
                {
                    var body = await new StreamReader(context.Request.Body).ReadToEndAsync();
                    context.Request.Body.Position = 0;

                    var request = JsonSerializer.Deserialize<GraphQlRequest>(body);
                    if (request?.Query != null)
                    {
                        var userId = context.GetUserId() ?? "anonymous";
                        var costResult = _costCalculator.Calculate(request.Query);

                        // Per-user rate limit
                        var rateLimitKey = $"graphql:ratelimit:{userId}";
                        var rateLimit = await _cache.GetStringAsync(rateLimitKey);

                        var allowed = CheckRateLimit(rateLimit, costResult.TotalCost);

                        if (!allowed)
                        {
                            context.Response.StatusCode = 429;
                            await context.Response.WriteAsJsonAsync(new
                            {
                                errors = new[]
                                {
                                    new
                                    {
                                        message = "Rate limit exceeded",
                                        extensions = new
                                        {
                                            code = "RATE_LIMITED",
                                            retryAfter = 60
                                        }
                                    }
                                }
                            });
                            return;
                        }

                        // Cost-based limits
                        if (costResult.TotalCost > 1000)
                        {
                            context.Response.StatusCode = 422;
                            await context.Response.WriteAsJsonAsync(new
                            {
                                errors = new[]
                                {
                                    new
                                    {
                                        message = "Query cost too high",
                                        extensions = new
                                        {
                                            code = "QUERY_TOO_EXPENSIVE",
                                            cost = costResult.TotalCost,
                                            maxCost = 1000
                                        }
                                    }
                                }
                            });
                            return;
                        }

                        if (costResult.Depth > 10)
                        {
                            context.Response.StatusCode = 422;
                            await context.Response.WriteAsJsonAsync(new
                            {
                                errors = new[]
                                {
                                    new
                                    {
                                        message = "Query depth too deep",
                                        extensions = new
                                        {
                                            code = "QUERY_TOO_DEEP",
                                            depth = costResult.Depth,
                                            maxDepth = 10
                                        }
                                    }
                                }
                            });
                            return;
                        }

                        // Record cost
                        await IncrementRateLimitCounter(rateLimitKey, costResult.TotalCost);
                    }
                }

                await _next(context);
            }

            private bool CheckRateLimit(string? currentRateLimit, int cost)
            {
                if (string.IsNullOrEmpty(currentRateLimit)) return true;

                var parts = currentRateLimit.Split(':');
                var currentCost = int.Parse(parts[0]);
                var windowEnd = DateTime.Parse(parts[1]);

                if (DateTime.UtcNow > windowEnd) return true; // Window expired

                return (currentCost + cost) <= 5000; // Max 5000 cost per minute
            }

            private async Task IncrementRateLimitCounter(string key, int cost)
            {
                var existing = await _cache.GetStringAsync(key);
                var parts = existing?.Split(':') ?? new[] { "0", DateTime.UtcNow.AddMinutes(1).ToString("O") };
                var currentCost = int.Parse(parts[0]);

                await _cache.SetStringAsync(key, $"{currentCost + cost}:{parts[1]}");
            }
        }

        public record GraphQlRequest(string? Query, string? OperationName, Dictionary<string, object>? Variables);

Practice Tasks

Response Compression with Content Negotiation

Implement:

  • Response compression middleware supporting gzip, brotli, and deflate
  • Content negotiation based on Accept-Encoding header
  • Vary: Accept-Encoding header for proper caching

ETag-Based Caching for Expensive Endpoints

Implement:

  • ETag generation for GET endpoints
  • If-None-Match / 304 Not Modified support
  • If-Modified-Since / 304 Not Modified support
  • Distributed caching with sliding expiration

GraphQL Query Cost Calculator with Per-User Rate Limits

Implement:

  • Query cost analysis based on field complexity
  • Depth limiting (max 10 levels)
  • Per-user rate limiting (5000 cost per minute)
  • Meaningful error responses for exceeded limits

References

  • [RFC 7232 - HTTP Caching](https://www.rfc-editor.org/rfc/rfc7232)
  • [RFC 9110 - HTTP Semantics](https://www.rfc-editor.org/rfc/rfc9110)
  • [Response Compression in ASP.NET Core](https://learn.microsoft.com/en-us/aspnet/core/performance/response-compression)
  • [HTTP/2 Specification](https://www.rfc-editor.org/rfc/rfc7540)
  • [HTTP/3 Specification](https://www.rfc-editor.org/rfc/rfc9114)

Практика


Контрольная точка модуля 8

# Модульный Проект: Multi-Protocol API Platform

Overview

Build a comprehensive multi-protocol API platform that integrates REST, gRPC, GraphQL, and event-driven APIs behind a unified API Gateway.

Architecture

                    +------------------+
                            |   Web / Mobile   |
                            |     Clients      |
                            +--------+---------+
                                     |
                            +--------v---------+
                            |   API Gateway    |
                            |    (YARP)        |
                            |  - Rate Limiting |
                            |  - Circuit Break |
                            |  - Load Balance  |
                            +--------+---------+
                                     |
                  +------------------+------------------+
                  |                  |                  |
            +-----v-----+    +------v------+    +------v------+
            |  REST API  |    |  gRPC API   |    | GraphQL API |
            |  (Blog)    |    | (Orders)    |    | (Search)    |
            +-----+-----+    +------+------+    +------+------+
                  |                  |                  |
                  +------------------+------------------+
                                     |
                            +--------v---------+
                            |   Message Bus    |
                            |   (RabbitMQ)     |
                            +--------+---------+
                                     |
                  +------------------+------------------+
                  |                  |                  |
            +-----v-----+    +------v------+    +------v------+
            |  Database  |    |  Redis      |    |  File Store |
            |  (Postgres)|    |  Cache      |    |             |
            +-----------+    +-------------+    +-------------+

Service Breakdown

Blog Service (REST API)

Endpoints:

  • GET /api/v1/posts - List posts with cursor pagination
  • GET /api/v1/posts/{id} - Get single post
  • POST /api/v1/posts - Create post
  • PUT /api/v1/posts/{id} - Update post
  • PATCH /api/v1/posts/{id} - Partial update
  • DELETE /api/v1/posts/{id} - Delete post
  • GET /api/v1/posts/{id}/comments - List comments
  • POST /api/v1/posts/{id}/comments - Add comment

Features:

  • Cursor-based pagination
  • Problem+JSON error responses (RFC 7807)
  • OpenAPI documentation with Swashbuckle
  • API versioning (v1, v2)
  • Deprecation headers for v1
  • ETag-based caching
  • Response compression (gzip, brotli)

Tech Stack:

  • ASP.NET Core Minimal APIs
  • Entity Framework Core
  • FluentValidation
  • Swashbuckle (OpenAPI)
  • Microsoft.AspNetCore.Mvc.Versioning

Order Service (gRPC)

Protobuf Contract:

syntax = "proto3";
        package ordermanagement;

        service OrderService {
          rpc CreateOrder(CreateOrderRequest) returns (Order);
          rpc GetOrder(OrderId) returns (Order);
          rpc UpdateOrder(UpdateOrderRequest) returns (Order);
          rpc CancelOrder(OrderId) returns (CancelOrderResponse);
          rpc GetOrderUpdates(OrderId) returns (stream OrderUpdate);
          rpc TrackOrders(stream OrderQuery) returns (stream OrderTracking);
        }

        message Order {
          int32 id = 1;
          string customer_email = 2;
          repeated OrderItem items = 3;
          OrderStatus status = 4;
          Money total_amount = 5;
          google.protobuf.Timestamp created_at = 6;
        }

        message OrderItem {
          string product_id = 1;
          string product_name = 2;
          int32 quantity = 3;
          Money unit_price = 4;
        }

        message Money {
          int64 units = 1;
          int32 nanos = 2;
          string currency_code = 3;
        }

        enum OrderStatus {
          ORDER_STATUS_UNSPECIFIED = 0;
          PENDING = 1;
          CONFIRMED = 2;
          SHIPPED = 3;
          DELIVERED = 4;
          CANCELLED = 5;
        }

Features:

  • gRPC interceptors (logging, auth, error handling)
  • Server streaming for order updates
  • Bidirectional streaming for order tracking
  • HTTP/2 multiplexing

Tech Stack:

  • ASP.NET Core gRPC
  • Google.Protobuf
  • Grpc.AspNetCore.Server

Search Service (GraphQL)

Schema:

type Query {
          searchProducts(query: String!, filters: ProductFilters): ProductSearchResult!
          product(id: ID!): Product
          category(slug: String!): Category
          products(first: Int, after: String, filters: ProductFilters): ProductConnection!
        }

        type Mutation {
          addToCart(input: AddToCartInput!): CartPayload!
          checkout(input: CheckoutInput!): OrderPayload!
        }

        type Subscription {
          priceChanged(productId: ID!): PriceUpdate!
          stockUpdated(productId: ID!): StockUpdate!
        }

Features:

  • DataLoader for batching database queries
  • Query depth limiting (max 10)
  • Query cost analysis
  • Per-user rate limiting
  • Filtering, sorting, pagination
  • Subscriptions for real-time updates

Tech Stack:

  • Hot Chocolate
  • GreenDonut (DataLoader)
  • Entity Framework Core

API Gateway (YARP)

Routes:

{
          "ReverseProxy": {
            "Routes": {
              "blog-route": {
                "ClusterId": "blog-cluster",
                "Match": { "Path": "/api/blog/{**remainder}" },
                "Transforms": [
                  { "PathRemovePrefix": "/api/blog" }
                ],
                "RateLimiterPolicy": "blog-limit"
              },
              "orders-route": {
                "ClusterId": "orders-cluster",
                "Match": { "Path": "/api/orders/{**remainder}" }
              },
              "graphql-route": {
                "ClusterId": "graphql-cluster",
                "Match": { "Path": "/graphql" }
              },
              "websocket-route": {
                "ClusterId": "websocket-cluster",
                "Match": { "Path": "/ws/{**remainder}" }
              }
            },
            "Clusters": {
              "blog-cluster": {
                "LoadBalancingPolicy": "round-robin",
                "HealthCheck": {
                  "Active": {
                    "Enabled": true,
                    "Interval": "00:00:10",
                    "Timeout": "00:00:05",
                    "Path": "/health"
                  }
                },
                "Destinations": {
                  "blog1": { "Address": "http://blog-service:5001" },
                  "blog2": { "Address": "http://blog-service:5002" }
                }
              },
              "orders-cluster": {
                "Destinations": {
                  "orders1": { "Address": "http://order-service:5003" }
                }
              },
              "graphql-cluster": {
                "Destinations": {
                  "graphql1": { "Address": "http://graphql-service:5004" }
                }
              }
            }
          }
        }

Features:

  • Health-based load balancing
  • Rate limiting per route
  • Circuit breaking with Polly
  • Request/response transformation
  • BFF layer for mobile and desktop

Webhook System

Endpoints:

  • POST /api/webhooks - Register webhook
  • GET /api/webhooks - List registered webhooks
  • DELETE /api/webhooks/{id} - Unregister webhook
  • GET /api/webhooks/{id}/deliveries - View delivery history

Features:

  • HMAC-SHA256 signature verification
  • Exponential backoff retry (up to 5 attempts)
  • Delivery status tracking
  • Event type filtering
  • Webhook health monitoring

Real-Time Events

SSE Endpoint

  • GET /api/events/stream - Server-sent events stream
  • Heartbeat every 15 seconds
  • Event types: post.created, order.updated, price.changed

WebSocket Endpoint

  • WS /ws/chat - Real-time chat
  • Authentication via query parameter or header
  • Room-based messaging
  • Heartbeat/pong mechanism
  • Exponential backoff reconnection

Implementation Steps

Step 1: Blog Service (REST)

  1. Create ASP.NET Core Web API project
  2. Set up EF Core with PostgreSQL
  3. Implement CRUD endpoints with Minimal APIs
  4. Add cursor-based pagination
  5. Add FluentValidation
  6. Add ProblemDetails for error responses
  7. Add Swashbuckle/OpenAPI documentation
  8. Add API versioning (v1, v2)
  9. Add ETag caching
  10. Add response compression

Step 2: Order Service (gRPC)

  1. Create gRPC service project
  2. Define .proto contracts
  3. Implement OrderService with all method types
  4. Add gRPC interceptors (logging, auth, error handling)
  5. Implement server streaming for order updates
  6. Implement bidirectional streaming for tracking
  7. Add health checks

Step 3: Search Service (GraphQL)

  1. Create ASP.NET Core project with Hot Chocolate
  2. Define schema (queries, mutations, subscriptions)
  3. Implement DataLoaders for batching
  4. Add query depth limiting
  5. Add query cost analysis
  6. Add per-user rate limiting
  7. Implement subscriptions for real-time updates

Step 4: API Gateway (YARP)

  1. Create YARP proxy project
  2. Configure routes for all services
  3. Add health-based load balancing
  4. Add rate limiting
  5. Add circuit breaking with Polly
  6. Implement BFF layer for mobile and desktop

Step 5: Webhook System

  1. Implement webhook registration
  2. Implement delivery with retry
  3. Add signature verification
  4. Add delivery status tracking

Step 6: Real-Time Events

  1. Implement SSE endpoint
  2. Implement WebSocket handler
  3. Add heartbeat mechanism
  4. Add reconnection logic

Passing Criteria

Documentation

  • [x] All REST endpoints documented via OpenAPI 3.1 spec
  • [x] gRPC .proto contracts defined
  • [x] GraphQL schema with documentation

Performance

  • [x] REST P95 latency < 100ms (local)
  • [x] gRPC P95 latency < 20ms (local)
  • [x] Response compression enabled (gzip, brotli)
  • [x] ETag caching implemented

API Versioning

  • [x] API versioning supports 2 major versions simultaneously
  • [x] Deprecation headers (RFC 8594)
  • [x] Migration guide available

GraphQL

  • [x] DataLoader eliminates N+1 queries
  • [x] Query depth limiting configured
  • [x] Query cost analysis working

Reliability

  • [x] Webhook delivery success rate > 99.9% with retry logic
  • [x] Circuit breaker implemented in gateway
  • [x] Health checks on all services

Security

  • [x] Authentication on all endpoints
  • [x] Webhook signature verification
  • [x] Rate limiting configured

Project Structure

MultiProtocolApi/
        ├── src/
        │   ├── Blog.Api/                    # REST API
        │   │   ├── Controllers/
        │   │   ├── Models/
        │   │   ├── Services/
        │   │   └── Program.cs
        │   ├── Orders.Grpc/                 # gRPC Service
        │   │   ├── Protos/
        │   │   ├── Services/
        │   │   └── Program.cs
        │   ├── Search.GraphQL/              # GraphQL Service
        │   │   ├── Types/
        │   │   ├── Resolvers/
        │   │   ├── DataLoaders/
        │   │   └── Program.cs
        │   ├── Gateway/                     # YARP API Gateway
        │   │   ├── appsettings.json
        │   │   └── Program.cs
        │   ├── Webhooks/                    # Webhook Service
        │   │   ├── Services/
        │   │   └── Program.cs
        │   └── Shared/
        │       ├── Models/
        │       └── Common/
        ├── tests/
        │   ├── Blog.Api.Tests/
        │   ├── Orders.Grpc.Tests/
        │   ├── Search.GraphQL.Tests/
        │   ├── Gateway.Tests/
        │   └── Contract.Tests/              # Pact tests
        └── docker-compose.yml

Docker Compose

version: '3.8'

        services:
          postgres:
            image: postgres:16
            environment:
              POSTGRES_DB: multi_protocol_api
              POSTGRES_USER: admin
              POSTGRES_PASSWORD: secret
            ports:
              - "5432:5432"
            volumes:
              - postgres_data:/var/lib/postgresql/data

          redis:
            image: redis:7-alpine
            ports:
              - "6379:6379"

          rabbitmq:
            image: rabbitmq:3-management
            ports:
              - "5672:5672"
              - "15672:15672"

          blog-api:
            build: ./src/Blog.Api
            ports:
              - "5001:8080"
            depends_on:
              - postgres
              - redis

          order-grpc:
            build: ./src/Orders.Grpc
            ports:
              - "5003:8080"
            depends_on:
              - postgres

          search-graphql:
            build: ./src/Search.GraphQL
            ports:
              - "5004:8080"
            depends_on:
              - postgres
              - redis

          gateway:
            build: ./src/Gateway
            ports:
              - "8080:8080"
            depends_on:
              - blog-api
              - order-grpc
              - search-graphql

        volumes:
          postgres_data:

References

  • [ASP.NET Core Web API Documentation](https://learn.microsoft.com/en-us/aspnet/core/web-api/)
  • [gRPC for .NET Documentation](https://learn.microsoft.com/en-us/aspnet/core/grpc/)
  • [Hot Chocolate Documentation](https://chillicream.com/docs/hotchocolate)
  • [YARP Documentation](https://microsoft.github.io/reverse-proxy/)
  • [OpenAPI Specification 3.1](https://spec.openapis.org/oas/v3.1.0)