API Style
Category: Architecture · Areas: api
Description
Category
architecture
Areas
api
Boundary
This concern owns the synchronous request/response interface style a service
EXPOSES — how a caller and a service exchange information in a single
client-initiated, response-awaited call, and which interface paradigm carries
that exchange: REST (resource-oriented over HTTP semantics), GraphQL
(client-specified queries against one schema endpoint), gRPC
(contract-first protobuf over HTTP/2, with streaming), RPC-style /
server-actions (typed procedure calls — tRPC, Next.js server actions — for a
same-repo first-party client), or MCP (the Model Context Protocol — the
agent-facing style that exposes tools, resources, and prompts to an LLM
client over JSON-RPC 2.0, the AI-client analog of REST-for-humans and
gRPC-for-services). It owns the contract shape, the error shape, the
versioning/typing of that contract, where input is validated, and the
cacheability of responses. It is a decision-guide: it picks the style on
selection signals and holds the chosen style to its standard shape. When the
chosen style is MCP, the MCP-specific discipline — tool/resource/prompt
modeling, description quality, transport + OAuth, and the agent-exposure threat
surface (prompt injection, tool poisoning, confused-deputy, token passthrough,
human-in-the-loop for destructive tools) — is owned by mcp-server; this
concern only places MCP among the styles and names when to pick it. Three
neighbors must stay distinct:
enterprise-integration-patternsowns asynchronous messaging BETWEEN systems — channels, routers, delivery guarantees, dead-letter paths, where sender and receiver are decoupled in space and time over a broker. This concern owns the synchronous request/response interface a service exposes: one caller, one call, one awaited response, no broker between them. The line is load-bearing: a queue/topic/webhook-ingest is EIP; an HTTP/RPC endpoint a client calls and blocks on is api-style. A single product commonly has both (a REST API at the edge and a message queue inside) — they compose, they do not overlap. Do not restate channel/router/delivery-guarantee rules here.onion-architectureowns the layering within one deployable — dependencies point inward, infrastructure is the outer ring. The API is an outer-ring delivery adapter: the controller/resolver/handler/procedure translates a wire request into a call on an inner application service and translates the result back out. api-style says what the wire contract and error shape are; onion says the handler is outer-ring code that depends inward on the application service, never the reverse. Do not restate the dependency rule — reference onion for the arrangement.domain-driven-designowns the domain model — aggregates, value objects, invariants, the ubiquitous language. The API is a translation layer OVER the domain, not the domain itself: REST resources / GraphQL types / protobuf messages / RPC DTOs are the wire representation a client sees, deliberately decoupled from the internal aggregate so the domain can evolve without breaking the contract (and the contract can evolve without reshaping the domain). Do not let wire types become the domain model, and do not restate aggregate/invariant rules here — defer to DDD.
Components
- Interface paradigm — the chosen style: REST, GraphQL, gRPC, RPC/server-actions, or MCP (the agent-facing style). One style per exposed surface (a product may expose more than one surface — see the hybrid note in When to use).
- Contract / schema — the typed, versioned description of the interface: an
OpenAPI spec for REST, a GraphQL schema (SDL), a protobuf
.proto, the inferred-or-declared procedure types for RPC, or for MCP the JSON-Schema-typed tool definitions (resources and prompts are metadata/argument models, not JSON-Schema in the same way) discovered over the protocol (tools/list,resources/list,prompts/list) — where a tool’s description is part of the contract because it is the model’s only affordance for deciding when to call it. The contract is the source of truth a client codes against, not the implementation. - Resource / operation surface — REST resources addressed by URI and acted on with HTTP verbs; GraphQL queries/mutations/subscriptions against one endpoint; gRPC service methods (unary + the three streaming modes); RPC procedures grouped into routers; MCP tools (model-invoked functions), resources (app-controlled context data addressed by URI), and prompts (user-invoked templates) exchanged as JSON-RPC methods.
- Error shape — the style’s standard failure representation: HTTP status
codes (REST/RPC-over-HTTP), the GraphQL
errorsarray in an otherwise200body, gRPC status codes, the JSON-RPC 2.0errorobject (and, for MCP tool execution, theisErrorresult flag). Consistent and machine-readable, never an ad-hoc{ "ok": false }smuggled into a200. - Boundary validation — the point where untrusted input is validated and coerced into typed, trusted values before it reaches the application service (request-body/param validation, schema-typed inputs, protobuf message validation, Zod-validated procedure inputs).
- Cacheability surface — whether and how responses are cacheable: REST’s HTTP cache semantics (verbs, status, cache headers, ETags) are a first-class advantage; GraphQL (single POST endpoint) and gRPC (binary HTTP/2) forgo HTTP caching and cache at the field/application layer instead.
- Versioning/evolution strategy — how the contract changes without breaking clients: REST versioned paths/media types, GraphQL additive evolution + field deprecation (avoid endpoint versioning), protobuf field-number back/forward compatibility, RPC procedure additive change with shared types.
Constraints
One style per exposed surface, chosen on selection signals
- The interface paradigm for a given exposed surface is a deliberate, recorded
choice, driven by the selection signals below — not inherited from a tutorial
or the first framework reached for. A product MAY expose more than one surface
(a public REST/GraphQL edge and internal gRPC between services and an
MCP surface for AI agents); each surface picks its own style for its own
consumer. Do not run two styles over the same surface for the same client.
When the consumer is an LLM agent / AI client consuming exposed tools,
data, or prompts for autonomous use, the style is MCP (and its
MCP-specific discipline is
mcp-server’s).
The contract is typed and versioned; clients code against it, not the implementation
- Every exposed surface has an explicit, typed contract (OpenAPI / GraphQL
schema /
.proto/ inferred-or-declared RPC types) that is the source of truth. Clients depend on the contract, not on implementation internals. - The contract evolves without breaking existing clients: additive change + deprecation (GraphQL), back/forward-compatible field numbers (protobuf), versioned paths/media types (REST), or additive procedures with shared types (RPC). A breaking change is a versioned/communicated event, never a silent reshape.
Input is validated at the boundary
- Untrusted input is validated and coerced at the API boundary into typed,
trusted values before it reaches the application service. The inner layers
receive validated domain inputs, not raw request payloads. (How invariants are
enforced inside the domain is
domain-driven-design’s; this concern guarantees the wire input is checked at the seam.)
Errors use the style’s standard shape
- Failures are returned in the style’s standard, machine-readable shape: HTTP
status codes for REST/RPC-over-HTTP (4xx client / 5xx server, not
200with an error body), the GraphQLerrorsarray, gRPC status codes. Error shape is consistent across the surface, so a client handles failure uniformly.
The wire contract is a translation over the domain, not the domain
- Wire types (REST resources, GraphQL types, protobuf messages, RPC DTOs) are a
representation for clients, mapped to/from the domain model at the boundary.
The internal aggregate is not serialized directly onto the wire, and the
wire shape is not treated as the domain. This decoupling is what lets the
contract and the domain evolve independently (it composes with
onion-architecture’s boundary-DTO rule anddomain-driven-design’s model).
Match HTTP semantics when the style is HTTP-resource-oriented
- For REST, HTTP verbs carry method semantics (GET safe + cacheable, PUT/DELETE
idempotent, POST for non-idempotent creation) and status codes carry outcome
— Richardson Level 2 at minimum. HTTP is not a dumb tunnel for a single
POST /apiRPC (that is Level 0). Cacheability and idempotency are derived from honoring these semantics, not bolted on.
Streaming and transport are chosen for the interaction, not the default
- A streaming interaction (server push, long-lived bidirectional exchange, high-throughput internal calls) is a deliberate reason to choose gRPC (its four method kinds) — not something to fake over request/response polling. Conversely, browser reach and HTTP caching are deliberate reasons NOT to choose gRPC at a public/web edge (binary HTTP/2 is poorly browser- and cache-friendly).
Drift Signals (anti-patterns to reject in review)
- A single
POST /api(or one verb for everything) tunneling RPC over HTTP while calling itself REST → Richardson Level 0; use real resources + verbs + status codes (Level 2), or pick an honest RPC/GraphQL style - An error returned as
200 OKwith{ "error": ... }(REST/RPC-over-HTTP) → use the correct HTTP status code; reserve the GraphQLerrorsarray for GraphQL - Inconsistent/ad-hoc error shapes across one surface → standardize on the style’s error shape so clients handle failure uniformly
- The internal aggregate/ORM row serialized directly onto the wire → map to a wire DTO/resource at the boundary; the contract is a translation over the domain
- No typed/versioned contract (hand-rolled JSON with no OpenAPI/schema/proto) → publish the contract clients code against
- A breaking change shipped silently (field removed/retyped, resource moved) → version or deprecate; never reshape an in-use contract in place
- Raw request payloads passed unvalidated into the application service → validate and coerce at the boundary first
- GraphQL resolvers issuing per-item queries with no batching → the N+1 problem; batch with a DataLoader (or equivalent)
- GraphQL or gRPC chosen reflexively for a thin first-party CRUD web app with one client and no flexible-fetch / streaming need → over-engineering; REST (or same-repo RPC/server-actions) is the simpler correct default
- gRPC exposed directly to a browser / public partners as the public edge → binary HTTP/2 is poor for browser reach and HTTP caching; keep gRPC internal and put REST/GraphQL at the edge
- Two API styles over the same surface for the same client → pick one per surface; do not maintain parallel paradigms for one consumer
- A human/service REST or gRPC API hand-wrapped as “agent tools” with no MCP
contract (bespoke function-calling glue per client) when the consumer is an LLM
agent → expose an MCP surface (standard tool/resource/prompt discovery);
see
mcp-server - An MCP surface treated as just another REST endpoint — tools with terse or
missing descriptions (the model’s only affordance), destructive tools with no
human-in-the-loop, or upstream tokens passed straight through → these are
mcp-server’s MCP-specific failures; defer the agent-exposure discipline there
When to use
Select for every product that EXPOSES a synchronous request/response interface
— any web/service API a client calls and awaits. It is a non-exclusive,
composable decision-guide concern (no slot): rather than one style winning the
project, it recommends a default and names when each alternative wins, and a
product MAY expose more than one surface (each picking its own style). It does
not apply to a product whose only inter-system communication is asynchronous
messaging (that is enterprise-integration-patterns) or a single-process
library/CLI with no exposed interface.
Default stance — REST (or same-repo RPC/server-actions for a first-party web client).
Default to REST for a public or partner-facing API, for resource-oriented CRUD, and wherever broad client reach and HTTP caching matter — it is resource-oriented over standard HTTP semantics (Richardson Level 2+), universally reachable, and cacheable for free. For a first-party web client in the same repo/monorepo (e.g. a Next.js app talking to its own backend) where there is no third-party consumer, a typed RPC/server-actions style (tRPC, Next.js server actions) is the simpler default — end-to-end type safety with no separate contract artifact. Choose GraphQL when many heterogeneous clients need flexible, client-specified data shapes from a shared backend and over-fetching / under-fetching or client-driven round-trips are a real, present cost (a data-intensive aggregating BFF for varied frontends) — accept its single-endpoint HTTP-caching loss and the N+1/resolver discipline it demands. Choose gRPC for internal service-to-service communication where you control both ends and want a strict protobuf contract, low-latency binary transport, and first-class streaming — not for a browser-facing or broadly-public edge, where its weak browser reach and HTTP-cacheability rule it out. Choose MCP when the consumer is an LLM agent / AI client and you are exposing tools, data, or prompts for autonomous use — an AI-native product (or an existing product) making its capabilities consumable by assistants/agents over a standard protocol rather than bespoke per-client function-calling glue. MCP is the agent-facing surface; its MCP-specific contract, transport/auth, and especially the agent-exposure security discipline are owned by
mcp-server(select it alongside api-style when the surface is MCP). Do NOT reach for GraphQL or gRPC on a thin single-client first-party CRUD app: that is cost without payoff (KISS/YAGNI); REST or same-repo RPC/server-actions is the right answer. Hybrids are legitimate — gRPC between backend services, GraphQL or REST as the BFF/edge, REST for public partners, MCP for AI agents — when each surface’s signal genuinely differs.
areas: api scopes its practices to the API/service work items where the
interface lives. Compose with onion-architecture (the API handler is an
outer-ring adapter depending inward), domain-driven-design (the contract is
a translation over the domain model), enterprise-integration-patterns (the
asynchronous counterpart for cross-system messaging — distinct surface),
mcp-server (when the surface is agent-facing: the MCP-specific
tool/resource/prompt, transport/auth, and agent-exposure security discipline),
security-owasp (boundary input validation / authz on the exposed surface), and
the tech-stack concern (which fixes the HTTP/RPC framework and codegen).
Artifact Impact
Selecting this concern requires these artifacts to change (a selected concern absent from them is drift):
- ADR: interface style choice per surface (REST/GraphQL/gRPC/RPC/MCP) + versioning strategy
- TD: the API contract artifact (OpenAPI/GraphQL SDL/proto/RPC types), error shape, boundary validation, cacheability
- TEST_PLAN: contract conformance + boundary input-validation tests on the exposed surface
ADR References
Record an ADR when selecting a non-default style for an exposed surface
(GraphQL or gRPC over the REST / same-repo-RPC default, MCP for an agent-facing
surface, or running multiple styles in a hybrid), naming the surface, its
consumers, and the decisive signal (public/partner reach + caching → REST;
flexible client-specified data across many clients → GraphQL; internal
service-to-service + streaming + strict contract → gRPC; first-party same-repo
client → RPC/server-actions; LLM agent / AI client consuming exposed
tools/data/prompts → MCP, also selecting mcp-server). A material
uncertainty about the interface (unknown client needs, streaming/transport
constraints, contract/versioning strategy) is a tech-spike, not a silent
assumption (see workflows/references/concern-resolution.md).
Practices by activity
Agents working in any of these activities inherit the practices below via the bead’s context digest.
These practices govern the synchronous request/response interface a service
exposes — choosing the interface style (REST / GraphQL / gRPC /
RPC-server-actions), shaping its contract and errors, validating input at the
boundary, and keeping the wire contract a translation over the domain. They do
not govern asynchronous cross-system messaging
(enterprise-integration-patterns), codebase layering (onion-architecture),
or the domain model itself (domain-driven-design); they reference those
concerns at the seam and stay on the exposed interface.
Decide the style before building the surface
- Name the exposed surface and its consumers (public/partner clients, a
first-party web client in the same repo, internal services you own both ends
of), then pick the style on the decisive signal:
- REST — public/partner API, resource-oriented CRUD, broad client reach, HTTP caching matters. This is the default.
- RPC / server-actions (tRPC, Next.js server actions) — a first-party web client in the same repo/monorepo with no third-party consumer; the simpler default there (end-to-end type safety, no separate contract artifact).
- GraphQL — many heterogeneous clients need flexible, client-specified data shapes from a shared backend and over/under-fetching is a real cost.
- gRPC — internal service-to-service where you control both ends and want a strict protobuf contract, low-latency binary transport, and streaming.
- MCP — the consumer is an LLM agent / AI client and you are exposing
tools, data, or prompts for autonomous use (an AI-native product making
its capabilities consumable by assistants/agents). MCP is the agent-facing
surface; when you select it, also select
mcp-serverfor the MCP-specific tool/resource/prompt, transport/OAuth, and agent-exposure security discipline this generic guide does not hold.
- Do NOT reach for GraphQL or gRPC on a thin single-client first-party CRUD app — cost without payoff (KISS/YAGNI). Record the choice (and any non-default selection) in an ADR.
- A product MAY expose multiple surfaces (gRPC internally, REST/GraphQL at the edge); pick one style per surface for its own reason. Do not run two styles over the same surface for the same client.
- A material uncertainty about the interface (unknown client needs, streaming /
transport constraints, contract / versioning strategy) is a
tech-spiketo de-risk before committing — not a silent assumption (seeworkflows/references/concern-resolution.md).
Define a typed, versioned contract
- Publish an explicit, typed contract that is the source of truth clients
code against: an OpenAPI spec (REST), a GraphQL schema (SDL), a
.proto(gRPC), or declared/inferred procedure types (RPC). Clients depend on the contract, not on implementation internals. - Evolve without breaking existing clients: additive change + field deprecation (GraphQL), back/forward-compatible field numbers — never reuse or renumber a field (protobuf), versioned paths/media types (REST), additive procedures with shared types (RPC). A breaking change is versioned and communicated, never a silent in-place reshape of an in-use contract.
Shape REST around HTTP semantics (Richardson Level 2+)
- Address resources by URI and use HTTP verbs for method semantics: GET
safe and cacheable, PUT/DELETE idempotent, POST for non-idempotent creation.
Do not tunnel everything through one
POST /api(Richardson Level 0). - Carry outcome in HTTP status codes (2xx success, 4xx client error, 5xx
server error) — not
200 OKwith an error body.
Handle GraphQL’s tradeoffs deliberately
- Serve one endpoint with client-specified queries; let clients request only the fields they need (this is the over/under-fetching win — do not re-add fixed endpoints that defeat it).
- Batch resolver data access with a DataLoader (or equivalent) to avoid the N+1 problem — a parent resolver returning N items MUST NOT trigger N per-item child queries.
- Return failures in the
errorsarray of the response; do not invent a separate error channel. Accept that the single POST endpoint forgoes HTTP caching — cache at the field/application layer instead.
Reserve gRPC for internal, contract-first, streaming use
- Define services and messages in a
.proto; generate typed client/server stubs (protoc). Use the right method kind for the interaction: unary, or server- / client- / bidirectional-streaming when the exchange is genuinely long-lived or high-throughput — do not fake streaming with polling. - Keep gRPC internal / service-to-service; do not expose it as the browser-facing or broadly-public edge (binary HTTP/2 is poor for browser reach and HTTP caching). Put REST/GraphQL at the edge and gRPC behind it.
Validate input at the boundary
- Validate and coerce untrusted input at the API boundary into typed, trusted
values before it reaches the application service (request body/param
validation, schema-typed inputs, protobuf message checks, Zod-validated RPC
inputs). The inner layers receive validated domain inputs, never raw payloads.
(Authn/authz and the broader threat model are
security-owasp’s; this concern guarantees the input is checked at the seam.)
Keep the wire contract a translation over the domain
- Map between wire types (resources / GraphQL types / protobuf messages / RPC DTOs) and the domain model at the boundary. Do not serialize the internal aggregate or ORM row directly onto the wire, and do not treat the wire shape as the domain model.
- The API handler (controller / resolver / service method / procedure) is an
outer-ring adapter (
onion-architecture): it translates the wire request into a call on an inner application service and translates the result back — it does not contain domain logic.
Boundary with neighbors
- vs
enterprise-integration-patterns: api-style is the synchronous request/response interface a service exposes (one caller, one awaited response); EIP is asynchronous messaging between systems over a broker. A product may have both — a REST/GraphQL/gRPC surface and a queue — but do not conflate an exposed endpoint with a message channel. - vs
onion-architecture: the API handler is outer-ring and depends inward on the application service; do not restate the dependency rule, do honor it (no domain logic in the handler, no inward dependency on the handler). - vs
domain-driven-design: the contract is a translation over the domain, decoupled so each can evolve; do not let wire types become the domain model, and defer aggregate/invariant rules to DDD. - vs
security-owasp: this concern requires input validation exists at the boundary; the threat model, authn/authz, and injection defenses aresecurity-owasp’s — compose, do not restate. - vs
mcp-server: when the exposed surface is agent-facing (an LLM client consuming tools/resources/prompts), api-style only places MCP among the styles and names when to pick it; the MCP-specific contract (tool descriptions as the model’s affordance), transport + OAuth, and agent-exposure security (prompt injection, tool poisoning, confused-deputy, token passthrough, human-in-the-loop for destructive tools) aremcp-server’s — compose, do not restate.
Quality Gates
- The exposed surface has an explicit, typed contract (OpenAPI / GraphQL
schema /
.proto/ declared-or-inferred RPC types) that clients code against, and its style was chosen on a recorded signal (non-default style → ADR-recorded). - Errors use the style’s standard shape — HTTP status codes for
REST/RPC-over-HTTP (no
200 OKwith an error body), the GraphQLerrorsarray, gRPC status codes — and the shape is consistent across the surface. - The contract is versioned/evolvable without breaking existing clients (additive + deprecation / compatible field numbers / versioned paths) — no silent in-place reshape of an in-use contract.
- Untrusted input is validated and coerced at the boundary before reaching the application service; inner layers do not receive raw request payloads (verifiable at the handler/resolver/procedure seam).
- REST surfaces use HTTP verbs + status codes semantically (Richardson Level
2+) — no single-
POSTtunnel calling itself REST; GET is safe/cacheable, PUT/DELETE idempotent. - GraphQL resolvers batch data access (DataLoader or equivalent) — no N+1 per-item query fan-out under a list resolver.
- gRPC is internal, contract-first, with the right method kind for the interaction; it is not the browser-facing/public edge (REST/GraphQL is).
- Wire types are a translation over the domain — the internal aggregate/ORM row is not serialized directly onto the wire; the handler is an outer-ring adapter with no domain logic.
- One style per surface for a given client — no two paradigms maintained over the same surface for the same consumer; and a thin single-client first-party CRUD app uses REST or same-repo RPC/server-actions, not GraphQL/gRPC.