Why API Design Is an Architectural Decision: Contracts, Consumers, What REST Really Is and Style Guides
API design is an architectural decision because the contract is everything a consumer can observe, and changing it means changing the consumers too. Starting from Bookstore's old RPC endpoint that returns errors inside a 200, we cover Fielding's REST constraints, the Richardson Maturity Model, where HATEOAS really helps, and the Azure, Google AIP and Zalando guidelines, then write our own style guide as an ADR and enforce it with Spectral.
Version note: The code in this post was built and run with C# 14 and .NET 10 (ASP.NET Core 10.0.15); the HTTP examples are real responses from this application, and the OpenAPI 3.2 document was linted with Redocly CLI 2.78.0 and Spectral 6.17.1.
Guess First
The first API of Bookstore, the online bookstore used as the shared example across this site's series, was written in a hurry: there is a single endpoint, and the method field in the body says what to do. The purchasing systems of the schools in the institutional ordering programme have been pulling book data through this endpoint for two years. Two real requests and responses:
$ curl -i -X POST http://127.0.0.1:52318/legacy/rpc -H "Content-Type: application/json" -d '{"method":"GetBook","params":{"isbn":"9780441172719"}}'
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Date: Sat, 14 Nov 2026 22:20:12 GMT
Server: Kestrel
Transfer-Encoding: chunked
{"success":true,"data":{"booK_ISBN":"9780441172719","booK_TITLE":"Dune","authoR_NAME":"Frank Herbert","pricE_TL":489.00,"stocK_QTY":14,"supplieR_CODE":"ace-tr"}}
$ curl -i -X POST http://127.0.0.1:52318/legacy/rpc -H "Content-Type: application/json" -d '{"method":"GetBook","params":{"isbn":"9780441172710"}}'
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Date: Sat, 14 Nov 2026 22:20:12 GMT
Server: Kestrel
Transfer-Encoding: chunked
{"success":false,"error":"Book not found"}
The second request has a wrong last digit in the ISBN. Three questions: does the error-rate chart on the monitoring dashboard count it as an error? Who chose odd field names like booK_ISBN? And what happens if the team splits the stock table tomorrow and drops the stocK_QTY field?
An API Is a Contract: You Can Change Code, Not the Contract
The answers, in order: no, the dashboard sees nothing; at the HTTP level both requests succeeded. Nobody chose the field names: the endpoint copies database columns (BOOK_ISBN, STOCK_QTY) into an anonymous object, and System.Text.Json's camelCase policy turns upper-case names with underscores into this. The third answer is the most expensive one: the school systems reading that field break, and we do not know which of them read it.
The point is this: an API's contract is not what the documentation says, it is everything a consumer can observe. URLs, field names and types, status codes, the error format, the order of a list, even the fact that a field is never null. Hyrum's Law, named after Hyrum Wright at Google, says exactly that: once an API has enough users, every observable behaviour will be depended on by somebody, whatever you promised. booK_ISBN is an accident, but it is part of the contract now.
You change code with a commit; changing a contract requires the consumers to change too. That is why API design is an architectural decision: it is expensive to reverse, and the cost depends on who consumes it. Bookstore has three kinds of consumers:
- Internal: the
bookstore-webAngular client. Same team, same release; when the contract changes, the client can change in the same pull request. - Partner: the purchasing systems of schools and libraries. Other teams with their own schedules; the company that built the integration at Nevin Hanım's school ships one release a year.
- Public: the planned mobile app and developers we have never met. You do not even know who they are, and old versions live on phones for years.
A wrong decision in an internal API costs a sprint; in a partner API, months; in a public API it sometimes cannot be fixed at all. And most endpoints written as "internal for now, we will open it later" quietly become partner endpoints. That is exactly how Bookstore's /legacy/rpc was born.
What Is REST, Really? Fielding's Constraints
REST is not a protocol or a standard. In chapter five of his 2000 doctoral dissertation at the University of California, Irvine, "Architectural Styles and the Design of Network-based Software Architectures", Roy Fielding defines it as the sum of a set of constraints: client-server, stateless, cache, uniform interface, layered system and the optional code-on-demand. The uniform interface itself has four constraints: identification of resources by URI, manipulation of resources through representations, self-descriptive messages, and hypermedia as the engine of application state, HATEOAS for short, where the client learns its next step from the links in a response.
It is easy to see what these constraints mean in practice through /legacy/rpc. Everything goes through one URL, so there is no resource identity; you cannot address a book, cache it or share it as a link. Every operation is a POST, so intermediaries (reverse proxies, the browser cache, the HTTP library in the client) cannot know which requests are safe to repeat. Errors come back inside a 200, so the message does not describe itself: to know that the response failed you have to parse the body and know this API's private success field. Fielding's constraints look academic, but each of them buys a concrete property such as scalability, caching or independent evolution.
The Richardson Maturity Model: From Level 0 to 3
The model Leonard Richardson presented at QCon in 2008, made popular by Martin Fowler's 2010 article, describes in four levels how much an API speaks HTTP's own language. At level 0, HTTP is only a transport: one endpoint, the method name in the body. Our /legacy/rpc lives here. At level 1, resources appear: each book has its own URL, but everything is still a POST. At level 2, HTTP methods and status codes are used for what they mean. At level 3, responses tell the client what it can do next through links. Let us ask the same question of the new level 2 endpoint:
$ curl -i http://127.0.0.1:52318/books/9780441172719
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Date: Sat, 14 Nov 2026 22:20:12 GMT
Server: Kestrel
Transfer-Encoding: chunked
{"isbn":"9780441172719","title":"Dune","author":"Frank Herbert","category":"science-fiction","price":{"amount":489.00,"currency":"TRY"},"inStock":true}
$ curl -i http://127.0.0.1:52318/books/9780441172710
HTTP/1.1 404 Not Found
Content-Type: application/problem+json
Date: Sat, 14 Nov 2026 22:20:12 GMT
Server: Kestrel
Transfer-Encoding: chunked
{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.5","title":"Book not found","status":404,"detail":"No book with ISBN 9780441172710 exists in the catalog.","traceId":"00-ec7e3ba7f7c95a95027cd7e7d95a0bd5-4851c37060a1b50f-00"}
The difference is not cosmetic. Monitoring, the reverse proxy and the interceptor in Angular all understand the 404 without looking at the body. Content-Type: application/problem+json says that the error body is in the Problem Details format, defined by the IETF in RFC 9457. The stock count and the supplier code are gone from the response too: they are the internal model, not the contract. The address in type is ASP.NET Core's default for now; in the fourth post of the series we define our own problem types.
Fowler reminds us in the article that Fielding treats level 3 as a precondition of REST; Fielding said so plainly in his 2008 post "REST APIs must be hypertext-driven". By that measure most of what is called a "REST API" today is a level 2 HTTP API. That is not a defect; the IETF working group that writes common building blocks for such APIs is even called "Building Blocks for HTTP APIs". In this series we use the word REST the way the industry does: a resource-oriented API that respects HTTP semantics.
HATEOAS: Where Links Help and Where They Are a Burden
With full HATEOAS the client does not know URLs; it starts from an entry point and follows the links and forms in responses. The browser with HTML is the most successful example. Bookstore's Angular client does not work like that: it knows its screens at design time and calls the API through a client generated from the contract. Putting "you can add this to the cart here, you can see its reviews there" links into every response makes the payload larger, opens a new surface that has to be tested, and most clients build the URL themselves anyway without reading those links.
A link really helps where the URL carries server state that the client cannot compute. There are three such places, and each gets its own design in this series: the address of a created resource (the Location header with 201, post 03), the next page of a list (post 05) and the status address of a long-running operation (post 07). The first form of one of them is already in Bookstore's list response:
$ curl 'http://127.0.0.1:52318/books?pageSize=2'
{"items":[{"isbn":"9780141439587","title":"Emma","author":"Jane Austen","category":"classics","price":{"amount":249.90,"currency":"TRY"},"inStock":true},{"isbn":"9780141439518","title":"Pride and Prejudice","author":"Jane Austen","category":"classics","price":{"amount":229.90,"currency":"TRY"},"inStock":false}],"nextPageToken":"b2Zmc2V0PTI"}
The client does not know how the next page is computed; it only sends nextPageToken back. What is inside this token, and why it cannot stay like this, is the subject of post 05. Bookstore's decision: Richardson level 2, with links only in these three places.
Big Companies' Style Guides: Azure, Google AIP and Zalando
You do not have to make these decisions from scratch. Companies that ship hundreds of APIs publish their rules: Microsoft's Azure REST API Guidelines, Google's AIPs, the API Improvement Proposals in which each numbered document turns one design topic into rules, and Zalando's RESTful API Guidelines. They agree on a lot: resource-oriented design, plural collection names, standard methods first, a consistent error format and pagination on list endpoints from day one. AIP-158 states the reason clearly: adding pagination later breaks clients that assume they received all results at once. Where they differ is exactly where teams lose time arguing:
| Topic | Azure REST API Guidelines | Google AIP | Zalando |
|---|---|---|---|
| Path segment | kebab-case (camelCase also accepted) | camelCase, plural (AIP-122) | kebab-case, plural (#129, #134) |
| JSON field name | camelCase | snake_case in proto (AIP-140), lowerCamelCase in JSON | snake_case (#118) |
| Operation that needs a verb | POST /orders/{id}:cancel | POST .../{id}:cancel (AIP-136) | No verbs, model it as a resource (#138) |
| Error format | Own error object and x-ms-error-code header | google.rpc.Status and ErrorInfo (AIP-193) | Problem JSON (#176) |
| Pagination | value and nextLink, maxpagesize | page_size, page_token, next_page_token (AIP-158) | Cursor preferred (#160) |
| Versioning | Required api-version=2026-11-15 query parameter | Major version in the path: /v1/ (AIP-185) | Avoid if possible; if needed, media type, never in the URL (#113, #115) |
Each row of the table maps to a post in this series. What matters is not which guide is "right", but picking one and writing down your exceptions. Zalando's snake_case rule makes sense for Zalando, but ASP.NET Core and Angular speak camelCase by default; bringing that rule into Bookstore means extra configuration for every DTO and every TypeScript interface.
Bookstore's Style Guide: Writing the Decision as an ADR
Choosing a style guide is a textbook ADR, an Architecture Decision Record: there are several options, the reasoning gets forgotten, and someone new will ask "why not snake_case?". With the Nygard template from the "Writing ADRs" post in the Architecture Decisions series, Bookstore's decision reads:
# ADR-0001: Bookstore HTTP API style guide
Status: Accepted (2026-11-15)
## Context
Bookstore.Api has three kinds of consumers: bookstore-web (Angular, our team),
institutional integrations (school and library systems, other teams we do not control)
and a planned mobile app. The old /legacy/rpc endpoint leaks table columns and returns
200 for every error; partners already depend on it. ASP.NET Core and Angular both
default to camelCase JSON and ASP.NET Core produces RFC 9457 Problem Details.
## Decision
We write our own short style guide (docs/api-style-guide.md) and base it on the
Microsoft Azure REST API Guidelines, with two exceptions:
- errors use RFC 9457 Problem Details, not the Azure error object;
- pagination follows Google AIP-158 (pageSize, pageToken, nextPageToken).
Rules that a tool can check are enforced by .spectral.yaml in CI; the rest are
checked in API review.
## Consequences
- New endpoints are designed in contract/bookstore.openapi.yaml before code.
- /legacy/rpc is frozen; no new methods are added to it.
- Deviations from the guide need a new ADR.
The style guide itself stays short and every rule has an id, so a review comment can say "violates BS-13". A few rules from the first version: path segments are plural and kebab-case (BS-01), no CRUD verbs in the URL (BS-02), a missing value is represented by leaving it out rather than sending null (BS-11), money is an object like { "amount": 489.00, "currency": "TRY" } (BS-12), and internal fields such as stock counts, supplier codes and database keys are not exposed (BS-13). Rules marked [lint] are checked by a tool, the rest in review.
Writing the Contract Before the Code and Enforcing It with a Linter
The first consequence of the ADR is that an endpoint is designed in the contract first. Bookstore's contract is contract/bookstore.openapi.yaml, an OpenAPI 3.2 document written in YAML. The definition of the book endpoint:
/books/{isbn}:
get:
operationId: getBook
summary: Get a book
description: Returns one book by its ISBN-13.
tags: [Books]
security: []
parameters:
- name: isbn
in: path
required: true
description: ISBN-13 without dashes.
schema:
type: string
pattern: '^97[89][0-9]{10}$'
responses:
'200':
description: The book.
content:
application/json:
schema:
$ref: '#/components/schemas/Book'
'404':
$ref: '#/components/responses/Problem'
A style guide that is not enforced by a tool is not much different from having none. We use two tools together. Redocly CLI checks that the document is structurally valid OpenAPI 3.2 and applies its recommended rules. Spectral is the engine for our own rules. Why both: Spectral 6.17.1, which we tried, runs its general rules on an openapi: 3.2.0 document but skips schema validation (oas3-schema); the same broken document produces an error when marked as 3.1 and none when marked as 3.2. Two rules of the style guide in Spectral:
# Rules from ADR-0001 that a linter can check.
extends: ["spectral:oas"]
rules:
bookstore-path-kebab-case:
description: Path segments are lowercase kebab-case; parameters are camelCase.
message: "Path is not kebab-case: {{property}}"
severity: error
given: $.paths[*]~
then:
function: pattern
functionOptions:
match: "^(/([a-z0-9]+(-[a-z0-9]+)*|\\{[a-z][a-zA-Z0-9]*\\}))+$"
bookstore-no-verbs-in-path:
description: Resources are nouns; CRUD verbs do not belong in the URL.
message: "Verb in path: {{property}}"
severity: error
given: $.paths[*]~
then:
function: pattern
functionOptions:
notMatch: "/(get|list|create|add|update|save|delete|remove)([A-Z/-]|$)"
Emre's first draft had paths written out of old habit. Redocly finds the draft valid, because structurally nothing is wrong; Spectral catches every path that violates the style guide:
$ npx redocly lint contract/bookstore.openapi.yaml
validating contract/bookstore.openapi.yaml...
contract/bookstore.openapi.yaml: validated in 12ms
Woohoo! Your API description is valid. 🎉
$ npx spectral lint contract/bookstore.openapi.yaml
/home/user/bookstore/contract/bookstore.openapi.yaml
24:13 error bookstore-no-verbs-in-path Verb in path: /getBooks paths./getBooks
24:13 error bookstore-path-kebab-case Path is not kebab-case: /getBooks paths./getBooks
43:17 error bookstore-path-kebab-case Path is not kebab-case: /Books/{ISBN} paths./Books/{ISBN}
67:23 error bookstore-path-kebab-case Path is not kebab-case: /book_stocks/{isbn} paths./book_stocks/{isbn}
✖ 4 problems (4 errors, 0 warnings, 0 infos, 0 hints)
Spectral exits with 1, so it stops the pull request in CI. Growing this rule set, wiring it into CI and catching breaking changes with a contract diff is the subject of post 09.
In ASP.NET Core
The application in this post is not a one-file Minimal API, but it is not much bigger: two endpoints under MapGroup("/books"), Results<Ok<BookResponse>, ProblemHttpResult> returned through TypedResults, TypedResults.Problem for the 404 and AddProblemDetails(). We implement BS-13 by separating the internal Book model from the outgoing BookResponse, and BS-11 with JsonIgnoreCondition.WhenWritingNull in ConfigureHttpJsonOptions. Status codes and ProblemDetails in detail are in the "Status Codes and Responses in ASP.NET Core: Ok, CreatedAtAction and ProblemDetails" post of the ASP.NET Core Web API series, and generating the contract from code is covered in the same series' .NET 9, 10 and 11 posts.
Common Pitfalls
Turning database tables into endpoints one to one. Endpoints like /book_stocks and /order_lines ship fast, but they nail the table schema into the contract. The next normalisation or table split becomes a breaking change. Resources come from the consumer's work, not from tables; that is the subject of the next post.
Designing an endpoint for one screen. Endpoints like "/cart-page-data for the cart page" lose their meaning the day the Angular screen changes, and they mean nothing to partners. If screen-shaped aggregation is needed, that is the job of a BFF, a Backend for Frontend; it is covered in the API Gateway and YARP series.
Writing a style guide and not enforcing it with a linter. Rules on a wiki are forgotten at the first urgent task. Give every checkable rule to a tool and keep review for the decisions a tool cannot catch.
Copying a big company's guide as is. Zalando's snake_case rule and Azure's mandatory api-version make sense in their own context. If you adopt a rule that fights your platform, the team works against it on every endpoint. Use a guide as a base and write the exceptions into an ADR.
Getting stuck in REST purity debates. "Is this real REST?" matters less than "can a consumer misread this contract, and will we break them when we need to change it?".
A Small Experiment
★☆☆ Run the application with dotnet run --project src/Bookstore.Api and send /legacy/rpc a request with a method name that does not exist. What is the status code, and where is the error? Which status code would you choose to make the same error visible to monitoring?
★★☆ Pick one of Bookstore's three consumers (Angular, a school integration, mobile) and write one sentence each on the cost of "removing a field", "adding a new enum value to a field" and "changing the order of a list" for that consumer. Which one surprised you most?
★★★ Add a rule for BS-12 to .spectral.yaml: flag as an error every property in components.schemas whose name ends in price or total and whose type is number. Check with npm run lint:contract that the rule works on the clean contract and on a copy you break on purpose.
We saw that a contract is a decision and how we record that decision. In the next post we start with the most visible part of the contract: finding Bookstore's resources in the domain, designing the URLs, and what to do when an operation such as cancelling an order needs a verb.