Skip to content

Separate HTTP schemas from service DTOs

Status

Accepted.

Context

Application services previously accepted FastAPI request schemas and returned response schemas. That made domain behavior depend on HTTP validation, serialization aliases, OpenAPI descriptions, and public identifier formatting. It also made service names ambiguous: a model named Read or Update could be either an HTTP contract or an internal transfer object.

OAuth2 client administration had a second ownership problem. One large service managed registrations, user-backed organization policy, machine organization policy, and revocation side effects. Bearer-principal resolution also lived in the OIDC package even though OAuth2-protected application routes use it without OIDC UserInfo.

These concerns do not apply to standardized OAuth2 and OIDC protocol routes. Those routes deliberately keep typed FastAPI parameters and protocol-specific error translation as part of their public contract.

Decision

Application-owned HTTP request and response models live beside their versioned routers under app/api/v1/. Their names end in Request, Response, or Query. Query is reserved for a typed group of FastAPI query parameters; it is an HTTP transport model, not a service DTO. Names such as *QueryRequest would repeat the transport role without adding meaning.

The cross-cutting application error envelope is the narrow exception. It lives with AppError under app/core/errors/ because domain exceptions, FastAPI handlers, and OpenAPI adapters must serialize and document the same payload. Routes still declare explicitly which application error classes they expose.

Service inputs and outputs live in feature-local dtos.py modules. Every service transfer object ends in DTO; supporting protocols, enums, and principal contexts keep names that describe their role. A versioned route converts its request schema to a DTO before calling a service and exposes a response schema rather than returning a service type as its public contract. Services do not import modules under app/api/.

An actor-neutral lifecycle mutation may normalize several actor-specific DTOs into one feature-local command. These immutable *Command models live in a commands.py module and describe the complete instruction consumed by the mutation service; they are not transport schemas or general service DTOs.

Standardized OAuth2 and OIDC protocol routes remain an explicit exception. Their transport models may stay with their protocol implementation because request extraction, OpenAPI, and protocol-error behavior form one standardized boundary.

OAuth2 bearer-principal resolution belongs to app/oauth2/, while OIDC retains only OIDC-specific UserInfo, claims, discovery, JWKS, and ID-token behavior. Browser users, OAuth2 users, and OAuth2 clients use separate immutable context types behind small read-only protocols. PrincipalContext is the minimal shared contract, UserPrincipalContext carries user-only facts, and OAuth2PrincipalContext is the union of the two bearer context types so client-only fields cannot appear on a user and browser-only state does not leak into bearer flows.

OAuth2 client administration is split by capability:

  • registration creates a client and returns its one-time credential DTO;
  • the registry service reads, updates, and deletes registrations;
  • the user-organization service owns user-backed assignments;
  • the machine-organization service owns client-credentials organization access;
  • the credential service rotates secrets.

Organization-scoped OAuth2 session inspection and revocation live directly under app/oauth2/, outside client registration management. Their service DTOs carry typed public identifiers. The versioned route parses incoming serialized identifiers, and its response schema owns prefixes and field aliases as required by the API-identifier ADR.

Each OAuth2 client-administration endpoint that can raise a service error catches and translates that error locally. Endpoints that cannot raise one need no ceremonial translation block. This repetition is a deliberate, personal project style rather than a general FastAPI requirement: the local try/except keeps the transport boundary and its documented error mapping visible beside every fallible operation. A global exception handler or decorator would remove repetition, but would also hide a decision that this reference server prefers to keep explicit.

Client identifier and secret generation is centralized in one module. Policy narrowing continues to revoke affected sessions inside the service that owns the mutation. Service-boundary DTOs reject unknown fields, machine access is replaced only together with its assignments, and bounded assignment sets are resolved in one query rather than one query per organization.

For SQLite, a single-column index is not retained when an existing composite index has the same leading column and supports the same lookup. Schema changes remain represented by reversible Alembic migrations.

Consequences

HTTP evolution no longer forces service signatures to change, and service tests can use DTOs without importing API contracts. Route modules contain visible, deliberate transport conversion. Some fields are represented twice—once as an internal DTO and once as a public schema—but that duplication documents a real boundary and prevents transport concerns from leaking inward.

The focused OAuth2 client services have smaller authorization and transaction surfaces. Moving bearer resolution clarifies that access-token authentication is OAuth2 infrastructure rather than an OIDC-only feature. Removing redundant SQLite indexes reduces write amplification while preserving the composite query paths.