Resource Modelleme ve URL Tasarımı: Collection, Sub-resource, Naming, Custom Action'lar ve Fiil Gerektiğinde
İyi bir URL tasarımında resource'lar tablolardan değil domain'in dilinden çıkar, her resource'un tek kanonik adresi olur ve fiil gereken iş ya custom method ya da kendi yaşam döngüsü olan yeni bir resource olur. Bookstore'un kurumsal sipariş adreslerini, singleton sepeti, ISBN, UUID v7 ve slug kimlik seçimlerini, POST /orders/{id}:cancel ile iade resource'unu gerçek HTTP yanıtları ve Spectral kurallarıyla tasarlıyoruz.
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 (bir online kitapçı) kurumsal müşterilere açılıyor. Hakan'ın getirdiği ilk istek net: Lakeside School'un satın alma sorumlusu Nevin Hanım kendi kampüsünün siparişlerini, okulun onay yetkilisi Linus ise bütün kampüslerin siparişlerini görmek istiyor. Ekipte iki öneri var. Emre veritabanındaki hiyerarşiyi URL'e taşımak istiyor:
GET /institutions/lakeside-school/units/main-campus/orders/01a1c112-d7c0-706c-8cb9-c18a8fadc1a6
Ayşe ise siparişin tek bir adresi olsun, kurum listesi de bir filtre olsun diyor:
GET /orders/01a1c112-d7c0-706c-8cb9-c18a8fadc1a6
GET /institutions/lakeside-school/orders?unitId=main-campus
İki soru. Başka bir kurumdan Grace aynı sipariş adresini isterse ne dönmeli: 403 mü, 404 mü? Ve Emre'nin tasarımında sipariş hem /orders/{orderId}'den hem de kurum yolundan erişilebilir olursa, "bu siparişi kim görebilir?" kontrolünü kaç yerde yazmak zorunda kalırsın?
Resource'ları Domain'den Çıkarmak
Bir önceki yazının tuzaklarından biri, tabloları birebir endpoint'e çevirmekti. Resource'ları bulmanın daha iyi yolu domain'in kendi diline bakmak: Bookstore'da insanlar kitap, sipariş, iade, sepet, kurum ve kampüs kelimelerini kullanıyor. Tablolarda ise OrderLines, StockReservations, InstitutionUserUnitMap gibi adlar var. Bunların bir kısmı resource'a dönüşür, bir kısmı bir resource'un representation'ının içinde kalır, bir kısmı da hiç dışarı çıkmaz. Sipariş satırları ayrı bir collection değil, siparişin içinde gelir; stok rezervasyonu bir iç mekanizmadır, tüketicinin işine yaramaz.
Bulunan her resource için iki soru sorulur. Birincisi: bu bir collection mı, singleton mı? Siparişlerden çok sayıda var, her birinin kimliği var: collection. Bir müşterinin sepeti ise tektir ve müşteriyle birlikte var olur: singleton. Google'ın API tasarım kuralları AIP'lerde (API Improvement Proposals; her biri tek bir konuyu kurala bağlayan numaralı dokümanlar) toplanır ve AIP-156 singleton'ı ebeveyninin adına eklenen sabit bir segment olarak tanımlar ve singleton'ın create ya da delete edilmediğini, ebeveyniyle birlikte oluşup yok olduğunu söyler. Bookstore'da sepet /customers/{customerId}/cart. Müşteri kendi kimliğini bilmek zorunda kalmasın diye me takma adını kabul ediyoruz; AIP-122 de böyle takma adlara izin verir, ama yanıtlarda hep gerçek kimliğin dönmesini ister:
$ curl -s -H "Authorization: Bearer ada" http://127.0.0.1:52318/customers/me/cart
{"customerId":"ada","items":[{"isbn":"9780441172719","title":"Dune","quantity":1,"unitPrice":{"amount":489.00,"currency":"TRY"}},{"isbn":"9780141439587","title":"Emma","quantity":2,"unitPrice":{"amount":249.90,"currency":"TRY"}}],"total":{"amount":988.80,"currency":"TRY"}}
$ curl -s -o /dev/null -w '%{http_code}\n' -H "Authorization: Bearer ada" http://127.0.0.1:52318/customers/grace/cart
404
Örneklerdeki Authorization: Bearer ada gerçek bir token değil; uygulamadaki küçük bir demo authentication handler'ı, Bearer'dan sonraki adı kullanıcı olarak kabul ediyor. Gerçek JWT (JSON Web Token; imzalı kimlik bilgisi taşıyan token formatı) doğrulaması bu serinin konusu değil, API Güvenliği serisinde anlatılıyor. İkinci soru ise kimlik formatı.
Naming ve Kimlik Formatı
Bookstore'un style guide'ına bu yazıyla gelen kurallar şunlar:
## Resources and URLs
- BS-01 [lint] Path segments are plural nouns in lowercase kebab-case; path parameters are camelCase.
- BS-02 [lint] No CRUD verbs in paths; the HTTP method is the verb.
- BS-03 [lint] A one-shot state change that is not CRUD is a custom method: POST /{collection}/{id}:{verb} (ADR-0002).
- BS-04 Something with its own lifecycle or approval (a return, an approval request) is a resource, not a custom method.
- BS-05 [lint] At most two path parameters (one level of nesting); deeper hierarchies are query filters.
- BS-06 Every resource has exactly one canonical URL; other collections that list it are filtered views.
- BS-07 Ids: ISBN-13 for books, UUID v7 for orders and returns, lowercase slugs for institutions and units; "me" is an alias for the caller.
Çoğul isim tartışması en kolayı: /books collection'ı, /books/{isbn} o collection'ın bir üyesi. /book/{isbn} ile /books'u karıştırmak istemcinin URL kurarken her seferinde dokümana bakması demek. Path için kebab-case, JSON ve query parametreleri için camelCase seçtik; Azure rehberi de aynı ayrımı yapar. Tutarlılık burada tercihin kendisinden daha önemli.
Kimlik formatı her resource için ayrı bir karar. Kitapta 13 haneli ISBN (kitaplara verilen uluslararası standart numara) kullanıyoruz: zaten herkesin bildiği, dışarıda da geçen, değişmeyen bir doğal anahtar. Yeni baskı yeni bir ISBN alır, bu da katalogda yeni bir kitap demek. Siparişte ise veritabanının int identity'si dışarı açılmıyor; ardışık sayı hem toplam sipariş adedini sızdırır hem de tahmin edilebilir. Çözüm Desenleri serisinin "Id'yi Dışarıya Açmak" yazısındaki yaklaşımla dışarıya bir UUID (Universally Unique Identifier; 128 bitlik benzersiz kimlik, .NET'teki adıyla Guid) veriyoruz, zamana göre sıralanan 7. sürümünü. Kurumlarda ise lakeside-school gibi okunabilir bir slug var. Slug'ın tuzağı, kurumun adı değiştiğinde onu da değiştirme isteği; ama slug bir kimlik, ad ise ayrı bir alan. Kurumun adı değişse de slug aynı kalır.
Hiyerarşi Derinliği ve Tek Kanonik URL
Şimdi tahmin sorusuna dönelim. Bookstore Ayşe'nin önerisini seçti: siparişin tek kanonik adresi /orders/{orderId}, kurum listesi ise bu collection'ın filtrelenmiş bir görünümü. Üç kullanıcının aynı siparişi istemesi:
$ curl -s -H "Authorization: Bearer nevin" http://127.0.0.1:52318/orders/01a1c112-d7c0-706c-8cb9-c18a8fadc1a6 | jq -c '{id, status, institutionId, unitId}'
{"id":"01a1c112-d7c0-706c-8cb9-c18a8fadc1a6","status":"Pending","institutionId":"lakeside-school","unitId":"main-campus"}
$ curl -s -o /dev/null -w '%{http_code}\n' -H "Authorization: Bearer linus" http://127.0.0.1:52318/orders/01a1c112-d7c0-706c-8cb9-c18a8fadc1a6
200
$ curl -s -o /dev/null -w '%{http_code}\n' -H "Authorization: Bearer grace" http://127.0.0.1:52318/orders/01a1c112-d7c0-706c-8cb9-c18a8fadc1a6
404
$ curl -s -o /dev/null -w '%{http_code}\n' -H "Authorization: Bearer grace" http://127.0.0.1:52318/orders/01a1c112-d7c0-706c-8cb9-c18a8fadc1a7
404
Grace için var olan siparişle var olmayan sipariş aynı cevabı alıyor. Bu bilinçli: 403 dönseydik "bu kimlikte bir sipariş var ama senin değil" demiş olurduk ve kimlikleri tek tek deneyen biri hangi siparişlerin var olduğunu öğrenebilirdi. HTTP method ve status code'larının anlamını tanımlayan standart (RFC 9110) da bir kaynağın varlığını gizlemek isteyen sunucunun 403 yerine 404 dönebileceğini açıkça söyler. Kurum listesi ise aynı görünürlük kuralını kullanıyor:
$ curl -s -H "Authorization: Bearer nevin" http://127.0.0.1:52318/institutions/lakeside-school/orders | jq -c '.items[] | {id, unitId, status}'
{"id":"01a1c112-d7c0-706c-8cb9-c18a8fadc1a6","unitId":"main-campus","status":"Pending"}
{"id":"01a12f4d-f2c0-73b8-a687-7991815ef6d1","unitId":"main-campus","status":"Shipped"}
{"id":"01a0a3f9-00c0-7392-af22-582a23b8c1e9","unitId":"main-campus","status":"Delivered"}
$ curl -s -H "Authorization: Bearer linus" http://127.0.0.1:52318/institutions/lakeside-school/orders | jq -c '.items[] | {id, unitId, status}'
{"id":"01a1c112-d7c0-706c-8cb9-c18a8fadc1a6","unitId":"main-campus","status":"Pending"}
{"id":"01a1a612-9c80-7e46-8590-67e08b9d2434","unitId":"east-campus","status":"Confirmed"}
{"id":"01a12f4d-f2c0-73b8-a687-7991815ef6d1","unitId":"main-campus","status":"Shipped"}
{"id":"01a0fbdc-1660-71a3-af67-19acad3c2d6d","unitId":"east-campus","status":"Delivered"}
{"id":"01a0a3f9-00c0-7392-af22-582a23b8c1e9","unitId":"main-campus","status":"Delivered"}
$ curl -s -H "Authorization: Bearer nevin" "http://127.0.0.1:52318/institutions/lakeside-school/orders?unitId=east-campus" | jq -c '{status, title, detail}'
{"status":403,"title":"Unit not allowed","detail":"Buyers can only list the orders of their own unit (main-campus)."}
$ curl -s -H "Authorization: Bearer grace" http://127.0.0.1:52318/institutions/lakeside-school/orders | jq -c '{status, title, detail}'
{"status":404,"title":"Institution not found","detail":"No institution lakeside-school exists."}
Burada 403 ile 404'ün ayrımı da tasarımın parçası. Nevin Lakeside'ın bir çalışanı; East Campus'ın var olduğunu zaten biliyor, ondan saklanacak bir şey yok, o yüzden "yetkin yok" demek (403) daha dürüst ve daha kullanışlı. Grace ise başka bir kurumdan; ona Lakeside'ın Bookstore müşterisi olduğunu bile söylemiyoruz (404).
Emre'nin önerisinin asıl sorunu derinlik değil, ikinci bir yol açması. Aynı sipariş iki URL'den erişilebilir olunca yetki kontrolü, cache anahtarı, log'lardaki URL ve contract'taki iki ayrı operation aynı şeyi iki kez anlatır. Bir gün biri yeni bir alanı yalnız bir yola ekler ya da yetki kontrolünü yalnız birinde günceller; API güvenliğindeki en yaygın açık olan BOLA (Broken Object Level Authorization; bir kullanıcının başkasına ait kaydı kimliğini değiştirerek okuyabilmesi) çoğu zaman böyle doğar. API Güvenliği serisi bu açığı ayrıntılı işliyor. URL'deki hiyerarşi yetkilendirme yapmaz; /institutions/lakeside-school/units/main-campus yazmak, isteği atanın o kampüse ait olduğunu kanıtlamaz. Kontrol her durumda sunucuda, tek bir kuralda yapılmalı.
Bu yüzden style guide'a iki kural ekledik: her resource'un tek kanonik URL'i var (BS-06) ve bir path'te en fazla iki parametre bulunur (BS-05). İkincisi lint ile kontrol ediliyor. Emre'nin yolunu contract'a ekleyince Spectral'in cevabı:
$ npx spectral lint contract/bookstore.openapi.yaml
/home/user/bookstore/contract/bookstore.openapi.yaml
264:65 error bookstore-max-two-path-params Too deep: /institutions/{institutionId}/units/{unitId}/orders/{orderId} paths./institutions/{institutionId}/units/{unitId}/orders/{orderId}
✖ 1 problem (1 error, 0 warnings, 0 infos, 0 hints)

Fiil Gerektiğinde: Sipariş İptali İçin Dört Seçenek
Her şey CRUD'a (Create, Read, Update, Delete; temel kayıt işlemleri) sığmıyor. Sipariş iptali bir alan güncellemesi değil, kuralları olan bir durum değişikliği: yalnız Pending ya da Confirmed siparişler iptal edilebilir, iptal stoğu serbest bırakır, ödeme iadesini başlatır. Masada dört seçenek var:
| Seçenek | Örnek | Artısı | Eksisi |
|---|---|---|---|
| Status alanını PATCH etmek | PATCH /orders/{id} ile {"status":"Cancelled"} | Yeni endpoint yok | Kural ve yan etkiler bir alan değişikliğine gizlenir; her status geçişi PATCH'te özel durum olur |
| Fiil alt path'i | POST /orders/{id}/cancel | Okunması kolay, her araçla çalışır | cancel bir sub-resource gibi görünür; GET ile ne döneceği belirsiz |
| Custom method | POST /orders/{id}:cancel | Google AIP-136 ve Azure rehberinin ortak kalıbı; resource ile işlemi ayırır | İki nokta üst üste bazı istemci ve shell'lerde sürpriz yapar |
| İşlemi resource yapmak | POST /orders/{id}/cancellations | Zalando'nun "fiil yok" kuralına uyar, geçmişi tutar | Anında biten bir işlem için gereksiz bir yaşam döngüsü |
Bookstore'un kuralı bir ayrıma dayanıyor: işlem tek seferde biten bir durum değişikliğiyse custom method, kendi yaşam döngüsü ya da onayı olan bir şey üretiyorsa resource. İptal anında biter: custom method. İade ise talep edilir, mağaza çalışanı onaylar ya da reddeder, sonra para iadesi yapılır; bu yüzden iade bir resource. Karar ADR (Architecture Decision Record; mimari karar kaydı) olarak:
# ADR-0002: Operations that need a verb
Status: Accepted (2026-11-15)
## Context
Cancelling an order is a state change with rules (only Pending or Confirmed orders),
not a field update. Options: PATCH status, POST /orders/{id}/cancel,
POST /orders/{id}:cancel (Google AIP-136, Azure guidelines) or a cancellation resource.
## Decision
- One-shot state changes use a custom method: POST /orders/{orderId}:cancel.
- Changes with their own lifecycle become resources: POST /orders/{orderId}/returns.
- status is read-only in every representation; it never changes through PUT or PATCH.
## Consequences
- The colon can never appear in our ids (UUIDs, ISBNs and slugs do not contain it).
- Clients that percent-encode the colon (%3A) reach the same endpoint.
- The kebab-case lint rule allows one :customMethod suffix at the end of a path.
Son madde gerçek bir deneyimden geldi. Custom method'u contract'a ilk eklediğimizde, bir önceki yazıda yazdığımız kural onu yakaladı:
$ npx spectral lint contract/bookstore.openapi.yaml
/home/user/bookstore/contract/bookstore.openapi.yaml
114:28 error bookstore-path-kebab-case Path is not kebab-case: /orders/{orderId}:cancel paths./orders/{orderId}:cancel
✖ 1 problem (1 error, 0 warnings, 0 infos, 0 hints)
Lint kuralı style guide'ın kodudur; style guide değişince kural da aynı pull request'te değişir. Kebab-case kuralına path'in sonunda tek bir :customMethod eki için izin verdik; Redocly CLI ise iki nokta üst üsteli path'i ilk denemeden geçerli saydı.
Custom Method'u Denemek
$ curl -i -X POST -H "Authorization: Bearer nevin" http://127.0.0.1:52318/orders/01a1c112-d7c0-706c-8cb9-c18a8fadc1a6:cancel
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Date: Sat, 14 Nov 2026 22:28:37 GMT
Server: Kestrel
Transfer-Encoding: chunked
{"id":"01a1c112-d7c0-706c-8cb9-c18a8fadc1a6","status":"Cancelled","institutionId":"lakeside-school","unitId":"main-campus","createdAt":"2026-11-09T16:10:00Z","items":[{"isbn":"9780451524935","title":"Nineteen Eighty-Four","quantity":40,"unitPrice":{"amount":197.10,"currency":"TRY"}}],"total":{"amount":7884.00,"currency":"TRY"}}
$ curl -s -X POST -H "Authorization: Bearer nevin" http://127.0.0.1:52318/orders/01a1c112-d7c0-706c-8cb9-c18a8fadc1a6:cancel | jq -c '{status, title, detail}'
{"status":409,"title":"Order cannot be cancelled","detail":"Order 01a1c112-d7c0-706c-8cb9-c18a8fadc1a6 is Cancelled; only Pending or Confirmed orders can be cancelled."}
$ curl -s -o /dev/null -w '%{http_code}\n' -X POST -H "Authorization: Bearer alan" http://127.0.0.1:52318/orders/01a1a612-9c80-7e46-8590-67e08b9d2434%3Acancel
200
$ curl -s -o /dev/null -w '%{http_code}\n' -H "Authorization: Bearer alan" http://127.0.0.1:52318/orders/01a1a612-9c80-7e46-8590-67e08b9d2434:cancel
404
Dört çıktının her biri bir karar. Başarılı iptal, Azure rehberinin de istediği gibi 200 ve güncel siparişle dönüyor. İkinci iptal 409: işlem siparişin mevcut durumuyla çakışıyor. %3A ile kodlanmış iki nokta üst üste aynı endpoint'e ulaşıyor; bazı HTTP library'leri path'teki iki nokta üst üsteyi böyle kodlar ve sunucunun ikisini de kabul etmesi gerekir. GET ile :cancel ise 404, çünkü custom method yalnız POST ile tanımlı.
İki nokta üst üstenin bir sürprizi de shell'de. Kimliği bir değişkende tutup URL'i "$id:cancel" diye kurarsan zsh :c'yi bir modifier sanıp 01a1ancel üretiyor; PowerShell ise $id:cancel'ı scope'lu bir değişken olarak okuyup boş string veriyor. Bash'te sorun yok. Her iki shell'de de doğru yazım "${id}:cancel". Bunu contract'ın örneklerine ve README'ye not düşmek, partner'ın ilk denemede vakit kaybetmesini önler.
İadenin resource olarak modellenmesi ise başka bir akış veriyor:
$ curl -i -X POST -H "Authorization: Bearer nevin" -H "Content-Type: application/json" -d '{"reason":"Damaged covers on 3 copies"}' http://127.0.0.1:52318/orders/01a0a3f9-00c0-7392-af22-582a23b8c1e9/returns
HTTP/1.1 201 Created
Content-Type: application/json; charset=utf-8
Date: Sat, 14 Nov 2026 22:28:37 GMT
Server: Kestrel
Location: /orders/01a0a3f9-00c0-7392-af22-582a23b8c1e9/returns/01a1dc2d-4a55-7d20-ad3b-34c8bc0bf72b
Transfer-Encoding: chunked
{"id":"01a1dc2d-4a55-7d20-ad3b-34c8bc0bf72b","orderId":"01a0a3f9-00c0-7392-af22-582a23b8c1e9","status":"Requested","reason":"Damaged covers on 3 copies","requestedAt":"2026-11-14T22:28:38.101163Z"}
$ curl -s -X POST -H "Authorization: Bearer nevin" -H "Content-Type: application/json" -d '{"reason":"Wrong edition"}' http://127.0.0.1:52318/orders/01a12f4d-f2c0-73b8-a687-7991815ef6d1/returns | jq -c '{status, title, detail}'
{"status":409,"title":"Order cannot be returned","detail":"Order 01a12f4d-f2c0-73b8-a687-7991815ef6d1 is Shipped; only Delivered orders can be returned."}
İade talebinin artık kendi adresi var (Location), kendi durumu var (Requested) ve mağaza çalışanının onay ekranı bu adresi kullanacak. 201 ile Location'ın ve 409'un ayrıntılı kuralları bir sonraki yazının konusu.
ASP.NET Core'da
Custom method için route template'te complex segment kullanılıyor: orders.MapPost("/{orderId:guid}:cancel", CancelOrder). Burada ilk iki nokta üst üste route constraint'ini (:guid), süslü parantezden sonraki ise literal :cancel'ı ifade ediyor; routing %3A'yı path'i eşleştirmeden önce çözdüğü için kodlanmış istek de aynı endpoint'e düşüyor. Kanonik URL ve kurum listesi aynı OrderData.CanSee(user, order) fonksiyonunu çağırıyor; görünmeyen ve var olmayan sipariş için tek bir OrderNotFound sonucu var. Route template ve constraint'lerin ayrıntısı ASP.NET Core Web API serisinin "ASP.NET Core Web API'de Routing: Attribute Routing, Route Template ve Constraint'ler" yazısında.
Sık Düşülen Tuzaklar
RPC (Remote Procedure Call; uzaktaki bir fonksiyonu çağırır gibi istek atmak) tarzı URL'ler. /getBooks, /orders/createOrder, /books/search-by-author gibi path'ler method'u URL'e taşır. Lint kuralı BS-02 ilk ikisini yakalar; üçüncüsü aslında /books?author=… filtresidir.
İç içe dört ve daha fazla seviye. /institutions/{id}/units/{unitId}/buyers/{buyerId}/orders/{orderId}/items gibi yollar hem istemcinin bilmediği kimlikleri ister hem de veritabanı ilişkisini contract'a kopyalar. Kampüs değiştiren bir alıcı bütün URL'lerini kaybeder.
Aynı resource'a birden çok kanonik yol. Takma adlar (me gibi) kabul edilebilir, ama yanıtta hep tek ve gerçek kimlik döner. İkinci bir tam yol açmak, yetki kontrolünü ve cache'i ikiye böler.
İsimlerin içine fiil saklamak. /order-cancellation-requests resource gibi görünür, ama talep anında işlenip kayboluyorsa bu sadece adı uzatılmış bir fiildir. Ölçüt adın biçimi değil, şeyin kendi yaşam döngüsü olup olmadığı.
Veritabanı kimliğini URL'e koymak. /orders/1042 hem sipariş hacmini gösterir hem de 1041'i denemeye davet eder; görünürlük kontrolün bir gün eksik kalırsa veri sızar.
Küçük Bir Deneme
★☆☆ /customers/me/cart ile /customers/ada/cart'ı Ada olarak iste ve yanıtları karşılaştır. Sonra Ada olarak /customers/grace/cart'ı iste. Neden 403 değil de 404?
★★☆ Bookstore'a kurumsal satın alma onayı (PurchaseApproval) geliyor: alıcı bir sipariş için onay ister, onay yetkilisi kabul ya da reddeder. Bunu POST /orders/{orderId}:approve olarak mı, yoksa ayrı bir resource olarak mı tasarlarsın? Seçtiğin URL'leri ve status code'ları contract'a ekle, npm run lint:contract ile kontrol et.
★★★ Spectral'e kanonik URL kuralı için yardımcı bir kontrol yaz: responses içinde aynı schema'yı (#/components/schemas/Order) tek bir resource olarak dönen birden fazla path varsa uyarı versin. Kuralın kurum listesini (bir collection döndürüyor) yanlışlıkla işaretlemediğinden emin ol.
Resource'larımız ve adresleri belli oldu. Bir sonraki yazıda bu adreslere hangi method'la gelindiğine ve sunucunun hangi status code'la cevap verdiğine bakıyoruz: PUT mu PATCH mı, 400 mü 422 mi, 409 mu 412 mi.