Skip to main content
Welcome. This site supports keyboard navigation and screen readers. Press ? at any time for keyboard shortcuts. Press [ to focus the sidebar, ] to focus the content. High-contrast themes are available via the toolbar.
serard@dev00:~/cv

API Contracts and Serialization

"The API is the boundary where your conventions meet someone else's code. Convention drift inside a monolith is painful. Convention drift on a public API is a production incident."


APIs are the external surface of your system. When a convention violation hides inside a service class, the team discovers it during code review — maybe. When a convention violation leaks into an API response, the client discovers it at 2 AM. A missing field. A changed casing. A 200 OK that should have been a 404. A ProblemDetails response that suddenly arrives as a raw string.

Inside a codebase, convention drift is a nuisance. At the API boundary, convention drift is a breaking change.

This part walks through the four eras of API contract management — from manual JSON construction to source-generated endpoints with typed clients — and shows how each era handles (or fails to handle) the gap between what the API documentation says and what the API actually returns.


Era 1: Code

In the beginning, you built the HTTP response by hand. Every status code, every header, every JSON property — explicit, manual, and brittle.

// ASP.NET Web API 1.0 / early MVC — manual everything
public class UsersController : ApiController
{
    private readonly IUserRepository _repository;

    public UsersController(IUserRepository repository)
    {
        _repository = repository;
    }

    // GET /api/users/{id}
    public HttpResponseMessage Get(int id)
    {
        try
        {
            var user = _repository.GetById(id);
            if (user == null)
            {
                return Request.CreateResponse(HttpStatusCode.NotFound,
                    new { error = "User not found", id = id });
            }

            var json = new
            {
                id = user.Id,
                name = user.FirstName + " " + user.LastName,  // manual concatenation
                email = user.Email,
                created = user.CreatedAt.ToString("yyyy-MM-dd") // manual date format
            };

            return Request.CreateResponse(HttpStatusCode.OK, json);
        }
        catch (Exception ex)
        {
            return Request.CreateResponse(HttpStatusCode.InternalServerError,
                new { error = "An error occurred", message = ex.Message });
            // Yes, leaking exception messages to clients. Era 1 was rough.
        }
    }

    // POST /api/users
    public HttpResponseMessage Post()
    {
        try
        {
            var body = Request.Content.ReadAsStringAsync().Result; // blocking async. Era 1.
            var data = JsonConvert.DeserializeObject<dynamic>(body);

            if (data == null || data.email == null)
            {
                return Request.CreateResponse(HttpStatusCode.BadRequest,
                    new { error = "Email is required" });
            }

            var user = new User
            {
                FirstName = (string)data.firstName,  // casing mismatch? silent null
                LastName = (string)data.lastName,
                Email = (string)data.email,
                CreatedAt = DateTime.UtcNow
            };

            _repository.Add(user);

            var response = Request.CreateResponse(HttpStatusCode.Created, new
            {
                id = user.Id,
                name = user.FirstName + " " + user.LastName,
                email = user.Email
            });
            response.Headers.Location = new Uri(
                Request.RequestUri + "/" + user.Id);

            return response;
        }
        catch (JsonException)
        {
            return Request.CreateResponse(HttpStatusCode.BadRequest,
                new { error = "Invalid JSON" });
        }
        catch (Exception ex)
        {
            return Request.CreateResponse(HttpStatusCode.InternalServerError,
                new { error = ex.Message });
        }
    }

    // GET /api/users
    public HttpResponseMessage GetAll()
    {
        var users = _repository.GetAll();
        var result = users.Select(u => new
        {
            id = u.Id,
            name = u.FirstName + " " + u.LastName,
            email = u.Email,
            created = u.CreatedAt.ToString("yyyy-MM-dd")
        });

        return Request.CreateResponse(HttpStatusCode.OK, result);
        // No pagination. No total count. No Link headers.
        // The client gets everything or nothing.
    }
}

The problems are fundamental:

  • No contract. The client guesses the response shape from trial and error. The only documentation is "ask Dave, he wrote it."
  • No consistency. The GET endpoint returns created as a formatted string. The POST response omits it entirely. One endpoint uses error, another uses message. Nobody notices until a client breaks.
  • No schema. There is no machine-readable description of what this API accepts or returns. Every consumer writes their own deserialization code, each with slightly different assumptions.
  • No validation. The POST endpoint checks for email with a null check on a dynamic object. Missing firstName silently becomes null in the database.
  • No error standard. Error responses are ad-hoc anonymous objects. Each endpoint invents its own error shape.

The API surface is a handwritten contract that exists only in code — and each endpoint writes a different contract.


Era 2: Configuration

ASP.NET Web API 2 and then early ASP.NET Core MVC introduced attributes, model binding, and Swagger integration. The contract moved from code into configuration metadata.

// ASP.NET Core MVC with Swagger annotations — heavily configured
[ApiController]
[Route("api/v{version:apiVersion}/[controller]")]
[ApiVersion("1.0")]
[Produces("application/json")]
[Consumes("application/json")]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status500InternalServerError)]
public class UsersController : ControllerBase
{
    private readonly IUserRepository _repository;
    private readonly ILogger<UsersController> _logger;

    public UsersController(IUserRepository repository, ILogger<UsersController> logger)
    {
        _repository = repository;
        _logger = logger;
    }

    /// <summary>
    /// Retrieves a user by their unique identifier.
    /// </summary>
    /// <param name="id">The user's unique ID.</param>
    /// <param name="cancellationToken">Cancellation token.</param>
    /// <returns>The user if found.</returns>
    /// <response code="200">Returns the user.</response>
    /// <response code="404">User not found.</response>
    /// <response code="500">Internal server error.</response>
    [HttpGet("{id:int}")]
    [ProducesResponseType(typeof(UserResponse), StatusCodes.Status200OK)]
    [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
    [SwaggerOperation(
        Summary = "Get user by ID",
        Description = "Retrieves a single user by their unique identifier.",
        OperationId = "GetUserById",
        Tags = new[] { "Users" })]
    [SwaggerResponse(200, "The user was found", typeof(UserResponse))]
    [SwaggerResponse(404, "The user was not found", typeof(ProblemDetails))]
    public async Task<ActionResult<UserResponse>> GetById(
        [FromRoute] int id,
        CancellationToken cancellationToken)
    {
        var user = await _repository.GetByIdAsync(id, cancellationToken);
        if (user is null)
        {
            return NotFound(new ProblemDetails
            {
                Title = "User not found",
                Detail = $"No user with ID {id} exists.",
                Status = StatusCodes.Status404NotFound,
                Instance = HttpContext.Request.Path
            });
        }

        return Ok(UserResponse.FromUser(user));
    }

    /// <summary>
    /// Creates a new user.
    /// </summary>
    /// <param name="request">The user creation request.</param>
    /// <param name="cancellationToken">Cancellation token.</param>
    /// <returns>The created user.</returns>
    /// <response code="201">User created successfully.</response>
    /// <response code="400">Invalid request body.</response>
    /// <response code="409">User with this email already exists.</response>
    [HttpPost]
    [ProducesResponseType(typeof(UserResponse), StatusCodes.Status201Created)]
    [ProducesResponseType(typeof(ValidationProblemDetails), StatusCodes.Status400BadRequest)]
    [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status409Conflict)]
    [SwaggerOperation(
        Summary = "Create a new user",
        Description = "Creates a new user with the provided information.",
        OperationId = "CreateUser",
        Tags = new[] { "Users" })]
    public async Task<ActionResult<UserResponse>> Create(
        [FromBody] CreateUserRequest request,
        CancellationToken cancellationToken)
    {
        var existing = await _repository.GetByEmailAsync(request.Email, cancellationToken);
        if (existing is not null)
        {
            return Conflict(new ProblemDetails
            {
                Title = "Duplicate email",
                Detail = $"A user with email '{request.Email}' already exists.",
                Status = StatusCodes.Status409Conflict
            });
        }

        var user = new User
        {
            FirstName = request.FirstName,
            LastName = request.LastName,
            Email = request.Email,
            CreatedAt = DateTime.UtcNow
        };

        await _repository.AddAsync(user, cancellationToken);

        var response = UserResponse.FromUser(user);
        return CreatedAtAction(nameof(GetById), new { id = user.Id }, response);
    }
}

// ── Separate DTO classes ──
public class UserResponse
{
    public int Id { get; init; }
    public string FullName { get; init; } = string.Empty;
    public string Email { get; init; } = string.Empty;
    public DateTime CreatedAt { get; init; }

    public static UserResponse FromUser(User user) => new()
    {
        Id = user.Id,
        FullName = $"{user.FirstName} {user.LastName}",
        Email = user.Email,
        CreatedAt = user.CreatedAt
    };
}

public class CreateUserRequest
{
    [Required]
    [StringLength(100)]
    public string FirstName { get; init; } = string.Empty;

    [Required]
    [StringLength(100)]
    public string LastName { get; init; } = string.Empty;

    [Required]
    [EmailAddress]
    public string Email { get; init; } = string.Empty;
}

Real progress, but a new class of problem emerges:

  • Annotations drift from reality. The [ProducesResponseType(typeof(ProblemDetails), 404)] documents a 404 response. But what if the method also throws a 500 that is not annotated? Swagger shows a clean contract. The client gets an undocumented error shape at runtime.
  • Duplication. The XML doc comments say "Returns the user if found." The [SwaggerOperation] says "Retrieves a single user by their unique identifier." The [SwaggerResponse] says "The user was found." Three places describing the same thing, each slightly different.
  • Schema is advisory. Swagger generates an OpenAPI spec from these annotations. But the spec is a side-effect, not a source of truth. Rename UserResponse.FullName to UserResponse.DisplayName? The spec updates. The client breaks. Nobody tested the contract.
  • Boilerplate per endpoint. Count the attributes on GetById: 7 attributes, 7 lines of XML comments, 15 lines of method body. The metadata-to-logic ratio is approaching 2:1.

The contract exists now — but it is a shadow cast by annotations, not a structural guarantee.


Era 3: Convention

ASP.NET Core Minimal APIs and System.Text.Json source generators brought the convention era to APIs. Less ceremony, more implicit behavior, and a set of team agreements about how endpoints should look.

// Minimal API endpoint — clean, convention-driven
public static class UserEndpoints
{
    public static void MapUserEndpoints(this IEndpointRouteBuilder app)
    {
        var group = app.MapGroup("/api/v1/users")
            .WithTags("Users")
            .WithOpenApi();

        group.MapGet("/{id:int}", GetById)
            .WithName("GetUserById")
            .Produces<UserResponse>(StatusCodes.Status200OK)
            .ProducesProblem(StatusCodes.Status404NotFound);

        group.MapPost("/", Create)
            .WithName("CreateUser")
            .Produces<UserResponse>(StatusCodes.Status201Created)
            .ProducesValidationProblem()
            .ProducesProblem(StatusCodes.Status409Conflict);

        group.MapGet("/", List)
            .WithName("ListUsers")
            .Produces<PagedResponse<UserResponse>>(StatusCodes.Status200OK);
    }

    private static async Task<Results<Ok<UserResponse>, NotFound<ProblemDetails>>> GetById(
        int id,
        IUserRepository repository,
        CancellationToken cancellationToken)
    {
        var user = await repository.GetByIdAsync(id, cancellationToken);
        if (user is null)
        {
            return TypedResults.NotFound(new ProblemDetails
            {
                Title = "User not found",
                Detail = $"No user with ID {id} exists.",
                Status = StatusCodes.Status404NotFound
            });
        }

        return TypedResults.Ok(UserResponse.FromUser(user));
    }

    private static async Task<Results<Created<UserResponse>, Conflict<ProblemDetails>,
        ValidationProblem>> Create(
        CreateUserRequest request,
        IUserRepository repository,
        IValidator<CreateUserRequest> validator,
        CancellationToken cancellationToken)
    {
        var validation = await validator.ValidateAsync(request, cancellationToken);
        if (!validation.IsValid)
        {
            return TypedResults.ValidationProblem(validation.ToDictionary());
        }

        var existing = await repository.GetByEmailAsync(request.Email, cancellationToken);
        if (existing is not null)
        {
            return TypedResults.Conflict(new ProblemDetails
            {
                Title = "Duplicate email",
                Detail = $"A user with email '{request.Email}' already exists.",
                Status = StatusCodes.Status409Conflict
            });
        }

        var user = new User
        {
            FirstName = request.FirstName,
            LastName = request.LastName,
            Email = request.Email,
            CreatedAt = DateTime.UtcNow
        };

        await repository.AddAsync(user, cancellationToken);

        return TypedResults.Created(
            $"/api/v1/users/{user.Id}",
            UserResponse.FromUser(user));
    }

    private static async Task<Ok<PagedResponse<UserResponse>>> List(
        [AsParameters] PaginationQuery pagination,
        IUserRepository repository,
        CancellationToken cancellationToken)
    {
        var (users, total) = await repository.GetPagedAsync(
            pagination.Page, pagination.PageSize, cancellationToken);

        return TypedResults.Ok(new PagedResponse<UserResponse>
        {
            Items = users.Select(UserResponse.FromUser).ToList(),
            Page = pagination.Page,
            PageSize = pagination.PageSize,
            TotalCount = total
        });
    }
}

// System.Text.Json source generation — compile-time serialization
[JsonSerializable(typeof(UserResponse))]
[JsonSerializable(typeof(CreateUserRequest))]
[JsonSerializable(typeof(PagedResponse<UserResponse>))]
[JsonSerializable(typeof(ProblemDetails))]
[JsonSerializable(typeof(ValidationProblemDetails))]
internal partial class AppJsonContext : JsonSerializerContext;

Cleaner. TypedResults gives compile-time safety on return types. System.Text.Json source generators eliminate reflection-based serialization. The framework convention handles most of the OpenAPI metadata automatically.

But now the team needs rules. Every team building Minimal APIs at scale discovers this within weeks — the framework gives you freedom, and freedom without guardrails produces inconsistency.

The Convention: API Design Standards

Every team writes this document. Some call it "API Guidelines." Some call it "REST Standards." Some put it in a wiki. Some put it in an ADR. All of them drift from the codebase within a quarter.

# API Design Standards v2.3
Last updated: 2025-09-14 (NOTE: this was 6 months ago)

## Endpoint Return Types
- All endpoints MUST return `Results<T1, T2, ...>` using TypedResults
- Do NOT return `IResult` — it defeats OpenAPI type inference
- Success types: `Ok<T>`, `Created<T>`, `NoContent`
- Error types: `NotFound<ProblemDetails>`, `Conflict<ProblemDetails>`,
  `BadRequest<ProblemDetails>`, `ValidationProblem`
- Do NOT use `Results.Ok()` — use `TypedResults.Ok()` (factory methods)

## Error Responses
- ALL error responses MUST use RFC 7807 ProblemDetails
- Do NOT return raw strings or anonymous objects as errors
- The `Title` field is human-readable, `Detail` provides specifics
- The `Instance` field SHOULD contain the request path
- The `Type` field SHOULD reference an error catalog URI

## Route Patterns
- Base: `/api/v{version}/{resource}/{id?}`
- Collections: GET `/api/v1/users`
- Single item: GET `/api/v1/users/{id}`
- Create: POST `/api/v1/users`
- Update: PUT `/api/v1/users/{id}`
- Partial update: PATCH `/api/v1/users/{id}`
- Delete: DELETE `/api/v1/users/{id}`
- Sub-resources: GET `/api/v1/users/{id}/orders`
- Use kebab-case for multi-word resources: `/api/v1/order-items`

## Serialization
- Register all request/response types in a JsonSerializerContext
- Use camelCase property naming (System.Text.Json default)
- DateTime as ISO 8601 strings
- Enums as strings, not integers
- Do NOT use `[JsonIgnore]` to hide sensitive data — use separate DTOs

## Pagination
- Use `X-Pagination` header with JSON: { page, pageSize, totalCount, totalPages }
- Accept `page` and `pageSize` query parameters
- Default page size: 25. Maximum: 100.
- Return items in a wrapper: `{ items: [], page, pageSize, totalCount }`

## Async and Cancellation
- ALL async endpoint handlers MUST accept CancellationToken as last parameter
- ALL repository/service calls MUST forward the CancellationToken

## Documentation
- ALL endpoints MUST have `.WithName()` for OpenAPI operation ID
- ALL endpoints MUST have `.WithTags()` for grouping
- ALL endpoint groups MUST call `.WithOpenApi()`

## Versioning
- Use URL path versioning: `/api/v1/`, `/api/v2/`
- Breaking changes require a new version
- Deprecated endpoints must have `.WithMetadata(new ObsoleteAttribute())`

Fifty-two lines of conventions. Every one of them is a rule that a developer can forget, a reviewer can miss, and a CI pipeline does not check (unless someone writes the check).

The Enforcement Code

So the team writes tests. These are real tests that real teams maintain in real codebases. Every single one exists because a convention was violated and a client broke.

// tests/Architecture/ApiConventionTests.cs
public class ApiConventionTests
{
    private readonly Assembly _apiAssembly = typeof(UserEndpoints).Assembly;

    [Fact]
    public void All_Endpoint_Handlers_Must_Return_TypedResults()
    {
        // Convention: "Do NOT return IResult — use TypedResults"
        var endpointMethods = GetEndpointHandlerMethods();

        foreach (var method in endpointMethods)
        {
            var returnType = method.ReturnType;
            // Unwrap Task<T>
            if (returnType.IsGenericType && returnType.GetGenericTypeDefinition() == typeof(Task<>))
                returnType = returnType.GetGenericArguments()[0];

            var isTypedResult = returnType.IsGenericType &&
                (returnType.GetGenericTypeDefinition().Name.StartsWith("Results`") ||
                 returnType.Name.StartsWith("Ok`") ||
                 returnType.Name.StartsWith("Created`") ||
                 returnType.Name == "NoContent");

            isTypedResult.Should().BeTrue(
                $"Handler '{method.DeclaringType!.Name}.{method.Name}' returns {returnType.Name}. " +
                "All endpoint handlers must return TypedResults (Results<T1,T2,...>). " +
                "See: wiki/api-design-standards.md#endpoint-return-types");
        }
    }

    [Fact]
    public void All_Error_Responses_Must_Use_ProblemDetails()
    {
        // Convention: "ALL error responses MUST use RFC 7807 ProblemDetails"
        var endpointMethods = GetEndpointHandlerMethods();

        foreach (var method in endpointMethods)
        {
            var returnType = UnwrapTask(method.ReturnType);
            if (!returnType.IsGenericType) continue;

            var typeArgs = returnType.GetGenericArguments();
            foreach (var arg in typeArgs)
            {
                var isErrorType = arg.Name.Contains("NotFound") ||
                                  arg.Name.Contains("Conflict") ||
                                  arg.Name.Contains("BadRequest") ||
                                  arg.Name.Contains("UnprocessableEntity");

                if (!isErrorType) continue;

                // Error type must be generic with ProblemDetails
                arg.IsGenericType.Should().BeTrue(
                    $"Error type '{arg.Name}' in '{method.Name}' must be " +
                    "generic with ProblemDetails, e.g., NotFound<ProblemDetails>. " +
                    "See: wiki/api-design-standards.md#error-responses");

                if (arg.IsGenericType)
                {
                    arg.GetGenericArguments()[0].Should().Be(typeof(ProblemDetails),
                        $"Error type in '{method.Name}' must wrap ProblemDetails, not " +
                        $"'{arg.GetGenericArguments()[0].Name}'. " +
                        "See: wiki/api-design-standards.md#error-responses");
                }
            }
        }
    }

    [Fact]
    public void All_Async_Handlers_Must_Accept_CancellationToken()
    {
        // Convention: "ALL async endpoint handlers MUST accept CancellationToken"
        var endpointMethods = GetEndpointHandlerMethods();

        foreach (var method in endpointMethods)
        {
            if (!typeof(Task).IsAssignableFrom(method.ReturnType)) continue;

            var parameters = method.GetParameters();
            var hasCancellationToken = parameters.Any(p =>
                p.ParameterType == typeof(CancellationToken));

            hasCancellationToken.Should().BeTrue(
                $"Async handler '{method.DeclaringType!.Name}.{method.Name}' " +
                "must accept a CancellationToken parameter. " +
                "See: wiki/api-design-standards.md#async-and-cancellation");
        }
    }

    [Fact]
    public void All_Endpoints_Must_Have_OperationName()
    {
        // Convention: "ALL endpoints MUST have .WithName()"
        // This test actually starts the test server and inspects the endpoint metadata
        using var app = CreateTestApplication();
        var endpointDataSource = app.Services.GetRequiredService<EndpointDataSource>();

        foreach (var endpoint in endpointDataSource.Endpoints)
        {
            if (endpoint is not RouteEndpoint routeEndpoint) continue;
            if (!routeEndpoint.RoutePattern.RawText?.StartsWith("/api/") ?? true) continue;

            var metadata = endpoint.Metadata;
            var hasName = metadata.GetMetadata<EndpointNameMetadata>() is not null;

            hasName.Should().BeTrue(
                $"Endpoint '{routeEndpoint.RoutePattern.RawText}' is missing .WithName(). " +
                "See: wiki/api-design-standards.md#documentation");
        }
    }

    [Fact]
    public void OpenAPI_Spec_Must_Match_Actual_Endpoints()
    {
        // Convention: Swagger must reflect actual behavior
        // This test generates the spec and validates it against endpoint signatures
        using var app = CreateTestApplication();
        var client = app.CreateClient();

        var spec = client.GetStringAsync("/swagger/v1/swagger.json").Result;
        var document = JsonDocument.Parse(spec);
        var paths = document.RootElement.GetProperty("paths");

        foreach (var path in paths.EnumerateObject())
        {
            foreach (var operation in path.Value.EnumerateObject())
            {
                var responses = operation.Value.GetProperty("responses");

                // Every endpoint must document at least a success and one error response
                var responseCodes = responses.EnumerateObject().Select(r => r.Name).ToList();

                responseCodes.Should().Contain(c =>
                    c.StartsWith("2"),
                    $"Endpoint {operation.Name.ToUpper()} {path.Name} " +
                    "has no documented success response");

                // Non-GET endpoints should have validation error response
                if (operation.Name != "get")
                {
                    responseCodes.Should().Contain(
                        c => c == "400" || c == "422",
                        $"Endpoint {operation.Name.ToUpper()} {path.Name} " +
                        "has no documented validation error response");
                }
            }
        }
    }

    [Fact]
    public void Route_Patterns_Must_Follow_Convention()
    {
        // Convention: "/api/v{version}/{resource}/{id?}"
        using var app = CreateTestApplication();
        var endpointDataSource = app.Services.GetRequiredService<EndpointDataSource>();

        var apiRoutePattern = new Regex(@"^/api/v\d+/[a-z][a-z0-9-]*(/\{[a-z][a-zA-Z]*\})?$");

        foreach (var endpoint in endpointDataSource.Endpoints)
        {
            if (endpoint is not RouteEndpoint routeEndpoint) continue;
            var route = routeEndpoint.RoutePattern.RawText;
            if (route is null || !route.StartsWith("/api/")) continue;

            apiRoutePattern.IsMatch(route).Should().BeTrue(
                $"Route '{route}' does not match pattern '/api/v{{version}}/{{resource}}/{{id?}}'. " +
                "Use kebab-case for multi-word resources. " +
                "See: wiki/api-design-standards.md#route-patterns");
        }
    }

    [Fact]
    public void All_Response_Types_Must_Be_Registered_In_JsonContext()
    {
        // Convention: "Register all request/response types in a JsonSerializerContext"
        var contextType = typeof(AppJsonContext);
        var registeredTypes = contextType.GetCustomAttributes<JsonSerializableAttribute>()
            .Select(a => a.TypeInfoPropertyName)
            .ToHashSet();

        var responseTypes = GetEndpointHandlerMethods()
            .SelectMany(m => ExtractResponseTypes(m.ReturnType))
            .Where(t => !t.IsPrimitive && t != typeof(string))
            .Distinct();

        foreach (var type in responseTypes)
        {
            registeredTypes.Should().Contain(type.Name,
                $"Response type '{type.Name}' is not registered in AppJsonContext. " +
                "See: wiki/api-design-standards.md#serialization");
        }
    }

    // Helper methods
    private IEnumerable<MethodInfo> GetEndpointHandlerMethods() { /* ... */ }
    private Type UnwrapTask(Type type) { /* ... */ }
    private IEnumerable<Type> ExtractResponseTypes(Type returnType) { /* ... */ }
    private WebApplicationFactory<Program> CreateTestApplication() { /* ... */ }
}

The Convention Tax: Counted

API Design Standards document:
  wiki/api-design-standards.md                    52 lines

Convention enforcement tests:
  All_Endpoint_Handlers_Must_Return_TypedResults   25 lines
  All_Error_Responses_Must_Use_ProblemDetails      30 lines
  All_Async_Handlers_Must_Accept_CancellationToken 18 lines
  All_Endpoints_Must_Have_OperationName            18 lines
  OpenAPI_Spec_Must_Match_Actual_Endpoints         28 lines
  Route_Patterns_Must_Follow_Convention            20 lines
  All_Response_Types_Registered_In_JsonContext      18 lines
  Test helper methods + setup                      40 lines
  ─────────────────────────────────────────────
  Enforcement subtotal:                           197 lines

JsonSerializerContext registration (manual):       6 lines
Endpoint registration boilerplate (per endpoint): 12 lines

Total Convention Overhead:                        ~267 lines

Two hundred and sixty-seven lines that have nothing to do with what users GET or POST. They exist because invisible rules need visible policing.

And here is the part that hurts most: every new endpoint needs maintenance in three places. Add a new OrderEndpoints.MapOrderEndpoints() file? You must also register the response types in AppJsonContext. You must also follow the return type convention. You must also use ProblemDetails. You must also accept CancellationToken. You must also call .WithName(). You must also call .WithTags(). If you forget any of these, the violation is invisible until a test catches it — and only if someone wrote that specific test.


Era 4: Contention

One attribute. The Source Generator handles the rest.

What the Developer Writes

// The developer defines the endpoint contract as a record:
[TypedEndpoint(
    Route = "/api/v1/users/{id:int}",
    Method = HttpMethod.Get,
    Name = "GetUserById",
    Tags = new[] { "Users" },
    Summary = "Retrieves a user by ID")]
public record GetUserByIdEndpoint(
    [RouteParam] int Id)
    : IEndpoint<GetUserByIdEndpoint, UserResponse>;

[TypedEndpoint(
    Route = "/api/v1/users",
    Method = HttpMethod.Post,
    Name = "CreateUser",
    Tags = new[] { "Users" },
    Summary = "Creates a new user")]
public record CreateUserEndpoint(
    [BodyParam, Validated] CreateUserRequest Body)
    : IEndpoint<CreateUserEndpoint, UserResponse>;

[TypedEndpoint(
    Route = "/api/v1/users",
    Method = HttpMethod.Get,
    Name = "ListUsers",
    Tags = new[] { "Users" },
    Summary = "Lists users with pagination")]
public record ListUsersEndpoint(
    [QueryParam] int Page = 1,
    [QueryParam] int PageSize = 25)
    : IEndpoint<ListUsersEndpoint, PagedResponse<UserResponse>>;

// The developer implements the handler:
public class GetUserByIdHandler : IEndpointHandler<GetUserByIdEndpoint, UserResponse>
{
    private readonly IUserRepository _repository;

    public GetUserByIdHandler(IUserRepository repository)
    {
        _repository = repository;
    }

    public async Task<EndpointResult<UserResponse>> HandleAsync(
        GetUserByIdEndpoint request, CancellationToken cancellationToken)
    {
        var user = await _repository.GetByIdAsync(request.Id, cancellationToken);
        if (user is null)
            return EndpointResult.NotFound($"No user with ID {request.Id} exists.");

        return EndpointResult.Ok(UserResponse.FromUser(user));
    }
}

public class CreateUserHandler : IEndpointHandler<CreateUserEndpoint, UserResponse>
{
    private readonly IUserRepository _repository;

    public CreateUserHandler(IUserRepository repository)
    {
        _repository = repository;
    }

    public async Task<EndpointResult<UserResponse>> HandleAsync(
        CreateUserEndpoint request, CancellationToken cancellationToken)
    {
        var existing = await _repository.GetByEmailAsync(
            request.Body.Email, cancellationToken);
        if (existing is not null)
            return EndpointResult.Conflict(
                $"A user with email '{request.Body.Email}' already exists.");

        var user = new User
        {
            FirstName = request.Body.FirstName,
            LastName = request.Body.LastName,
            Email = request.Body.Email,
            CreatedAt = DateTime.UtcNow
        };

        await _repository.AddAsync(user, cancellationToken);

        return EndpointResult.Created(
            $"/api/v1/users/{user.Id}",
            UserResponse.FromUser(user));
    }
}

That is the entire developer surface. No .WithName() calls. No [ProducesResponseType] stacking. No manual AppJsonContext registration. No convention document to read. Just a record defining the shape and a handler implementing the logic.

What the Source Generator Produces

// ── Generated: EndpointRegistration.g.cs ──
[GeneratedCode("Cmf.Api.Generators", "1.0.0")]
public static class EndpointRegistrationExtensions
{
    public static IEndpointRouteBuilder MapGeneratedEndpoints(
        this IEndpointRouteBuilder app)
    {
        // GET /api/v1/users/{id:int}
        app.MapGet("/api/v1/users/{id:int}", async (
                int id,
                IEndpointHandler<GetUserByIdEndpoint, UserResponse> handler,
                CancellationToken cancellationToken) =>
            {
                var request = new GetUserByIdEndpoint(id);
                var result = await handler.HandleAsync(request, cancellationToken);
                return result.ToHttpResult();
            })
            .WithName("GetUserById")
            .WithTags("Users")
            .WithSummary("Retrieves a user by ID")
            .WithOpenApi()
            .Produces<UserResponse>(StatusCodes.Status200OK)
            .ProducesProblem(StatusCodes.Status404NotFound)
            .ProducesProblem(StatusCodes.Status500InternalServerError);

        // POST /api/v1/users
        app.MapPost("/api/v1/users", async (
                [FromBody] CreateUserRequest body,
                IEndpointHandler<CreateUserEndpoint, UserResponse> handler,
                IValidator<CreateUserRequest> validator,
                CancellationToken cancellationToken) =>
            {
                var validation = await validator.ValidateAsync(body, cancellationToken);
                if (!validation.IsValid)
                    return Results.ValidationProblem(validation.ToDictionary());

                var request = new CreateUserEndpoint(body);
                var result = await handler.HandleAsync(request, cancellationToken);
                return result.ToHttpResult();
            })
            .WithName("CreateUser")
            .WithTags("Users")
            .WithSummary("Creates a new user")
            .WithOpenApi()
            .Produces<UserResponse>(StatusCodes.Status201Created)
            .ProducesValidationProblem()
            .ProducesProblem(StatusCodes.Status409Conflict)
            .ProducesProblem(StatusCodes.Status500InternalServerError)
            .AddEndpointFilter<ValidationFilter<CreateUserRequest>>();

        // GET /api/v1/users
        app.MapGet("/api/v1/users", async (
                [AsParameters] ListUsersEndpoint request,
                IEndpointHandler<ListUsersEndpoint, PagedResponse<UserResponse>> handler,
                CancellationToken cancellationToken) =>
            {
                var result = await handler.HandleAsync(request, cancellationToken);
                return result.ToHttpResult();
            })
            .WithName("ListUsers")
            .WithTags("Users")
            .WithSummary("Lists users with pagination")
            .WithOpenApi()
            .Produces<PagedResponse<UserResponse>>(StatusCodes.Status200OK)
            .ProducesProblem(StatusCodes.Status500InternalServerError);

        return app;
    }
}

// ── Generated: ApiJsonSerializerContext.g.cs ──
// The SG discovers all request/response types from [TypedEndpoint] records
// and generates the serialization context automatically.
[GeneratedCode("Cmf.Api.Generators", "1.0.0")]
[JsonSerializable(typeof(UserResponse))]
[JsonSerializable(typeof(CreateUserRequest))]
[JsonSerializable(typeof(PagedResponse<UserResponse>))]
[JsonSerializable(typeof(ProblemDetails))]
[JsonSerializable(typeof(ValidationProblemDetails))]
internal partial class ApiJsonSerializerContext : JsonSerializerContext;

// ── Generated: EndpointResult.g.cs ──
// Typed result wrapper that the SG understands
[GeneratedCode("Cmf.Api.Generators", "1.0.0")]
public readonly struct EndpointResult<T>
{
    private readonly T? _value;
    private readonly ProblemDetails? _problem;
    private readonly int _statusCode;
    private readonly string? _location;

    internal EndpointResult(T value) =>
        (_value, _statusCode) = (value, 200);

    internal EndpointResult(T value, string location) =>
        (_value, _statusCode, _location) = (value, 201, location);

    internal EndpointResult(ProblemDetails problem, int statusCode) =>
        (_problem, _statusCode) = (problem, statusCode);

    public IResult ToHttpResult() => _statusCode switch
    {
        200 => TypedResults.Ok(_value),
        201 => TypedResults.Created(_location, _value),
        404 => TypedResults.NotFound(_problem),
        409 => TypedResults.Conflict(_problem),
        _ => TypedResults.Problem(_problem)
    };
}

// ── Generated: UsersTypedClient.g.cs ──
// A typed HTTP client for consumers — generated from the same [TypedEndpoint] metadata
[GeneratedCode("Cmf.Api.Generators", "1.0.0")]
public class UsersApiClient
{
    private readonly HttpClient _client;
    private readonly JsonSerializerOptions _options;

    public UsersApiClient(HttpClient client)
    {
        _client = client;
        _options = new JsonSerializerOptions
        {
            TypeInfoResolver = ApiJsonSerializerContext.Default
        };
    }

    /// <summary>Retrieves a user by ID.</summary>
    public async Task<ApiResult<UserResponse>> GetUserByIdAsync(
        int id, CancellationToken cancellationToken = default)
    {
        var response = await _client.GetAsync(
            $"/api/v1/users/{id}", cancellationToken);

        return response.StatusCode switch
        {
            HttpStatusCode.OK =>
                ApiResult.Ok(await response.Content
                    .ReadFromJsonAsync<UserResponse>(_options, cancellationToken)),
            HttpStatusCode.NotFound =>
                ApiResult.NotFound<UserResponse>(await response.Content
                    .ReadFromJsonAsync<ProblemDetails>(_options, cancellationToken)),
            _ =>
                ApiResult.Error<UserResponse>(response.StatusCode,
                    await response.Content.ReadAsStringAsync(cancellationToken))
        };
    }

    /// <summary>Creates a new user.</summary>
    public async Task<ApiResult<UserResponse>> CreateUserAsync(
        CreateUserRequest request, CancellationToken cancellationToken = default)
    {
        var response = await _client.PostAsJsonAsync(
            "/api/v1/users", request, _options, cancellationToken);

        return response.StatusCode switch
        {
            HttpStatusCode.Created =>
                ApiResult.Ok(await response.Content
                    .ReadFromJsonAsync<UserResponse>(_options, cancellationToken)),
            HttpStatusCode.Conflict =>
                ApiResult.Conflict<UserResponse>(await response.Content
                    .ReadFromJsonAsync<ProblemDetails>(_options, cancellationToken)),
            HttpStatusCode.BadRequest =>
                ApiResult.ValidationError<UserResponse>(await response.Content
                    .ReadFromJsonAsync<ValidationProblemDetails>(_options, cancellationToken)),
            _ =>
                ApiResult.Error<UserResponse>(response.StatusCode,
                    await response.Content.ReadAsStringAsync(cancellationToken))
        };
    }

    /// <summary>Lists users with pagination.</summary>
    public async Task<ApiResult<PagedResponse<UserResponse>>> ListUsersAsync(
        int page = 1, int pageSize = 25, CancellationToken cancellationToken = default)
    {
        var response = await _client.GetAsync(
            $"/api/v1/users?page={page}&pageSize={pageSize}", cancellationToken);

        return response.StatusCode switch
        {
            HttpStatusCode.OK =>
                ApiResult.Ok(await response.Content
                    .ReadFromJsonAsync<PagedResponse<UserResponse>>(
                        _options, cancellationToken)),
            _ =>
                ApiResult.Error<PagedResponse<UserResponse>>(response.StatusCode,
                    await response.Content.ReadAsStringAsync(cancellationToken))
        };
    }
}

// ── Generated: DI Registration ──
[GeneratedCode("Cmf.Api.Generators", "1.0.0")]
public static class ApiServiceRegistrationExtensions
{
    public static IServiceCollection AddGeneratedEndpointHandlers(
        this IServiceCollection services)
    {
        services.AddScoped<
            IEndpointHandler<GetUserByIdEndpoint, UserResponse>,
            GetUserByIdHandler>();
        services.AddScoped<
            IEndpointHandler<CreateUserEndpoint, UserResponse>,
            CreateUserHandler>();
        services.AddScoped<
            IEndpointHandler<ListUsersEndpoint, PagedResponse<UserResponse>>,
            ListUsersHandler>();

        return services;
    }

    public static IServiceCollection AddGeneratedApiClients(
        this IServiceCollection services, Action<HttpClient> configureClient)
    {
        services.AddHttpClient<UsersApiClient>(configureClient);
        return services;
    }
}

What the Analyzer Enforces

The Source Generator produces correct code. The Analyzer catches the developer writing incorrect input.

// ── Analyzer: API001 — Handler returns raw type instead of EndpointResult<T> ──

// BAD:
public async Task<UserResponse> HandleAsync(            // API001 ←
    GetUserByIdEndpoint request, CancellationToken ct)
// Error: "Handler 'GetUserByIdHandler.HandleAsync' returns 'UserResponse' directly.
//   IEndpointHandler<TRequest, TResponse>.HandleAsync must return
//   Task<EndpointResult<TResponse>> for consistent error handling.
//   Return EndpointResult.Ok(value) for success."

// GOOD:
public async Task<EndpointResult<UserResponse>> HandleAsync(
    GetUserByIdEndpoint request, CancellationToken ct)


// ── Analyzer: API002 — Request DTO missing [Validated] attribute ──

// BAD:
[TypedEndpoint(Route = "/api/v1/orders", Method = HttpMethod.Post, ...)]
public record CreateOrderEndpoint(
    [BodyParam] CreateOrderRequest Body)              // API002 ←
    : IEndpoint<CreateOrderEndpoint, OrderResponse>;
// Warning: "Endpoint 'CreateOrderEndpoint' has a [BodyParam] parameter
//   'Body' of type 'CreateOrderRequest' without [Validated].
//   Add [Validated] to enable automatic request validation,
//   or [SkipValidation] to explicitly opt out."


// ── Analyzer: API003 — Async handler missing CancellationToken ──

// BAD:
public async Task<EndpointResult<UserResponse>> HandleAsync(
    GetUserByIdEndpoint request)                      // API003 ←
// Warning: "Handler 'GetUserByIdHandler.HandleAsync' is async but does not accept
//   CancellationToken. Add 'CancellationToken cancellationToken' as the last
//   parameter to support request cancellation."

// GOOD:
public async Task<EndpointResult<UserResponse>> HandleAsync(
    GetUserByIdEndpoint request, CancellationToken cancellationToken)


// ── Analyzer: API004 — Route parameter mismatch ──

// BAD:
[TypedEndpoint(Route = "/api/v1/users/{userId:int}", ...)]
public record GetUserByIdEndpoint(
    [RouteParam] int Id)                              // API004 ←
    : IEndpoint<GetUserByIdEndpoint, UserResponse>;
// Error: "Route parameter 'userId' in '/api/v1/users/{userId:int}' does not match
//   any [RouteParam] property. 'GetUserByIdEndpoint' has [RouteParam] 'Id' —
//   rename it to 'UserId' or change the route to '{id:int}'."

Four analyzers. Four diagnostics. All visible in the IDE as the developer types — red squiggles, not test failures discovered twenty minutes later in CI.

The Convention Tax: Eliminated

Convention documentation needed:           0 lines  (the attribute IS the contract)
Convention enforcement tests needed:       0 lines  (the analyzers enforce at compile time)
Manual JsonContext registration needed:    0 lines  (the SG discovers types automatically)
Manual endpoint wiring boilerplate:        0 lines  (the SG generates MapGet/MapPost)
Manual typed client code:                  0 lines  (the SG generates the HTTP client)
Manual DI registration:                    0 lines  (the SG generates AddGeneratedEndpointHandlers)
─────────────────────────────────────────────
Developer wrote:   ~80 lines (3 endpoint records + 2 handler classes)
SG generated:     ~300 lines (endpoint registration + JSON context + typed client + DI)
Analyzers enforce:  4 rules (API001-API004) at compile time
Convention overhead: 0 lines

The Convention Tax

Aspect Code Configuration Convention Contention
Contract definition Anonymous objects Swagger attributes TypedResults + guidelines doc [TypedEndpoint] record
Serialization JsonConvert manual Json.NET reflection System.Text.Json SG (manual registration) SG auto-discovers types
Error shape Ad-hoc per endpoint ProblemDetails (sometimes) ProblemDetails (documented, not enforced) ProblemDetails (generated, always)
OpenAPI spec None Generated from annotations Generated from conventions Generated from endpoint records
Typed client None None None (manual HttpClient wrappers) SG-generated per endpoint group
CancellationToken Forgotten Forgotten Documented, test-enforced Analyzer-enforced
Route consistency Wild west Attribute-based Convention doc + test Part of the type definition
Lines of documentation 0 0 (XML comments) 52 0
Lines of enforcement 0 0 197 0
Drift risk Total High (annotations vs reality) Medium (doc vs code vs tests) Zero (single source of truth)

Contract Drift Across Eras

Diagram

In Eras 1-3, the client and server can diverge. The further right on the diagram, the less drift is possible — but only in Era 4 is drift structurally eliminated. The server endpoint and the typed client are generated from the same attribute. They cannot disagree because they share a single source of truth.

Full Generation Chain

Diagram

One record. Six generated artifacts. Four compile-time guardrails. Zero convention documents. Zero enforcement tests.


The Typed Client: Why It Matters

The generated typed client deserves special attention because it eliminates an entire class of contract drift that even the Convention era cannot address.

In every era before Contention, the API consumer writes their own HTTP client code. They read the Swagger spec (if one exists), they write HttpClient.GetAsync(...), they deserialize the response, and they hope the response shape matches what the spec said. When the server team renames a field, the client breaks at runtime.

With [TypedEndpoint], the Source Generator produces both the server endpoint and the client. The consumer references the generated client:

// Consumer code — no manual HttpClient wiring
var result = await usersClient.GetUserByIdAsync(42);

result.Match(
    ok: user => Console.WriteLine($"Found: {user.FullName}"),
    notFound: problem => Console.WriteLine($"Not found: {problem.Detail}"),
    error: (status, body) => Console.WriteLine($"Unexpected: {status}")
);

If the server team renames UserResponse.FullName to UserResponse.DisplayName, the generated client updates in the same build. The consumer gets a compile error: 'UserResponse' does not contain a definition for 'FullName'. The contract drift is caught before anyone pushes to main.

This is the same pattern described in Requirements as Code — the type system as the single source of truth — applied to the API boundary. And it follows the M1/M2 metamodeling pattern: the [TypedEndpoint] record is an M1 instance of an M2 API metamodel, just as an [AggregateRoot] class is an M1 instance of the DDD metamodel.


What About GraphQL? gRPC? Protobuf?

A reasonable objection: "GraphQL and gRPC already have typed contracts and generated clients. Isn't this just reinventing the wheel for REST?"

Yes and no.

GraphQL and gRPC solve the contract-drift problem by replacing REST entirely. They introduce their own schema languages (.graphql, .proto), their own tooling, their own learning curves, and their own ecosystems. They are effective — but they are also a different technology choice.

Contention solves the contract-drift problem within REST. The endpoint is still a standard Minimal API route. The response is still JSON. The OpenAPI spec is still generated. The only difference is that the contract is defined in C# — not in annotations, not in convention documents, not in a separate schema language — in the same type system the developer already uses.

For teams that want or need REST (which is most teams, most of the time), Contention provides the same contract guarantees that gRPC provides for RPC — without leaving the C# type system.


What Happens When You Add an Endpoint

In the Convention era, adding a new endpoint requires:

  1. Create the handler method
  2. Register the route in the endpoint group
  3. Add .WithName(), .WithTags(), .WithOpenApi()
  4. Add .Produces<T>() for each success response type
  5. Add .ProducesProblem() for each error status code
  6. Register all new request/response types in AppJsonContext
  7. Ensure the handler returns TypedResults, not IResult
  8. Ensure error responses use ProblemDetails
  9. Ensure the handler accepts CancellationToken
  10. Ensure the route follows the naming convention
  11. Write or update the integration test
  12. Hope someone runs the convention test suite before merging

Twelve steps. Miss any one of them, and a convention test might catch it — if that test exists.

In the Contention era, adding a new endpoint requires:

  1. Create a [TypedEndpoint] record
  2. Create the handler class

Two steps. The SG generates steps 2-10. The analyzer catches structural mistakes in the record or handler. There is no step 12 because there is no convention to violate.


Closing

Convention-era APIs look clean until you count the shadow artifacts: the fifty-two-line API guidelines document that three new developers never read, the seven enforcement tests that someone must maintain whenever an endpoint convention changes, the manual JsonSerializerContext registration that nobody remembers until the serialization fails at runtime.

Contention collapses all of that into a single type-safe record. One attribute. The Source Generator produces the endpoint, the OpenAPI metadata, the serialization context, and the typed client. The Analyzer catches structural violations before the code compiles. There is nothing to document because the record IS the documentation. There is nothing to enforce because the compiler IS the enforcement.

The API boundary is where convention drift hurts most — because the client is on the other side of a network call, in a different codebase, on a different team's schedule. Making the contract structural, not conventional, is not a luxury. It is the minimum standard for APIs that other people depend on.

Next: Part V: Database Mapping and EF Core — where [AggregateRoot] drives EF configuration, repository generation, and factory creation, and where the Convention Tax is heaviest: folder conventions, naming conventions, EF conventions, all documented and policed separately, all replaceable with one attribute.