API Tasarımı Neden Mimari Bir Karardır? Contract, Tüketici, REST'in Gerçekte Ne Olduğu ve Style Guide'lar
API tasarımı mimari bir karardır, çünkü contract tüketicinin gözlemleyebildiği her şeydir ve onu değiştirmek tüketicileri de değiştirmeyi gerektirir. Bookstore'un 200 içinde hata dönen eski RPC endpoint'inden yola çıkıp Fielding'in REST kısıtlarını, Richardson Maturity Model'i, HATEOAS'ın gerçekten işe yaradığı yerleri, Azure, Google AIP ve Zalando rehberlerini karşılaştırıyor ve kendi style guide'ımızı ADR olarak yazıp Spectral ile zorluyoruz.
Sürüm notu: Bu yazıdaki kodlar C# 14 ve .NET 10 (ASP.NET Core 10.0.15) ile derlenip denenmiştir; HTTP örnekleri bu uygulamanın gerçek yanıtlarıdır, OpenAPI 3.2 dokümanı Redocly CLI 2.78.0 ve Spectral 6.17.1 ile lint edilmiştir.
Önce Tahmin Et
Sitedeki serilerin ortak örnek uygulaması Bookstore'un (bir online kitapçı) ilk API'si aceleyle yazılmış: tek bir endpoint var, hangi işin yapılacağını body'deki method alanı söylüyor. Kurumsal Sipariş'e katılan okulların satın alma sistemleri iki yıldır bu endpoint'le kitap bilgisi çekiyor. İki gerçek istek ve yanıt:
$ 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"}
İkinci istekte ISBN'in (kitaplara verilen uluslararası standart numara) son hanesi yanlış. Üç soru: Monitoring panelindeki hata oranı grafiği bu isteği hata olarak sayar mı? booK_ISBN gibi tuhaf alan adlarını kim seçti? Ve ekip yarın stok tablosunu ikiye bölüp stocK_QTY alanını kaldırırsa ne olur?
API Bir Contract'tır: Kodu Değiştirirsin, Contract'ı Değiştiremezsin
Cevaplar sırayla: Hayır, panel hiçbir şey görmez; HTTP katmanında iki istek de başarılı. Alan adlarını kimse seçmedi: endpoint veritabanı kolonlarını (BOOK_ISBN, STOCK_QTY) anonim bir object'e kopyalıyor, System.Text.Json'ın camelCase politikası da alt çizgili büyük harfli adları bu hâle getiriyor. Üçüncü sorunun cevabı en pahalısı: o alanı okuyan okul sistemleri kırılır ve hangilerinin okuduğunu bilmiyoruz.
Burada önemli olan şu: bir API'nin contract'ı, dokümanda yazdığın şey değil, tüketicinin gözlemleyebildiği her şeydir. URL'ler, alan adları ve tipleri, status code'lar, hata formatı, liste sıralaması, hatta bir alanın hiç null gelmemesi. Google'da çalışan Hyrum Wright'ın adıyla anılan Hyrum's Law tam olarak bunu söyler: yeterince çok kullanıcısı olan bir API'de, söz verip vermediğinden bağımsız olarak gözlemlenebilen her davranışa birisi bağımlı hâle gelir. booK_ISBN bir kaza, ama artık contract'ın parçası.
Kodu bir commit'le değiştirirsin; contract'ı değiştirmek ise tüketicilerin de değişmesini gerektirir. Bu yüzden API tasarımı mimari bir karardır: geri alması pahalıdır ve maliyeti kimin tükettiğine göre değişir. Bookstore için üç tür tüketici var:
- Internal:
bookstore-webAngular istemcisi. Aynı ekip, aynı release; contract değişince istemci de aynı pull request'te değişebilir. - Partner: okul ve kütüphanelerin satın alma sistemleri. Başka ekipler, kendi takvimleri var; Nevin Hanım'ın okulundaki entegrasyonu yazan firma yılda bir release çıkarıyor.
- Public: ileride açılacak mobil uygulama ve tanımadığımız geliştiriciler. Kim olduklarını bile bilmezsin, eski sürümler yıllarca telefonlarda yaşar.
Internal API'de yanlış bir karar bir sprint'e mal olur; partner API'de aylara; public API'de bazen hiç düzeltilemez. "Şimdilik internal, sonra açarız" diye yazılan endpoint'lerin çoğu da sessizce partner'a açılır. Bookstore'un /legacy/rpc'si tam olarak böyle doğdu.
REST Gerçekte Ne? Fielding'in Kısıtları
REST (Representational State Transfer; Roy Fielding'in tanımladığı mimari stil) bir protokol ya da standart değil. Fielding 2000 yılında Kaliforniya Üniversitesi Irvine'daki doktora tezinin ("Architectural Styles and the Design of Network-based Software Architectures") beşinci bölümünde onu bir dizi kısıtın toplamı olarak tanımlar: client-server, stateless, cache, uniform interface, layered system ve isteğe bağlı olan code-on-demand. Uniform interface'in kendi içinde dört kısıtı var: resource'ların kimlikle (URI) tanımlanması, resource'ların representation'lar üzerinden değiştirilmesi, kendini anlatan mesajlar (self-descriptive messages) ve HATEOAS (Hypermedia as the Engine of Application State; istemcinin bir sonraki adımı yanıttaki link'lerden öğrenmesi).
Bu kısıtların pratik karşılığını /legacy/rpc üzerinden görmek kolay. Her şey tek URL'den geçtiği için resource kimliği yok; bir kitabı adresleyemezsin, cache'leyemezsin, bir link olarak paylaşamazsın. Her işlem POST olduğu için ara katmanlar (reverse proxy, tarayıcı cache'i, istemcideki HTTP library'si) hangi isteğin güvenle tekrarlanabileceğini bilemez. Hatalar 200 içinde döndüğü için mesaj kendini anlatmıyor: yanıtın başarısız olduğunu anlamak için body'yi parse edip bu API'ye özel success alanını bilmen gerekiyor. Fielding'in kısıtları akademik görünür, ama her biri ölçeklenme, cache ve bağımsız evrim gibi somut bir özellik satın alır.
Richardson Maturity Model: Seviye 0'dan 3'e
Leonard Richardson'ın 2008'de QCon'da anlattığı ve Martin Fowler'ın 2010 tarihli makalesiyle yaygınlaşan model, bir API'nin HTTP'yi ne kadar "kendi diliyle" kullandığını dört seviyede anlatır. Seviye 0'da HTTP sadece taşıma aracıdır: tek endpoint, body'de method adı. Bizim /legacy/rpc burada. Seviye 1'de resource'lar ortaya çıkar: her kitabın kendi URL'i vardır ama her şey hâlâ POST'tur. Seviye 2'de HTTP method'ları ve status code'lar anlamlarıyla kullanılır. Seviye 3'te yanıtlar, istemcinin sonra ne yapabileceğini link'lerle söyler. Aynı soruyu seviye 2'deki yeni endpoint'e soralım:
$ 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"}
Fark yalnız estetik değil. 404'ü monitoring de, reverse proxy de, Angular'daki interceptor da body'ye bakmadan anlıyor. Content-Type: application/problem+json, hata gövdesinin Problem Details formatında olduğunu söylüyor. Bu format, IETF'in (Internet Engineering Task Force; internet standartlarını geliştiren kuruluş) RFC'lerinden (Request for Comments; numaralı standart dokümanları) 9457 numaralı olanda tanımlı. Yanıtta stok adedi ve tedarikçi kodu da yok: onlar iç model, contract değil. type alanındaki adres şimdilik ASP.NET Core'un varsayılanı; serinin 04. yazısında kendi hata tiplerimizi tanımlayacağız.
Fowler makalesinde Fielding'in seviye 3'ü REST'in ön koşulu saydığını hatırlatır; Fielding 2008'de "REST APIs must be hypertext-driven" başlıklı yazısında bunu açıkça söyler. Bu ölçüyle bugün "REST API" denen şeylerin büyük kısmı aslında seviye 2'deki HTTP API'leridir. Bu bir kusur değil; IETF'te bu tür API'ler için ortak yapı taşlarını yazan çalışma grubunun adı da zaten "Building Blocks for HTTP APIs". Bu seride REST kelimesini sektörün kullandığı anlamda, yani resource odaklı ve HTTP semantiğine saygılı API anlamında kullanacağız.
HATEOAS: Link'ler Nerede İşe Yarar, Nerede Yük Olur?
Tam HATEOAS'ta istemci URL'leri bilmez; giriş noktasından başlar, yanıttaki link ve form'ları takip eder. Tarayıcı ile HTML bunun en başarılı örneği. Ama Bookstore'un Angular istemcisi böyle çalışmaz: ekranlarını tasarım aşamasında bilir, contract'tan üretilmiş bir client ile çağrı yapar. Her yanıta "buradan sepete ekleyebilirsin, buradan yorumlarına gidebilirsin" link'leri koymak payload'ı büyütür, test edilmesi gereken yeni bir yüzey açar ve istemcilerin çoğu o link'leri okumadan URL'i yine kendisi kurar.
Link'in gerçekten işe yaradığı yer, URL'in istemcinin hesaplayamayacağı sunucu durumunu taşıdığı yerdir. Üç örnek var ve üçü de bu seride ayrı ayrı tasarlanacak: oluşturulan resource'un adresi (201 ile birlikte Location header'ı, 03. yazı), listenin bir sonraki sayfası (05. yazı) ve uzun süren bir işlemin durum adresi (07. yazı). Bookstore'un liste yanıtında bunun ilk hâli şimdiden var:
$ 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"}
İstemci bir sonraki sayfanın nasıl hesaplandığını bilmiyor, sadece nextPageToken'ı geri gönderiyor. Bu token'ın içinde ne olduğuna ve neden bu hâliyle kalamayacağına 05. yazıda bakacağız. Bookstore'un kararı: Richardson seviye 2, link'ler yalnız bu üç yerde.
Büyük Şirketlerin Style Guide'ları: Azure, Google AIP ve Zalando
Bu kararları sıfırdan vermek zorunda değilsin. Yüzlerce API çıkaran şirketlerin kendi kuralları açık kaynak: Microsoft'un Azure REST API Guidelines'ı, Google'ın AIP'leri (API Improvement Proposals; her biri tek bir tasarım konusunu kurala bağlayan numaralı dokümanlar) ve Zalando'nun RESTful API Guidelines'ı. Ortak noktaları geniş: resource odaklı tasarım, çoğul collection adları, önce standart method'lar, tutarlı hata formatı ve liste endpoint'lerinde ilk günden pagination. AIP-158 bunun nedenini net söyler: pagination'ı sonradan eklemek, tüm sonuçları tek seferde aldığını varsayan istemcileri kırar. Ayrıştıkları yerler ise tam da bir ekibin tartışarak zaman kaybettiği konular:
| Konu | Azure REST API Guidelines | Google AIP | Zalando |
|---|---|---|---|
| Path segment | kebab-case (camelCase de kabul) | camelCase, çoğul (AIP-122) | kebab-case, çoğul (#129, #134) |
| JSON alan adı | camelCase | proto'da snake_case (AIP-140), JSON'da lowerCamelCase | snake_case (#118) |
| Fiil gereken işlem | POST /orders/{id}:cancel | POST .../{id}:cancel (AIP-136) | Fiil yok, resource olarak modelle (#138) |
| Hata formatı | Kendi error object'i ve x-ms-error-code header'ı | google.rpc.Status ve ErrorInfo (AIP-193) | Problem JSON (#176) |
| Pagination | value ve nextLink, maxpagesize | page_size, page_token, next_page_token (AIP-158) | Cursor tercih edilir (#160) |
| Versioning | Zorunlu api-version=2026-11-15 query parametresi | Path'te major sürüm: /v1/ (AIP-185) | Mümkünse hiç; gerekirse media type, URL'de asla (#113, #115) |
Tablodaki her satır serinin bir yazısına denk geliyor. Önemli olan, hangi rehberin "doğru" olduğu değil; bir tanesini seçip istisnalarını yazıya dökmek. Zalando'nun snake_case kuralı Zalando için mantıklı, ama ASP.NET Core ve Angular varsayılan olarak camelCase konuşuyor; bu kuralı Bookstore'a almak her DTO (Data Transfer Object; dışarıya veri taşıyan sade class) ve her TypeScript interface'i için fazladan ayar demek.
Bookstore'un Style Guide'ı: Kararı ADR Olarak Yazmak
Style guide seçimi tam bir ADR (Architecture Decision Record; bir mimari kararı bağlamı ve sonuçlarıyla kaydeden kısa doküman) konusu: birden çok seçenek var, gerekçesi zamanla unutulur, yeni gelen biri "neden snake_case değil?" diye soracak. Architecture Decision'lar serisinin "ADR Yazmak" yazısındaki Nygard şablonuyla Bookstore'un kararı:
# 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.
Style guide'ın kendisi kısa tutuluyor ve her kuralın bir kimliği var, böylece review yorumunda "BS-13'e aykırı" diye referans verebilirsin. İlk sürümdeki kurallardan birkaçı: path segment'leri çoğul ve kebab-case (BS-01), URL'de CRUD (Create, Read, Update, Delete; temel kayıt işlemleri) fiili yok (BS-02), olmayan değer null olarak değil hiç gönderilmeden temsil edilir (BS-11), para { "amount": 489.00, "currency": "TRY" } biçiminde bir object'tir (BS-12) ve stok adedi, tedarikçi kodu, veritabanı anahtarı gibi iç alanlar dışarı açılmaz (BS-13). Kuralların yanında [lint] işareti olanlar araçla kontrol ediliyor, diğerleri review'da.
Contract'ı Koddan Önce Yazmak ve Lint ile Zorlamak
ADR'ın ilk sonucu, endpoint'in önce contract'ta tasarlanması. Bookstore'un contract'ı contract/bookstore.openapi.yaml: OpenAPI 3.2 formatında, YAML (insan tarafından kolay okunan bir veri formatı) ile yazılmış bir doküman. Kitap endpoint'inin tanımı:
/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'
Style guide yazıp araçla zorlamamak, olmayan bir style guide'dan pek farklı değil. İki aracı birlikte kullanıyoruz. Redocly CLI dokümanın OpenAPI 3.2'ye yapısal olarak uyup uymadığını ve önerilen kuralları kontrol ediyor. Spectral ise kendi kurallarımızı yazdığımız motor. Neden ikisi: denediğimiz Spectral 6.17.1, openapi: 3.2.0 dokümanında genel kuralları çalıştırıyor ama şema doğrulamasını (oas3-schema) atlıyor; aynı hatalı doküman 3.1 olarak işaretlenince hata veriyor, 3.2 olarak işaretlenince vermiyor. Style guide'ın iki kuralı Spectral'de şöyle:
# ADR-0001'deki kurallardan linter'ın kontrol edebildikleri.
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'nin ilk taslağında path'ler eski alışkanlıkla yazılmıştı. Redocly bu taslağı geçerli buluyor, çünkü yapısal olarak hiçbir sorun yok; Spectral ise style guide'a aykırı her path'i yakalıyor:
$ 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 1 ile çıktığı için CI'da pull request'i durdurur. Bu kural setini büyütmek, CI'a bağlamak ve contract diff'iyle breaking change yakalamak serinin 09. yazısının konusu.
ASP.NET Core'da
Bu yazıdaki uygulama tek dosyalık bir Minimal API değil ama ondan çok da büyük değil: MapGroup("/books") altında iki endpoint, TypedResults ile dönen Results<Ok<BookResponse>, ProblemHttpResult>, 404 için TypedResults.Problem ve AddProblemDetails(). BS-13 kuralını iç model Book ile dışarı giden BookResponse'u ayırarak, BS-11'i ise ConfigureHttpJsonOptions içinde JsonIgnoreCondition.WhenWritingNull ile uyguluyoruz. Status code ve ProblemDetails'in ayrıntısı ASP.NET Core Web API serisinin "ASP.NET Core'da Status Code ve Response: Ok, CreatedAtAction ve ProblemDetails" yazısında, contract'ı koddan üretme tarafı ise aynı serinin .NET 9, 10 ve 11 yenilikleri yazılarında.
Sık Düşülen Tuzaklar
Veritabanı tablolarını birebir endpoint'e çevirmek. /book_stocks, /order_lines gibi endpoint'ler hızlı çıkar, ama tablo şemasını contract'a çivilersin. Bir sonraki normalizasyon ya da tablo bölme işlemi breaking change olur. Resource'lar tablolardan değil, tüketicinin işinden çıkar; bir sonraki yazının konusu bu.
Tek bir ekran için endpoint tasarlamak. "Sepet sayfası için /cart-page-data" gibi endpoint'ler Angular ekranı değiştiği gün anlamsızlaşır ve partner'lar için hiçbir anlam taşımaz. Ekran odaklı birleştirme gerekiyorsa bu BFF'in (Backend for Frontend; belirli bir istemciye özel ara API) işidir; API Gateway ve YARP (Microsoft'un .NET ile yazılmış reverse proxy library'si) serisinde anlatılıyor.
Style guide'ı yazıp lint ile zorlamamak. Wiki'deki kurallar ilk acil işte unutulur. Kontrol edilebilen her kuralı araca ver, review'u araçla yakalanamayan kararlara sakla.
Büyük bir şirketin rehberini olduğu gibi kopyalamak. Zalando'nun snake_case kuralı ya da Azure'un api-version zorunluluğu kendi bağlamlarında anlamlı. Kendi platformunla çatışan kuralı alırsan ekip her endpoint'te ona karşı çalışır. Temel al, istisnaları ADR'a yaz.
REST saflığı tartışmasında takılıp kalmak. "Bu gerçek REST mi?" sorusu, "tüketici bu contract'ı yanlış anlayabilir mi, değiştirmemiz gerektiğinde onu kırar mıyız?" sorusundan daha az önemlidir.
Küçük Bir Deneme
★☆☆ Uygulamayı dotnet run --project src/Bookstore.Api ile çalıştır ve /legacy/rpc'ye var olmayan bir method adıyla istek at. Status code ne, hata nerede? Aynı hatayı monitoring'in görebileceği bir yanıta çevirmek için hangi status code'u seçerdin?
★★☆ Bookstore'un üç tüketicisinden birini seç (Angular, okul entegrasyonu, mobil) ve bu tüketici için "bir alanı kaldırmak", "alana yeni bir enum değeri eklemek" ve "liste sırasını değiştirmek" değişikliklerinin maliyetini bir cümleyle yaz. Hangisi seni en çok şaşırttı?
★★★ .spectral.yaml'a BS-12 için bir kural ekle: components.schemas içinde adı price ya da total ile biten ve tipi number olan property'leri hata olarak işaretlesin. npm run lint:contract ile kuralın çalıştığını hem temiz contract'ta hem de bilerek bozduğun bir kopyada dene.
Contract'ın bir karar olduğunu ve bu kararı nasıl kayda alacağımızı gördük. Bir sonraki yazıda contract'ın en görünür parçasıyla başlıyoruz: Bookstore'un resource'larını domain'den çıkarmak, URL'leri tasarlamak ve sipariş iptali gibi bir fiil gerektiğinde ne yapacağımız.