Bookstore'un Teknik Yapısı: Module'ler, Veri Modeli, Web API ve Angular İstemcisi
Bookstore'un referans uygulaması tek bir ASP.NET Core 10 API'sinde Catalog, Ordering ve Inventory module'lerini, her module için ayrı SQL schema'sı olan tek bir BookstoreContext'i, xUnit v3 integration testlerini ve Angular 22 istemcisini içerir. Module haritasını, veri modelini, endpoint'leri, eşzamanlı siparişte deadlock veren ilk stok kodunu ve onu düzelten koşullu UPDATE'i, Windows'ta çalıştırma adımlarını anlatıyoruz.
Sürüm notu: Bu yazıdaki kodlar C# 14, .NET 10, Entity Framework Core (EF Core) 10, SQL Server 2025 ve Angular 22 (TypeScript 6.0) ile derlenip denenmiştir.
Önce Tahmin Et: Son Üç War and Peace
Sitedeki serilerin ortak örnek uygulaması Bookstore (bir online kitapçı) için referans uygulamayı yazarken stok rezervasyonunun ilk hâli şuydu. Inventory module'ü stok satırlarını okuyor, yeterli mi diye bakıyor, yeterliyse ayrılan miktarı artırıyor. Kaydetmeyi siparişle birlikte tek bir SaveChangesAsync yapıyor, yani her şey tek transaction'da:
public async Task<ReservationResult> ReserveAsync(IReadOnlyCollection<StockLine> lines, CancellationToken ct)
{
int[] bookIds = [.. lines.Select(l => l.BookId)];
Dictionary<int, StockItem> stock = await db.StockItems
.Where(s => bookIds.Contains(s.BookId))
.ToDictionaryAsync(s => s.BookId, ct);
List<int> unavailable =
[
.. lines
.Where(l => !stock.TryGetValue(l.BookId, out StockItem? item) || item.Available < l.Quantity)
.Select(l => l.BookId)
];
if (unavailable.Count > 0)
{
return new ReservationResult(false, unavailable);
}
foreach (StockLine line in lines)
{
stock[line.BookId].QuantityReserved += line.Quantity;
}
// Kaydetme işini çağıran taraf (OrderService) siparişle birlikte yapar.
return new ReservationResult(true, []);
}
StockItem'da bir de RowVersion kolonu vardı ve OrderService, DbUpdateConcurrencyException'ı yakalayıp 409 dönüyordu. Seed verisinde War and Peace'ten stokta 3 kitap var. Grace ve Linus aynı anda 2'şer kitaplık sipariş veriyor. Ne olur? İkisi de geçer mi, biri 409 mu alır, yoksa başka bir şey mi olur?
Module'ler ve Sorumlulukları
Bookstore'un mimari parçaları Mimariye Giriş serisinde belirlendi ve bütün seriler aynı adları kullanıyor. Her module bir namespace, birkaç ana tip ve yayınladığı event'lerle tanımlı:
| Module | Namespace | Sorumluluk | Event'ler |
|---|---|---|---|
| Catalog | Bookstore.Catalog | Kitap, yazar, kategori, arama, fiyat | BookPriceChanged |
| Ordering | Bookstore.Ordering | Sepet, sipariş, müşteri, sipariş durumu | OrderPlaced, OrderCancelled |
| Inventory | Bookstore.Inventory | Stok, rezervasyon, depo girişleri | StockReserved, OutOfStock |
| Payments | Bookstore.Payments | Ödeme sağlayıcısıyla authorize, capture, refund | PaymentCaptured, PaymentFailed |
| Shipping | Bookstore.Shipping | Kargo etiketi, takip numarası | OrderShipped |
| Notifications | Bookstore.Notifications | Sipariş ve kargo e-postası, SMS (kısa mesaj) | Yok |
| Institutional | Bookstore.Institutional | Kurum, birim hiyerarşisi, kurum kullanıcıları, satın alma onayı | Seriler tanımlar |
| Identity | Bookstore.Identity | Kullanıcı ve roller | Seriler tanımlar |
Referans uygulamada bunlardan yalnız üçü var: Catalog, Ordering ve Inventory. Bir siparişin uçtan uca çalışması için gereken en küçük çekirdek bu. Diğer module'ler onları anlatan serilerin branch'lerinde ekleniyor. Aşağıdaki figürün sağ tarafı bu üç module'ün SQL Server'daki karşılığını, yani ER (Entity-Relationship; tablolar ve aralarındaki ilişkiler) özetini gösteriyor.

Repo'nun main Branch'i: Klasörler ve Çalıştırma
Referans uygulama repo'nun main branch'i. Düzen şöyle:
Bookstore.slnx
global.json
Directory.Build.props
Directory.Packages.props
dotnet-tools.json
README.md
src/Bookstore.Api/
Catalog/
Ordering/
Inventory/
Data/
Program.cs
tests/Bookstore.Api.Tests/
web/bookstore-web/
global.json .NET 10 SDK'yı ve test runner'ı sabitliyor. Directory.Build.props bütün projelere net10.0, Nullable, ImplicitUsings ve "uyarılar hatadır" ayarını veriyor; Directory.Packages.props paket sürümlerini tek yerde topluyor (Central Package Management). dotnet-tools.json içinde local tool olarak dotnet-ef var. API projesinde her module bir klasör: Catalog'da Book, Author, Category ve seed verisi, Ordering'de Cart, Order, Customer ve OrderService, Inventory'de StockItem ve InventoryService, Data'da BookstoreContext ve migration'lar.
Tek bir API projesi var ve module'ler bu projenin içinde klasör ve namespace olarak ayrılıyor. Her module kendi AddXModule ve MapXEndpoints extension method'larını sunuyor; Program.cs yalnız bunları çağırıyor. Ayrı proje yerine klasörle başlamak bilinçli: Mimariye Giriş serisinde Bookstore'un monolith olarak başlaması gerekçelendirildi, module'leri ayrı projelere taşımak Modular Monolith serisinin konusu.
Okurların Windows/x64 kullandığını varsayıyoruz. Gerekenler: .NET 10 SDK, Node.js 24 LTS (Long Term Support; uzun süre destek alan sürüm) ve Docker Desktop. Angular 22'nin package.json'daki engines alanı Node.js için ^22.22.3 || ^24.15.0 || >=26.0.0 istiyor. PowerShell'de:
# 1. SQL Server 2025'i container olarak başlat
docker run -e "ACCEPT_EULA=Y" -e "MSSQL_SA_PASSWORD=<YourStrong!Passw0rd>" `
-p 1433:1433 --name bookstore-sql -d mcr.microsoft.com/mssql/server:2025-latest
# 2. Connection string'i repoya değil user-secrets'a yaz
dotnet user-secrets set "ConnectionStrings:BookstoreDb" `
"Server=localhost,1433;Database=BookstoreDb;User Id=sa;Password=<YourStrong!Passw0rd>;TrustServerCertificate=True" `
--project src/Bookstore.Api
# 3. API (Development'ta migration ve seed açılışta uygulanır)
dotnet run --project src/Bookstore.Api
# 4. İkinci terminalde Angular istemcisi
cd web/bookstore-web
npm ci
npm start
# 5. Testler
dotnet test
API açılırken migration'ı uygular ve http://localhost:5080'i dinler:
info: Microsoft.EntityFrameworkCore.Migrations[20402]
Applying migration '20261114005447_InitialCreate'.
info: Microsoft.Hosting.Lifetime[14]
Now listening on: http://localhost:5080
Angular istemcisi http://localhost:4200'de açılır. appsettings.json'daki BookstoreDb bilerek boş: connection string verilmezse API, nereye bakılacağını söyleyen bir mesajla açılmadan durur. Şifreyi bir kez bile commit'lemek, onu repo geçmişine kalıcı olarak yazmak demek.
Veri Modeli: BookstoreContext, Schema'lar ve Seed
Bütün module'ler tek bir DbContext kullanıyor, ama her module'ün tabloları kendi SQL schema'sında:
// Referans uygulamada tek context; her module kendi SQL schema'sına map edilir (catalog, ordering, inventory).
public sealed class BookstoreContext(DbContextOptions<BookstoreContext> options) : DbContext(options)
{
public DbSet<Book> Books => Set<Book>();
public DbSet<Author> Authors => Set<Author>();
public DbSet<Category> Categories => Set<Category>();
public DbSet<StockItem> StockItems => Set<StockItem>();
public DbSet<Cart> Carts => Set<Cart>();
public DbSet<Customer> Customers => Set<Customer>();
public DbSet<Order> Orders => Set<Order>();
protected override void OnModelCreating(ModelBuilder modelBuilder) =>
modelBuilder.ApplyConfigurationsFromAssembly(typeof(BookstoreContext).Assembly);
// Bütün zamanlar UTC saklanır; okunan değerler de UTC olarak işaretlenir.
protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder) =>
configurationBuilder.Properties<DateTime>().HaveConversion<UtcDateTimeConverter>();
private sealed class UtcDateTimeConverter() : ValueConverter<DateTime, DateTime>(
v => v, v => DateTime.SpecifyKind(v, DateTimeKind.Utc));
}
Mapping'ler module klasörlerindeki IEntityTypeConfiguration<T> class'larında. Module'ler arası ilişkiye bir örnek Inventory'deki stok satırı:
stock.ToTable("StockItems", "inventory", t =>
{
t.HasCheckConstraint("CK_StockItems_Quantities", "[QuantityReserved] >= 0 AND [QuantityReserved] <= [QuantityOnHand]");
});
stock.HasIndex(s => s.BookId).IsUnique();
// Foreign key veritabanında var, ama module'ler arasında navigation property yok.
stock.HasOne<Book>().WithOne().HasForeignKey<StockItem>(s => s.BookId);
StockItem bir kitabı yalnız BookId ile tanıyor; stockItem.Book.Title diye Catalog'un içine giremiyor. Bir module ötekinden bilgi istediğinde bunu bir interface üzerinden yapıyor: Ordering kitap fiyatını ICatalogService'ten, stok durumunu IInventoryService'ten alıyor. Veritabanındaki foreign key ise veri bütünlüğünü koruyor. Bu ayrım, ileride bir module'ü ayrı bir servise taşımak istediğinde nereden keseceğini baştan belli ediyor.
Seed verisi HasData ile modelin parçası, yani ilk migration'ın içinde: 4 kategori, 18 yazar, 20 klasik kitap ve her kitap için bir stok satırı. Don Quixote'un stoğu 0, War and Peace'in 3; diğerleri 25. Böylece stok tükenme yolları ilk çalıştırmada görülebiliyor. Migration uygulandıktan sonra veritabanı:
Schema Table Rows
---------- ------------ --------------------
catalog Authors 18
catalog Books 20
catalog Categories 4
inventory StockItems 20
ordering CartItems 0
ordering Carts 0
ordering Customers 0
ordering OrderItems 0
ordering Orders 0
İsim sözleşmeleri bütün serilerde aynı. Tablo adları İngilizce ve çoğul (Books, OrderItems), schema adı module adının küçük harflisi. Para decimal ve veritabanında decimal(10,2), toplamlar decimal(12,2). ISBN (kitaplara verilen uluslararası standart numara) varchar(13) ve unique. Zaman kolonları Utc son ekiyle biter ve UTC (Coordinated Universal Time; saat diliminden bağımsız ortak saat) tutar. Enum'lar veritabanına string olarak yazılır: Orders.Status kolonunda 2 değil Placed görürsün. Sepet id'si Guid.CreateVersion7() ile üretilen zamana göre sıralı bir Guid; sipariş id'si ise int.
Web API: Endpoint'ler ve Hata Cevapları
API, Minimal API ile yazıldı ve endpoint'ler module'lere göre gruplandı:
| Method | Yol | Ne yapar |
|---|---|---|
| GET | /api/books?search=&category=&page=&pageSize= | Katalog listesi ve arama |
| GET | /api/books/{isbn} | Kitap detayı ve satılabilir stok |
| GET | /api/categories | Kategoriler |
| POST | /api/carts | Yeni sepet |
| GET | /api/carts/{cartId} | Sepet |
| PUT | /api/carts/{cartId}/items/{isbn} | Kitap ekler ya da adedini değiştirir |
| DELETE | /api/carts/{cartId}/items/{isbn} | Kitabı sepetten çıkarır |
| POST | /api/orders | Sipariş verir |
| GET | /api/orders/{orderId} | Sipariş |
Catalog'un endpoint'leri tek bir method'da toplanıyor:
public static IEndpointRouteBuilder MapCatalogEndpoints(this IEndpointRouteBuilder app)
{
RouteGroupBuilder catalog = app.MapGroup("/api").WithTags("Catalog");
catalog.MapGet("/books", SearchBooks);
catalog.MapGet("/books/{isbn}", GetBook);
catalog.MapGet("/categories", GetCategories);
return app;
}
İstek modelleri record ve doğrulama kuralları DataAnnotations attribute'larıyla yazıldı. .NET 10'da Minimal API'ler için gelen doğrulama, builder.Services.AddValidation() çağrısıyla açılıyor; Microsoft Learn'deki ASP.NET Core 10 yenilikleri sayfasına göre bu özellik bir source generator ile çalışıyor ve hata olduğunda handler hiç çalışmadan 400 dönüyor:
public sealed record SetQuantityRequest([property: Range(1, 99)] int Quantity);
public sealed record PlaceOrderRequest(
Guid CartId,
[property: Required, StringLength(200)] string CustomerName,
[property: Required, EmailAddress, StringLength(256)] string CustomerEmail);
Sepete 2 Dune ve 1 Emma koyup Emma'nın adedini 0 yapmaya çalışınca, sonra da siparişi verince API'nin gerçek cevapları:
PUT /api/carts/01a1d78e-309d-72ce-9975-9d9907f5463f/items/9780141439587 { "quantity": 0 }
{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.1","title":"One or more validation errors occurred.","status":400,"errors":{"Quantity":["The field Quantity must be between 1 and 99."]},"traceId":"00-61aa18975a2e24017c54864eaffa96bc-c80599baa854a47f-00"}
POST /api/orders { "cartId": "01a1d78e-309d-72ce-9975-9d9907f5463f", "customerName": "Ada Lovelace", "customerEmail": "ada@example.com" }
HTTP/1.1 201 Created
Location: http://localhost:5080/api/orders/2
{"id":2,"status":"Placed","placedAtUtc":"2026-11-14T00:56:22.532117Z","items":[{"title":"Dune","unitPrice":489.00,"quantity":2,"lineTotal":978.00},{"title":"Emma","unitPrice":249.90,"quantity":1,"lineTotal":249.90}],"total":1227.90,"currency":"TRY"}
Hatalar her zaman ProblemDetails biçiminde dönüyor: Program.cs'te AddProblemDetails(), UseExceptionHandler() ve UseStatusCodePages() var. Bu yüzden Angular istemcisi 400'ü de 409'u da aynı şekilde okuyabiliyor.
Sipariş Verirken Ne Oluyor?
Önce Tahmin Et'teki kodla iki siparişi aynı anda gönderdiğimde biri 201 aldı, öteki 500. Log'da şu vardı:
System.InvalidOperationException: An exception has been raised that is likely due to a transient failure. Consider enabling transient error resiliency by adding 'EnableRetryOnFailure' to the 'UseSqlServer' call.
---> Microsoft.EntityFrameworkCore.DbUpdateException: An error occurred while saving the entity changes. See the inner exception for details.
---> Microsoft.Data.SqlClient.SqlException (0x80131904): Transaction (Process ID 58) was deadlocked on lock resources with another process and has been chosen as the deadlock victim. Rerun the transaction.
İki istek de stoğu 3 olarak okudu, ikisi de "yeterli" kararını C# tarafında verdi. Sonra iki transaction aynı kaynakları farklı sırayla kilitlemeye çalıştı ve SQL Server birini deadlock kurbanı seçti. RowVersion hiç devreye girmedi, çünkü hata bir concurrency çakışması olarak değil, deadlock olarak geldi; yakalanmayan exception da 500'e dönüştü. Stok yine de doğru kaldı, ama müşteri "stok yok" yerine anlamsız bir hata gördü.
Düzeltilmiş hâlde kontrolü ve artırmayı veritabanı tek bir koşullu UPDATE ile yapıyor:
public async Task<ReservationResult> ReserveAsync(IReadOnlyCollection<StockLine> lines, CancellationToken ct)
{
List<int> unavailable = [];
// Her kitap için tek bir koşullu UPDATE; hep BookId sırasıyla, böylece eşzamanlı siparişler satırları aynı sırayla kilitler.
foreach (StockLine line in lines.OrderBy(l => l.BookId))
{
int updated = await db.StockItems
.Where(s => s.BookId == line.BookId && s.QuantityOnHand - s.QuantityReserved >= line.Quantity)
.ExecuteUpdateAsync(s => s.SetProperty(x => x.QuantityReserved, x => x.QuantityReserved + line.Quantity), ct);
if (updated == 0)
{
unavailable.Add(line.BookId);
}
}
return new ReservationResult(unavailable.Count == 0, unavailable);
}
EF Core'un ürettiği SQL:
UPDATE [s]
SET [s].[QuantityReserved] = [s].[QuantityReserved] + @line_Quantity
FROM [inventory].[StockItems] AS [s]
WHERE [s].[BookId] = @line_BookId AND [s].[QuantityOnHand] - [s].[QuantityReserved] >= @line_Quantity
Satırı kilitleyen ilk transaction güncellemeyi yapıyor, ikincisi kilidin açılmasını bekliyor ve koşulu yeni değerle değerlendiriyor: satılabilir 1 kitap kaldığı için 0 satır güncelleniyor ve sonuç 409 oluyor. OrderService rezervasyonu, siparişi ve sepetin silinmesini tek bir explicit transaction içinde yapıyor; rezervasyon başarısız olursa transaction commit edilmeden dispose ediliyor ve o ana kadar ayrılan stoklar geri alınıyor. Önce Tahmin Et'in cevabı artık beklenen şey: biri 201, öteki "Not enough stock for: 9781400079988." detayıyla 409. Aynı denemeyi aynı anda gönderilen 30 siparişle (her biri üç farklı kitaptan birer adet, kitap sırası karışık, her kitaptan stokta 25 adet) tekrarladığımda 25 sipariş 201, 5 sipariş 409 aldı ve log'da hiç hata kalmadı.
Endpoint tarafında OrderService'in sonucu bir record hiyerarşisi ve switch expression ile HTTP cevabına çevriliyor:
return outcome switch
{
PlaceOrderOutcome.Placed placed =>
TypedResults.CreatedAtRoute(ToView(placed.Order), "GetOrder", new { orderId = placed.Order.Id }),
PlaceOrderOutcome.CartNotFound => TypedResults.NotFound(),
PlaceOrderOutcome.CartEmpty =>
TypedResults.Problem("The cart is empty.", statusCode: StatusCodes.Status400BadRequest),
PlaceOrderOutcome.OutOfStock outOfStock =>
TypedResults.Problem(
$"Not enough stock for: {string.Join(", ", outOfStock.Isbns)}.",
statusCode: StatusCodes.Status409Conflict),
PlaceOrderOutcome.CartChanged =>
TypedResults.Problem("The cart changed while the order was being placed.",
statusCode: StatusCodes.Status409Conflict),
_ => throw new UnreachableException()
};
Sonuç tipi bir abstract record ve onun içindeki nested record'lardan oluşuyor. Yeni bir sonuç eklenip switch'e kolu eklenmezse kod yine derlenir, hata ancak çalışırken UnreachableException olarak çıkar; derleyici burada seni uyarmaz.
Testler: WebApplicationFactory ve Ayrı Veritabanı
Bookstore.Api.Tests projesi xUnit v3 ile yazıldı ve testler API'yi gerçek bir SQL Server'a karşı uçtan uca çağırıyor. Bütün test koşusu için tek bir host ve sıfırdan kurulan tek bir veritabanı var; xUnit v3'ün assembly fixture'ı bunu sağlıyor:
[assembly: AssemblyFixture(typeof(Bookstore.Api.Tests.BookstoreApiFactory))]
namespace Bookstore.Api.Tests;
// Bütün test koşusu için tek API host'u ve sıfırdan kurulan tek veritabanı.
// Veritabanı, development veritabanının "_Tests" son ekli hâli: BookstoreDb → BookstoreDb_Tests.
public sealed class BookstoreApiFactory : WebApplicationFactory<Program>, IAsyncLifetime
{
private readonly string connectionString = TestConnectionString();
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.UseSetting("ConnectionStrings:BookstoreDb", connectionString);
builder.UseSetting("Database:MigrateOnStartup", "false");
}
public async ValueTask InitializeAsync()
{
await using AsyncServiceScope scope = Services.CreateAsyncScope();
BookstoreContext db = scope.ServiceProvider.GetRequiredService<BookstoreContext>();
await db.Database.EnsureDeletedAsync();
await db.Database.MigrateAsync();
}
// TestConnectionString: user-secrets ve environment variable'lardan okur, veritabanı adına "_Tests" ekler.
}
Program.cs'in sonunda public partial class Program; satırı yok. .NET 10'da ASP.NET Core'un source generator'ı top-level statement kullanan uygulamalar için bu bildirimi kendisi üretiyor; test projesi WebApplicationFactory<Program>'ı bu satır olmadan derledi.
On testin üçü Catalog'u, dördü Ordering'i, üçü yuvarlama kuralını sınıyor. Ordering testlerinden biri Önce Tahmin Et'teki durumun kendisi: iki siparişi Task.WhenAll ile aynı anda gönderiyor ve biri 201, öteki 409 almalı, stokta 1 kitap kalmalı. global.json'da test runner olarak Microsoft.Testing.Platform seçili. Bu ayar olmadan xunit.v3 4.x paketiyle dotnet test, .NET 10 SDK'da VSTest yolunun artık desteklenmediğini söyleyen bir hatayla duruyor.
dotnet test
Test run summary: Passed!
total: 10
failed: 0
succeeded: 10
skipped: 0
duration: 1s 902ms
Angular İstemcisi: bookstore-web
web/bookstore-web, Angular CLI 22 ile oluşturulmuş standalone bir uygulama. Üç sayfa var: katalog, kitap detayı ve sepet. Angular 22.2 projeleri TypeScript 6.0 istiyor; proje TypeScript ~6.0.2 ile geliyor. Route'lar:
export const routes: Routes = [
{ path: '', component: CatalogPage, title: 'Bookstore' },
{ path: 'books/:isbn', component: BookDetailPage, title: 'Book · Bookstore' },
{ path: 'cart', component: CartPage, title: 'Cart · Bookstore' },
{ path: '**', redirectTo: '' },
];
Okumalar Angular 22'de stable olan httpResource ile yapılıyor. Katalog sayfası arama ve kategori için iki signal tutuyor; signal'lardan biri değişince istek kendiliğinden yeniden gidiyor:
export class CatalogPage {
protected readonly cartStore = inject(CartStore);
protected readonly search = signal('');
protected readonly category = signal('');
protected readonly categories = httpResource<CategoryView[]>(() => '/api/categories', { defaultValue: [] });
// search ya da category değiştiğinde istek kendiliğinden yeniden gider.
protected readonly books = httpResource<PagedResult<BookSummary>>(() => ({
url: '/api/books',
params: { search: this.search(), category: this.category(), pageSize: 50 },
}));
protected applySearch(event: Event, value: string): void {
event.preventDefault();
this.search.set(value.trim());
}
}
Yazma işlemleri (sepete ekleme, adet değiştirme, sipariş) CartStore adlı root service'te, HttpClient ve async/await ile. Sepetin kendisi API'de duruyor; tarayıcı yalnız sepet id'sini localStorage'da hatırlıyor. Kitap detayı sayfası ISBN'i route parametresinden input.required<string>() ile alıyor (withComponentInputBinding()).
İstemci bütün istekleri /api/... gibi göreli yollarla yapıyor. Geliştirmede Angular dev server bunları API'ye proxy'liyor, böylece CORS (Cross-Origin Resource Sharing; tarayıcının başka origin'e yapılan istekleri kısıtlaması) ayarına gerek kalmıyor:
{
"/api": {
"target": "http://localhost:5080",
"secure": false
}
}
İstemci ile API'nin gerçekten konuştuğunu Playwright ile headless bir tarayıcıda denedim: katalogda 20 kitap, "tolkien" aramasında 1 kitap, The Hobbit detayında "25 in stock", 2 adet sepete eklenince başlıkta "Cart (2)", sepette "Total: ₺659.80", sipariş sonrası "Order #1 placed (Placed)", Don Quixote'ta "Out of stock" ve The Hobbit'te "23 in stock" göründü; tarayıcı console'unda hata yoktu.
Seriler Bu Çekirdeği Nasıl Genişletiyor?
Referans uygulama küçük tutuldu, çünkü her seri onu kendi yönünde büyütüyor ve her serinin değişikliği kendi branch'inde okunabilir kalmalı. Birkaç örnek yön:
Mimari tarafta Modular Monolith serisi module sınırlarını ayrı projelere ve kurallara taşıyor; Microservices serisi aynı module'leri ayrı servislere bölmenin bedelini tartışıyor, .NET ile Mikroservis Uygulaması serisi bunu kodla yapıyor. CQRS (Command Query Responsibility Segregation; yazma ve okuma modellerini ayırma) ve Event Sourcing serisi sipariş tarafını ayrı yazma ve okuma modelleriyle ele alıyor. Messaging ve Event-Driven Architecture serisi OrderPlaced gibi event'leri gerçekten yayınlıyor ve outbox ekliyor.
Uygulama tarafında Caching serisi katalog aramasının önüne cache koyuyor, Authentication ve Authorization serisi Identity module'ünü ve rolleri getiriyor, testleri E2E (end-to-end; kullanıcı akışını baştan sona deneyen test) seviyesine taşıyan Integration ve E2E Test serisi buradaki testleri genişletiyor, Angular'da State Yönetimi serisi CartStore'un yerini tartışıyor. Container'a almak ilerideki Docker ve Docker Compose serilerinin, aramayı ayrı bir arama motoruna taşımak ilerideki Elasticsearch ile Arama serisinin konusu.
Ortak kural şu: bir seri çekirdekteki bir adı değiştirmiyor. Book, Order, StockItem, schema adları ve endpoint yolları seriler arasında aynı kalıyor; seri yeni bir şey ekliyorsa yeni bir ad veriyor.
Sık Düşülen Tuzaklar
Kontrolü uygulamada, yazmayı veritabanında yapmak. Önce Tahmin Et'teki kod tek kullanıcıyla hiç hata vermedi; sorun yalnız eşzamanlı istekte çıktı. "Oku, karar ver, yaz" desenini paylaşılan bir sayaç için kullanıyorsan karar da veritabanında, aynı ifadede verilmeli.
Module'ler arasında navigation property açmak. StockItem.Book eklemek ilk gün kolaylık sağlar, ama Inventory'yi Catalog'un class'larına bağlar ve module'ü ayırmak istediğinde ilk kırılan yer burası olur.
Connection string'i appsettings.json'a şifresiyle yazmak. Repo'da yalnız boş bir değer var; şifre user-secrets'ta ya da environment variable'da duruyor.
Testleri development veritabanına karşı koşturmak. Factory veritabanını önce siliyor; aynı adı kullansaydı her dotnet test çalıştığında yerel verin kaybolurdu. Bu yüzden ad _Tests son ekiyle ayrılıyor.
Yeni bir müşterinin aynı e-postayla iki siparişi aynı anda vermesi hâlâ korunmasız: ikisi de müşteriyi bulamayıp eklemeye çalışırsa unique index'e takılan ikinci istek 500 alır. Referans uygulamada bu bilinçli olarak açık bırakıldı; giriş yapan kullanıcılarla müşteri kaydı Identity'ye taşındığında ortadan kalkıyor.
Küçük Bir Deneme
★☆☆ Referans uygulamayı yukarıdaki adımlarla Windows'ta çalıştır. Katalogda "austen" ara ve http://localhost:5080/openapi/v1.json dokümanında /api/books için hangi query parametrelerinin listelendiğine bak.
★★☆ ReserveAsync'i Önce Tahmin Et'teki hâline geri döndür ve Two_parallel_orders_cannot_reserve_more_than_the_stock testini birkaç kez koştur. Test her seferinde aynı şekilde mi kırılıyor? Hata mesajını ve HTTP durum kodunu not et.
★★★ Kurumsal minimumu (en az 20 adet) API'ye ekle: PlaceOrderRequest'e kurumsal siparişi belirten bir alan, PlaceOrderOutcome'a yeni bir sonuç ve bunu sınayan bir integration test. Kuralı Ordering'e mi koydun, yeni bir Institutional module'üne mi? Gerekçeni yaz.
Bu yazıda Bookstore'un teknik yapısını ve repo'nun main branch'indeki referans uygulamayı gördük. Serinin son yazısında repo'nun kendisine bakıyoruz: seri branch'leri ve yazı tag'leri, bir yazının kodunu almak, iki yazı arasındaki farkı görmek ve hangi parçanın hangi seride anlatıldığını gösteren okuma haritası.