Clean Architecture Nedir? Kullanım Senaryoları ve Pratik Rehber

📚 Architecture Patterns Serisi Bu yazı, farklı mimari yaklaşımları karşılaştırmalı olarak ele aldığımız serinin 4. yazısıdır.


TL;DR

  • Clean Architecture, Robert C. Martin (Uncle Bob) tarafından 2012’de tanıtılan ve uygulamayı dört eş merkezli halka olarak organize eden bir mimari yaklaşımdır: Entities → Use Cases → Interface Adapters → Frameworks & Drivers.
  • Temel kural olan Dependency Rule‘a göre kaynak kodu bağımlılıkları her zaman içe doğru akar; dış katmanlar iç katmanlara bağımlıdır, iç katmanlar dış katmanları bilmez.
  • İş mantığı framework, veritabanı, UI veya dış servislere bağımlı değildir; bu bileşenler değiştirilebilir dış detaylardır.
  • Use Case’ler uygulama iş mantığını sarar; örnek projede her senaryo Command/Query + Handler çifti olarak ifade edilir ve Mediator ile çalıştırılır (CQRS). Mediator, Clean Architecture için zorunlu değil, tercih edilen bir araçtır.
  • Yazma tarafı Repository + Unit of Work (EF Core), okuma tarafı Read Service (Dapper) ile ayrılır; arayüzler Application katmanında tanımlanır, implementasyonları Infrastructure katmanında kalır.
  • Doğrulama ValidationBehavior pipeline’ı ile, hata yönetimi Result<T> ve domain’de DomainResult ile yapılır; mimari kurallar NetArchTest ile otomatik doğrulanır.
  • Kullanılması gereken yerler: Orta-büyük ölçekli, domain karmaşıklığı yüksek, uzun ömürlü uygulamalar.
  • Kullanılmaması gereken yerler: Basit CRUD uygulamaları, kısa ömürlü MVP/prototip, domain karmaşıklığı olmayan sistemler.

1. Clean Architecture Nedir?

Clean Architecture, Robert C. Martin (Uncle Bob) tarafından 2012 yılında yayınlanan blog yazısı ve ardından 2017 yılında kaleme aldığı Clean Architecture: A Craftsman’s Guide to Software Structure and Design adlı kitapta detaylandırılan bir mimari yaklaşımdır. Temel iddiası şudur: iş kuralları, framework’lerden, veritabanlarından, UI’dan ve dış servislerden bağımsız olmalıdır.

Uncle Bob, Hexagonal Architecture (Alistair Cockburn, 2005), Onion Architecture (Jeffrey Palermo, 2008) ve BCE (Ivar Jacobson) gibi önceki yaklaşımları sentezleyerek Clean Architecture modelini ortaya koymuştur. Tüm bu yaklaşımların ortak paydası şudur: bağımlılıkları merkeze doğru yönlendirmek.

Temel Prensipler

  • Dependency Rule (Bağımlılık Kuralı): Kaynak kodu bağımlılıkları yalnızca içe doğru işaret edebilir. Dıştaki bir halka, içteki bir halkayı kullanabilir; ama içteki bir halka dıştaki bir halkayı asla bilmez.
  • Framework Bağımsızlığı: Mimari, herhangi bir kütüphane veya framework’e bağımlı değildir. Framework’ler araç olarak kullanılır, kısıtlayıcı iskelet olarak değil.
  • Test Edilebilirlik: İş kuralları UI, veritabanı, web sunucusu veya dış bileşen olmadan test edilebilir.
  • UI Bağımsızlığı: UI kolayca değiştirilebilir; iş kuralları değişmez.
  • Veritabanı Bağımsızlığı: İş kuralları veritabanına bağlı değildir; SQL Server yerine MongoDB veya in-memory kullanılabilir.
  • Dış Servis Bağımsızlığı: Dış dünya hakkında iş kurallarının hiçbir bilgisi yoktur.

Motivasyon: Hangi Problemi Çözer?

Geleneksel katmanlı mimarilerde sıkça karşılaşılan sorunlar:

  1. Framework’e Kilitleniyor: Uygulama framework etrafında şekillendiğinden, framework değiştiğinde her şey değişmek zorunda kalır.
  2. Ters Bağımlılık: Domain katmanı veritabanına, ORM’e veya dış servislere doğrudan bağımlıdır.
  3. Test Zorluğu: İş mantığı altyapı bileşenlerine sıkıştığında birim test yazmak için gerçek veritabanı veya harici servis gerekmektedir.
  4. Sorumluluk Karışıklığı: Servis sınıfları hem iş mantığını hem de veri erişim, validasyon ve harici servis çağrılarını barındırır.
  5. Değişim Maliyeti: Küçük bir iş kuralı değişikliği birçok katmanı etkiler ve riskli hale gelir.

Clean Architecture bu sorunları şu şekilde çözer:

  • Entities katmanı kurumsal iş kurallarını barındırır; framework’ten, veritabanından ve UI’dan tamamen bağımsızdır.
  • Use Cases katmanı uygulama iş kurallarını düzenler; hangi verinin ne zaman aktığını belirler.
  • Interface Adapters katmanı verileri kullanım senaryoları ve entity’lerden uygun bir formata dönüştürür (controller, presenter, gateway).
  • Frameworks & Drivers en dıştaki halkadır; veritabanı, web framework, harici kütüphaneler burada yer alır.

Avantajlar

  • Yüksek Test Edilebilirlik: Domain ve Use Case katmanları tamamen izole; herhangi bir framework veya veritabanı bağımlılığı olmadan test edilebilir.
  • Framework Bağımsızlığı: Uygulama iş mantığı ASP.NET Core, Entity Framework Core veya herhangi bir harici kütüphaneye bağımlı değildir.
  • Uzun Ömürlü Mimari: İş kuralları teknoloji değişimlerinden etkilenmez; yalnızca dış katman güncellenir.
  • Açık Sorumluluk Sınırları: Her katmanın görevi nettir; Use Case nedir, Entity nedir, Controller nedir — belirsizlik yoktur.
  • Değişime Kapalı İç Katman: Veritabanı değişse de, UI teknolojisi değişse de Entities ve Use Cases katmanları etkilenmez.
  • Kolay Ölçeklenebilirlik: Use Case bazlı yapı sayesinde yeni özellikler eklemek mevcut kodu bozmaz.

Dezavantajlar

  • Yüksek Başlangıç Karmaşıklığı: Dört farklı proje (Domain, Application, Infrastructure, API) ve katmanlar arası veri dönüşümleri küçük projeler için fazla yapı oluşturabilir.
  • Boilerplate Kod: Her yeni özellik için Command/Query, Handler, DTO, Validator, Repository/Read Service arayüzü ve implementasyonu gerekmektedir.
  • Öğrenme Eğrisi: Dependency Rule, Interface Adapters kavramı ve katmanlar arası veri akışı yeni geliştiriciler için kafa karıştırıcı olabilir.
  • Aşırı Mühendislik Riski: Basit bir CRUD işlemi için Entity → Command/Query Handler → Repository Interface → Repository Implementation → Endpoint yolu gerektiğinden küçük projeler için overkill olabilir.
  • DTO Çoğalması: Katmanlar arası veri taşımak için çok sayıda DTO, record ve mapping kodu yazılması gerekir.

Ne Zaman Kullanmalı?

Senaryo Uygun mu? Neden
Orta-büyük ölçekli, domain karmaşık uygulama ✅ Evet Katmanlar arası bağımsızlık uzun vadede düşük değişim maliyeti sağlar
Birden fazla UI (web + mobil + CLI) olan proje ✅ Evet Use Cases katmanı UI teknolojisinden bağımsızdır; her UI aynı use case’i kullanır
ORM veya veritabanı değişimi öngörülen proje ✅ Evet Repository arayüzleri sayesinde Infrastructure değişimi iş mantığını etkilemez
Yüksek test kapsamı hedeflenen proje ✅ Evet Domain ve Use Cases izole; mock olmadan bile test yazılabilir
Çok geliştiricili, uzun ömürlü proje ✅ Evet Net katman sınırları paralel geliştirmeyi kolaylaştırır
Basit CRUD uygulaması (3-5 tablo) ❌ Hayır Mimari overhead fazla; Vertical Slice daha pratik olur
Kısa ömürlü prototip veya MVP ❌ Hayır Hız öncelikli; karmaşık yapı geliştirmeyi yavaşlatır
Domain karmaşıklığı olmayan sistem ❌ Hayır Tüm katman ayrımı anlamsız hale gelir
Tek geliştirici, küçük proje ❌ Hayır Yönetim maliyeti faydayı aşar

2. Katman Yapısı

GitHub deposunda hazırladığımız Restaurant Management API örneği üzerinden Clean Architecture yapısını ve pratiklerini inceleyecek, kavramları örnek kodlarla açıklayacağız.

🔗 DTVegaArchChapter/ArchitecturePatterns — Clean Architecture: Restaurant Management API

Clean Architecture dört eş merkezli halkadan oluşur. Halkaların isimleri değişebilir ancak Dependency Rule değişmez: bağımlılıklar daima içe doğru işaret eder.

┌─────────────────────────────────────────────────────────┐
│           Frameworks & Drivers (Dış Çevre)              │
│  ┌─────────────────────────────────────────────────┐    │
│  │         Interface Adapters (Adaptörler)         │    │
│  │  ┌───────────────────────────────────────────┐  │    │
│  │  │       Use Cases (Uygulama İş Kuralları)   │  │    │
│  │  │  ┌─────────────────────────────────────┐  │  │    │
│  │  │  │    Entities (Kurumsal İş Kuralları) │  │  │    │
│  │  │  └─────────────────────────────────────┘  │  │    │
│  │  └───────────────────────────────────────────┘  │    │
│  └─────────────────────────────────────────────────┘    │
└─────────────────────────────────────────────────────────┘

Bağımlılık yönü (Dependency Flow):

RestaurantManagement.Api          →  Application  →  Domain
RestaurantManagement.Api          →  Infrastructure  (yalnızca composition root: Program.cs)
RestaurantManagement.Infrastructure  →  Application  →  Domain

Halka 1: Entities (Domain Katmanı)

Kurumsal iş kurallarını barındırır. Herhangi bir framework veya kütüphaneye bağımlılığı yoktur. Entity’ler, sadece C# sınıflarıdır; iş kurallarını private setter’lar ve domain metotlarıyla korur.

Örnek projemizde:

  • Order — Sipariş aggregate root’u; durum geçişlerini (StartPreparation, MarkAsReady, Serve, Cancel) iş kurallarıyla zorunlu kılar.
  • MenuItem — Menü öğesi; fiyat ve kullanılabilirlik kurallarını içerir.
  • Table — Masa aggregate root’u; rezervasyon ve doluluk durumlarını yönetir.
  • OrderItem — Sipariş kalemi; miktar ve fiyat kurallarını korur. Yalnızca Order üzerinden erişilir.

Domain katmanında iki yardımcı yapı bulunur: Aggregate root’ları işaretleyen IAggregateRoot (yalnızca aggregate root’lar repository üzerinden erişilir) ve iş kuralı ihlallerini exception fırlatmadan döndüren DomainResult.

// src/RestaurantManagement.Domain/Common/BaseEntity.cs
namespace RestaurantManagement.Domain.Common;

public abstract class BaseEntity
{
    public int Id { get; protected set; }
}
// src/RestaurantManagement.Domain/Common/IAggregateRoot.cs
namespace RestaurantManagement.Domain.Common;

public interface IAggregateRoot
{
}
// src/RestaurantManagement.Domain/Common/DomainResult.cs
namespace RestaurantManagement.Domain.Common;

public readonly record struct DomainResult(bool IsSuccess, string? Error)
{
    public static DomainResult Success() => new(true, null);

    public static DomainResult Failure(string error) => new(false, error);
}

Beklenen iş kuralı ihlalleri (örneğin yanlış durumdaki bir siparişi iptal etmek) exception yerine DomainResult ile döndürülür; exception yalnızca programlama hataları (geçersiz constructor argümanı gibi) için kullanılır.

// src/RestaurantManagement.Domain/Entities/Order.cs
using RestaurantManagement.Domain.Common;

namespace RestaurantManagement.Domain.Entities;

public class Order : BaseEntity, IAggregateRoot
{
    public string OrderNumber { get; private set; } = null!;
    public int TableId { get; private set; }
    public DateTime OrderDate { get; private set; }
    public OrderStatus Status { get; private set; }
    public decimal TotalAmount { get; private set; }
    public string? Notes { get; private set; }

    private readonly List<OrderItem> _orderItems = [];
    public IReadOnlyCollection<OrderItem> OrderItems => _orderItems.AsReadOnly();

    private Order() { } // EF Core için

    public Order(string orderNumber, int tableId, string? notes = null)
    {
        if (string.IsNullOrWhiteSpace(orderNumber))
            throw new ArgumentException("Order number cannot be null or empty", nameof(orderNumber));

        OrderNumber = orderNumber;
        TableId = tableId;
        OrderDate = DateTime.UtcNow;
        Status = OrderStatus.Pending;
        Notes = notes;
        TotalAmount = 0;
    }

    public DomainResult AddOrderItem(int menuItemId, int quantity, decimal price, string? specialInstructions = null)
    {
        if (Status != OrderStatus.Pending)
            return DomainResult.Failure($"Cannot add items to order with status: {Status}");

        if (_orderItems.Any(oi => oi.MenuItemId == menuItemId))
            return DomainResult.Failure($"Menu item {menuItemId} is already in the order");

        var orderItem = new OrderItem(menuItemId, quantity, price, specialInstructions);
        _orderItems.Add(orderItem);
        RecalculateTotal();
        return DomainResult.Success();
    }

    public DomainResult RemoveOrderItem(int menuItemId)
    {
        if (Status != OrderStatus.Pending)
            return DomainResult.Failure($"Cannot remove items from order with status: {Status}");

        var item = _orderItems.FirstOrDefault(oi => oi.MenuItemId == menuItemId);
        if (item != null)
        {
            _orderItems.Remove(item);
            RecalculateTotal();
        }
        return DomainResult.Success();
    }

    public DomainResult UpdateOrderItemQuantity(int menuItemId, int newQuantity)
    {
        if (Status != OrderStatus.Pending)
            return DomainResult.Failure($"Cannot update items in order with status: {Status}");

        var item = _orderItems.FirstOrDefault(oi => oi.MenuItemId == menuItemId);
        if (item != null)
        {
            item.UpdateQuantity(newQuantity);
            RecalculateTotal();
        }
        return DomainResult.Success();
    }

    public DomainResult StartPreparation()
    {
        if (Status != OrderStatus.Pending)
            return DomainResult.Failure($"Cannot start preparation for order with status: {Status}");

        if (!_orderItems.Any())
            return DomainResult.Failure("Cannot start preparation for order with no items");

        Status = OrderStatus.InPreparation;
        return DomainResult.Success();
    }

    public DomainResult MarkAsReady()
    {
        if (Status != OrderStatus.InPreparation)
            return DomainResult.Failure($"Cannot mark order as ready with status: {Status}");

        Status = OrderStatus.Ready;
        return DomainResult.Success();
    }

    public DomainResult Serve()
    {
        if (Status != OrderStatus.Ready)
            return DomainResult.Failure($"Cannot serve order with status: {Status}");

        Status = OrderStatus.Served;
        return DomainResult.Success();
    }

    public DomainResult Cancel()
    {
        if (Status == OrderStatus.Served)
            return DomainResult.Failure("Cannot cancel a served order");
        if (Status == OrderStatus.Cancelled)
            return DomainResult.Failure("Order is already cancelled");

        Status = OrderStatus.Cancelled;
        return DomainResult.Success();
    }

    public void UpdateNotes(string? notes)
    {
        Notes = notes;
    }

    private void RecalculateTotal()
    {
        TotalAmount = _orderItems.Sum(item => item.GetTotalPrice());
    }

    public bool CanBeModified => Status == OrderStatus.Pending;
}
// src/RestaurantManagement.Domain/Entities/Table.cs
using RestaurantManagement.Domain.Common;

namespace RestaurantManagement.Domain.Entities;

public class Table : BaseEntity, IAggregateRoot
{
    public int TableNumber { get; private set; }
    public int Capacity { get; private set; }
    public TableStatus Status { get; private set; }
    public DateTime? ReservedAt { get; private set; }

    private Table() { } // EF Core için

    public Table(int tableNumber, int capacity)
    {
        if (tableNumber <= 0)
            throw new ArgumentException("Table number must be positive", nameof(tableNumber));
        if (capacity <= 0)
            throw new ArgumentException("Capacity must be positive", nameof(capacity));

        TableNumber = tableNumber;
        Capacity = capacity;
        Status = TableStatus.Available;
    }

    public bool IsAvailable => Status == TableStatus.Available;

    public DomainResult Reserve(DateTime reservationTime)
    {
        if (Status != TableStatus.Available)
            return DomainResult.Failure($"Cannot reserve table {TableNumber}. Current status: {Status}");

        Status = TableStatus.Reserved;
        ReservedAt = reservationTime;
        return DomainResult.Success();
    }

    public DomainResult Occupy()
    {
        if (Status != TableStatus.Available && Status != TableStatus.Reserved)
            return DomainResult.Failure($"Cannot occupy table {TableNumber}. Current status: {Status}");

        Status = TableStatus.Occupied;
        ReservedAt = null;
        return DomainResult.Success();
    }

    public DomainResult MakeAvailable()
    {
        if (Status == TableStatus.Available)
            return DomainResult.Failure($"Table {TableNumber} is already available");

        Status = TableStatus.Available;
        ReservedAt = null;
        return DomainResult.Success();
    }

    public DomainResult TakeOutOfService()
    {
        if (Status == TableStatus.Occupied)
            return DomainResult.Failure($"Cannot take occupied table {TableNumber} out of service");

        Status = TableStatus.OutOfService;
        ReservedAt = null;
        return DomainResult.Success();
    }
}

Halka 2: Use Cases (Application Katmanı)

Uygulama iş kurallarını barındırır. Entity’lere bağımlıdır; veritabanı veya framework’e bağımlı değildir. Veri erişim sözleşmelerini (repository arayüzleri) burada tanımlar; implementasyonları dış katmana bırakır.

Her kullanım senaryosu kendi klasöründe yer alan bir Command (yazma) veya Query (okuma) ile bunu işleyen bir Handler sınıfıyla temsil edilir. Handler’lar Mediator kütüphanesi (source generator tabanlı) üzerinden çalıştırılır; her handler tek sorumluluğa sahip bağımsız bir sınıftır. Mediator Clean Architecture’ın zorunlu bir parçası değildir; ancak cross-cutting concern’leri (örneğin validation) pipeline behavior olarak merkezi yönetmeyi kolaylaştırır.

Yazma ve okuma tarafı ayrıdır (CQRS):

  • Yazma tarafı: Aggregate’ler IUnitOfWork üzerinden repository’lerle yüklenir, domain metotlarıyla değiştirilir ve kaydedilir (EF Core).
  • Okuma tarafı: I*ReadService arayüzleri aggregate yüklemeden doğrudan DTO döner (Dapper).

Repository Arayüzleri (Uygulama Sözleşmeleri):

Repository yalnızca aggregate root’lar için vardır ve yalnızca yazma tarafının ihtiyaç duyduğu metotları içerir.

// src/RestaurantManagement.Application/Common/Interfaces/IOrderRepository.cs
using RestaurantManagement.Domain.Entities;

namespace RestaurantManagement.Application.Common.Interfaces;

public interface IOrderRepository
{
    Task<Order?> GetByIdAsync(int id, CancellationToken cancellationToken = default);
    Task AddAsync(Order order, CancellationToken cancellationToken = default);
    Task DeleteAsync(Order order, CancellationToken cancellationToken = default);
}

Read Service Arayüzleri (Okuma Sözleşmeleri):

// src/RestaurantManagement.Application/Common/Interfaces/IOrderReadService.cs
using RestaurantManagement.Application.Common.DTOs;

namespace RestaurantManagement.Application.Common.Interfaces;

/// <summary>
/// Read-only query side. Returns DTOs directly and never loads aggregates.
/// </summary>
public interface IOrderReadService
{
    Task<OrderDto?> GetByIdAsync(int id, CancellationToken cancellationToken = default);
    Task<IReadOnlyList<OrderDto>> GetKitchenOrdersAsync(CancellationToken cancellationToken = default);
}
// src/RestaurantManagement.Application/Common/Interfaces/IUnitOfWork.cs
namespace RestaurantManagement.Application.Common.Interfaces;

public interface IUnitOfWork
{
    ITableRepository Tables { get; }
    IMenuItemRepository MenuItems { get; }
    IOrderRepository Orders { get; }

    Task<int> SaveChangesAsync(CancellationToken cancellationToken = default);
    Task BeginTransactionAsync(CancellationToken cancellationToken = default);
    Task CommitTransactionAsync(CancellationToken cancellationToken = default);
    Task RollbackTransactionAsync(CancellationToken cancellationToken = default);
}

Halka 3: Interface Adapters (Adaptör Katmanı)

Verileri dönüştürür; Use Cases ve Entity’lerden gelen verileri web framework, veritabanı veya dış servisler için uygun formata çevirir. Controller’lar, Endpoint’ler, Presenter’lar ve Gateway’ler bu katmanda yer alır.

Örnek projede bu katman RestaurantManagement.Api projesindeki Minimal API endpoint’leri, API sözleşme modelleri (Contracts) ve ResultHelper aracılığıyla gerçekleştirilir.

Halka 4: Frameworks & Drivers (Altyapı Katmanı)

En dıştaki halkadır; veritabanı, web framework, harici kütüphaneler burada yer alır. Repository ve Read Service implementasyonları, DbContext, dependency injection konfigürasyonu bu katmandadır. Bu katmanı tamamen değiştirmek iş kurallarını etkilememelidir.


3. Klasör Yapısı

src/
├── RestaurantManagement.Domain/                  # Entities (İç halka)
│   ├── Common/
│   │   ├── BaseEntity.cs                        # Tüm entity'lerin base sınıfı
│   │   ├── IAggregateRoot.cs                    # Aggregate root işaretleyicisi
│   │   └── DomainResult.cs                      # Domain işlem sonucu
│   └── Entities/
│       ├── Order.cs                             # Sipariş entity'si (iş kuralları)
│       ├── OrderItem.cs                         # Sipariş kalemi
│       ├── OrderStatus.cs                       # Sipariş durumu enum
│       ├── MenuItem.cs                          # Menü öğesi
│       ├── Table.cs                             # Masa entity'si
│       └── TableStatus.cs                       # Masa durumu enum
│
├── RestaurantManagement.Application/             # Use Cases (2. halka)
│   ├── Common/
│   │   ├── Result.cs                            # Explicit hata yönetimi
│   │   ├── Behaviors/
│   │   │   └── ValidationBehavior.cs            # Otomatik validation pipeline'ı
│   │   ├── DTOs/
│   │   │   ├── OrderDto.cs
│   │   │   ├── OrderItemDto.cs
│   │   │   ├── MenuItemDto.cs
│   │   │   └── TableDto.cs
│   │   └── Interfaces/
│   │       ├── IOrderRepository.cs              # Yazma tarafı: arayüz burada tanımlanır
│   │       ├── IMenuItemRepository.cs
│   │       ├── ITableRepository.cs
│   │       ├── IUnitOfWork.cs
│   │       ├── IOrderReadService.cs             # Okuma tarafı: DTO dönen sorgu sözleşmeleri
│   │       ├── IMenuItemReadService.cs
│   │       └── ITableReadService.cs
│   ├── Orders/
│   │   ├── CreateOrder/
│   │   │   ├── CreateOrderCommand.cs            # Input modeli (ICommand)
│   │   │   ├── CreateOrderCommandHandler.cs     # Sipariş oluşturma kullanım senaryosu
│   │   │   └── CreateOrderCommandValidator.cs   # FluentValidation doğrulayıcı
│   │   ├── UpdateOrderStatus/
│   │   │   ├── UpdateOrderStatusCommand.cs
│   │   │   ├── UpdateOrderStatusCommandHandler.cs
│   │   │   └── UpdateOrderStatusCommandValidator.cs
│   │   └── GetKitchenOrders/
│   │       ├── GetKitchenOrdersQuery.cs         # IQuery
│   │       └── GetKitchenOrdersQueryHandler.cs
│   ├── MenuItems/
│   │   └── GetMenuItems/
│   │       ├── GetMenuItemsQuery.cs
│   │       └── GetMenuItemsQueryHandler.cs
│   ├── Tables/
│   │   ├── GetAllTables/
│   │   │   ├── GetAllTablesQuery.cs
│   │   │   └── GetAllTablesQueryHandler.cs
│   │   └── UpdateTableStatus/
│   │       ├── UpdateTableStatusCommand.cs
│   │       ├── UpdateTableStatusCommandHandler.cs
│   │       └── UpdateTableStatusCommandValidator.cs
│   └── DependencyInjection.cs                   # AddApplication()
│
├── RestaurantManagement.Infrastructure/          # Frameworks & Drivers (Dış halka)
│   ├── Data/
│   │   ├── RestaurantDbContext.cs               # EF Core DbContext
│   │   └── DbConnectionFactory.cs               # Dapper için bağlantı fabrikası
│   ├── Repositories/                            # Yazma tarafı (EF Core)
│   │   ├── OrderRepository.cs                   # IOrderRepository implementasyonu
│   │   ├── MenuItemRepository.cs
│   │   ├── TableRepository.cs
│   │   └── UnitOfWork.cs                        # IUnitOfWork implementasyonu
│   ├── ReadServices/                            # Okuma tarafı (Dapper)
│   │   ├── DapperOrderReadService.cs            # IOrderReadService implementasyonu
│   │   ├── DapperMenuItemReadService.cs
│   │   └── DapperTableReadService.cs
│   └── DependencyInjection.cs                   # AddInfrastructure()
│
└── RestaurantManagement.Api/                     # Interface Adapters (3. halka)
    ├── Program.cs                               # Composition root
    ├── Endpoints/
    │   ├── OrderEndpoints.cs                    # Minimal API endpoint'leri
    │   ├── MenuItemEndpoints.cs
    │   └── TableEndpoints.cs
    ├── Common/
    │   └── ResultHelper.cs                      # Result → IResult dönüşümü
    └── Contracts/
        ├── Orders/
        │   ├── CreateOrderRequest.cs            # API sözleşmesi (DTO)
        │   └── UpdateOrderStatusRequest.cs
        └── Tables/
            └── UpdateTableStatusRequest.cs

test/
├── RestaurantManagement.Api.Tests/               # Domain, Application ve Api birim testleri
├── RestaurantManagement.Api.IntegrationTests/    # Repository, read service ve UnitOfWork testleri (SQLite)
├── RestaurantManagement.Api.FunctionalTests/     # WebApplicationFactory ile uçtan uca testler
└── RestaurantManagement.Api.ArchTests/           # Mimari kuralların doğrulanması (NetArchTest)

4. Use Case Pattern: CQRS ve Mediator

Clean Architecture’ın en belirgin özelliklerinden biri Use Case’lerdir. Örnek projede her Use Case, kendi klasöründe izole üç parçadan oluşur:

  • Command / Query: Use Case’in girdisini tanımlayan record (ICommand<TResponse> veya IQuery<TResponse>).
  • Handler: İş akışını yöneten sınıf (ICommandHandler veya IQueryHandler).
  • Validator: (Yalnızca command’lar için) FluentValidation kuralları.

Command/Query’ler ISender.Send(...) ile gönderilir; Mediator ilgili handler’ı bulup çalıştırır. Böylece Api katmanı handler sınıflarını doğrudan tanımaz, yalnızca command/query’yi bilir.

Command Handler’ın sorumluluğu (yazma):

  1. Gerekli aggregate’leri yüklemek (IUnitOfWork üzerinden repository’ler)
  2. İş kurallarını çalıştırmak (entity metotları → DomainResult)
  3. Sonucu persist etmek (SaveChangesAsync)
  4. DTO içeren Result<T> dönmek

Query Handler’ın sorumluluğu (okuma): Read Service’i çağırıp DTO’yu Result<T> içinde dönmek. Aggregate yüklenmez.

Input validasyonu handler’ın içinde değil, ValidationBehavior pipeline’ında yapılır (bkz. Validation).

CreateOrderCommand ve Handler

// src/RestaurantManagement.Application/Orders/CreateOrder/CreateOrderCommand.cs
using Mediator;
using RestaurantManagement.Application.Common;
using RestaurantManagement.Application.Common.DTOs;

namespace RestaurantManagement.Application.Orders.CreateOrder;

public sealed record OrderItemInput(int MenuItemId, int Quantity, string? SpecialInstructions);

public sealed record CreateOrderCommand(int TableId, List<OrderItemInput> Items, string? Notes)
    : ICommand<Result<OrderDto>>;
// src/RestaurantManagement.Application/Orders/CreateOrder/CreateOrderCommandHandler.cs
using Mediator;
using RestaurantManagement.Application.Common;
using RestaurantManagement.Application.Common.DTOs;
using RestaurantManagement.Application.Common.Interfaces;
using RestaurantManagement.Domain.Entities;

namespace RestaurantManagement.Application.Orders.CreateOrder;

public sealed class CreateOrderCommandHandler(IUnitOfWork unitOfWork)
    : ICommandHandler<CreateOrderCommand, Result<OrderDto>>
{
    public async ValueTask<Result<OrderDto>> Handle(CreateOrderCommand command, CancellationToken cancellationToken)
    {
        // 1. Masa kontrolü
        var table = await unitOfWork.Tables.GetByIdAsync(command.TableId, cancellationToken);
        if (table is null)
            return Result<OrderDto>.NotFound($"Table {command.TableId} not found");

        if (!table.IsAvailable)
            return Result<OrderDto>.Failure($"Table {table.TableNumber} is not available for orders");

        // 2. Menü öğesi kontrolü
        var menuItemIds = command.Items.Select(i => i.MenuItemId).ToList();
        var menuItems = await unitOfWork.MenuItems.GetByIdsAsync(menuItemIds, cancellationToken);
        var availableMenuItems = menuItems.Where(m => m.IsAvailable).ToList();

        var availableMenuItemIds = availableMenuItems.Select(m => m.Id).ToList();
        var unavailableMenuItemIds = menuItemIds.Where(id => !availableMenuItemIds.Contains(id)).ToList();

        if (unavailableMenuItemIds.Count != 0)
            return Result<OrderDto>.Failure(
                $"The following menu items are not available: {string.Join(", ", unavailableMenuItemIds)}",
                errorDetails: new Dictionary<string, object> { ["UnavailableMenuItemIds"] = unavailableMenuItemIds });

        // 3. Domain aggregate oluşturma
        var orderNumber = $"ORD-{DateTime.UtcNow:yyyyMMdd}-{Guid.NewGuid().ToString("N")[..8].ToUpperInvariant()}";
        var order = new Order(orderNumber, command.TableId, command.Notes);

        foreach (var itemRequest in command.Items)
        {
            var menuItem = availableMenuItems.First(m => m.Id == itemRequest.MenuItemId);
            var added = order.AddOrderItem(itemRequest.MenuItemId, itemRequest.Quantity, menuItem.Price, itemRequest.SpecialInstructions);
            if (!added.IsSuccess)
                return Result<OrderDto>.Conflict(added.Error!);
        }

        // 4. Masayı dolu olarak işaretle (aynı transaction içinde)
        var occupied = table.Occupy();
        if (!occupied.IsSuccess)
            return Result<OrderDto>.Conflict(occupied.Error!);

        // 5. Persist
        await unitOfWork.Orders.AddAsync(order, cancellationToken);
        try
        {
            await unitOfWork.SaveChangesAsync(cancellationToken);
        }
        catch (InvalidOperationException ex)
        {
            // Eşzamanlılık çakışması (örn. aynı masaya aynı anda iki sipariş)
            return Result<OrderDto>.Conflict(ex.Message);
        }

        // 6. DTO dönüşümü
        var orderItemDtos = order.OrderItems.Select(oi =>
        {
            var menuItem = availableMenuItems.First(m => m.Id == oi.MenuItemId);
            return new OrderItemDto(oi.Id, menuItem.Name, oi.Quantity, oi.Price, oi.SpecialInstructions);
        }).ToList();

        var orderDto = new OrderDto(
            order.Id, order.OrderNumber, order.TableId,
            order.OrderDate, order.Status.ToString(),
            order.TotalAmount, order.Notes, orderItemDtos);

        return Result<OrderDto>.Success(orderDto);
    }
}

Dikkat edilmesi gerekenler:

  • Handler DbContext‘i değil, yalnızca Application katmanında tanımlı IUnitOfWork arayüzünü kullanır.
  • Validasyon kodu yoktur; handler’a ulaşan command zaten geçerlidir.
  • Masanın Occupy() edilmesi ve siparişin eklenmesi tek SaveChangesAsync çağrısıyla atomik kaydedilir. Table.Status EF Core’da concurrency token olduğundan eşzamanlı çakışmalar UnitOfWork tarafından InvalidOperationException‘a çevrilir ve 409 Conflict olarak döner.

UpdateOrderStatusCommand ve Handler

Durum güncelleme Order aggregate’ini yükler, domain metodunu çağırır ve sonucu Read Service ile okur; yani aynı use case içinde yazma tarafı EF Core, okuma tarafı Dapper kullanır.

// src/RestaurantManagement.Application/Orders/UpdateOrderStatus/UpdateOrderStatusCommand.cs
using Mediator;
using RestaurantManagement.Application.Common;
using RestaurantManagement.Application.Common.DTOs;

namespace RestaurantManagement.Application.Orders.UpdateOrderStatus;

public sealed record UpdateOrderStatusCommand(int OrderId, string NewStatus) : ICommand<Result<OrderDto>>;
// src/RestaurantManagement.Application/Orders/UpdateOrderStatus/UpdateOrderStatusCommandHandler.cs
using Mediator;
using RestaurantManagement.Application.Common;
using RestaurantManagement.Application.Common.DTOs;
using RestaurantManagement.Application.Common.Interfaces;
using RestaurantManagement.Domain.Common;
using RestaurantManagement.Domain.Entities;

namespace RestaurantManagement.Application.Orders.UpdateOrderStatus;

public sealed class UpdateOrderStatusCommandHandler(
    IUnitOfWork unitOfWork,
    IOrderReadService orderReadService)
    : ICommandHandler<UpdateOrderStatusCommand, Result<OrderDto>>
{
    public async ValueTask<Result<OrderDto>> Handle(UpdateOrderStatusCommand command, CancellationToken cancellationToken)
    {
        // 1. Aggregate yükleme
        var order = await unitOfWork.Orders.GetByIdAsync(command.OrderId, cancellationToken);
        if (order is null)
            return Result<OrderDto>.NotFound($"Order {command.OrderId} not found");

        // 2. Domain iş kuralı — durum geçişi (validator geçerli bir enum değeri olduğunu garanti eder)
        var newStatus = Enum.Parse<OrderStatus>(command.NewStatus, true);
        DomainResult transition;
        switch (newStatus)
        {
            case OrderStatus.InPreparation:
                transition = order.StartPreparation();
                break;
            case OrderStatus.Ready:
                transition = order.MarkAsReady();
                break;
            case OrderStatus.Served:
                transition = order.Serve();
                break;
            case OrderStatus.Cancelled:
                transition = order.Cancel();
                break;
            default:
                return Result<OrderDto>.Failure($"Cannot transition order to status: {newStatus}");
        }

        if (!transition.IsSuccess)
            return Result<OrderDto>.Conflict(transition.Error!);

        // 3. Persist (EF Core change tracking; Update çağrısına gerek yok)
        await unitOfWork.SaveChangesAsync(cancellationToken);

        // 4. Okuma tarafı: DTO doğrudan Read Service'ten gelir
        var orderDto = await orderReadService.GetByIdAsync(order.Id, cancellationToken);

        return orderDto is null
            ? Result<OrderDto>.NotFound($"Order {command.OrderId} not found")
            : Result<OrderDto>.Success(orderDto);
    }
}

Query Handler: GetKitchenOrders

Query handler’lar iş kuralı içermez; yalnızca Read Service’i çağırır. Aggregate yüklenmez, IUnitOfWork kullanılmaz.

// src/RestaurantManagement.Application/Orders/GetKitchenOrders/GetKitchenOrdersQuery.cs
using Mediator;
using RestaurantManagement.Application.Common;
using RestaurantManagement.Application.Common.DTOs;

namespace RestaurantManagement.Application.Orders.GetKitchenOrders;

public sealed record GetKitchenOrdersQuery : IQuery<Result<List<OrderDto>>>;
// src/RestaurantManagement.Application/Orders/GetKitchenOrders/GetKitchenOrdersQueryHandler.cs
using Mediator;
using RestaurantManagement.Application.Common;
using RestaurantManagement.Application.Common.DTOs;
using RestaurantManagement.Application.Common.Interfaces;

namespace RestaurantManagement.Application.Orders.GetKitchenOrders;

public sealed class GetKitchenOrdersQueryHandler(IOrderReadService orderReadService)
    : IQueryHandler<GetKitchenOrdersQuery, Result<List<OrderDto>>>
{
    public async ValueTask<Result<List<OrderDto>>> Handle(GetKitchenOrdersQuery query, CancellationToken cancellationToken)
    {
        var orders = await orderReadService.GetKitchenOrdersAsync(cancellationToken);

        return Result<List<OrderDto>>.Success([.. orders]);
    }
}

DTO Modelleri

Command/Query’ler input, DTO’lar output modelidir. Domain entity’leri Application katmanının dışına çıkmaz.

// src/RestaurantManagement.Application/Common/DTOs/OrderDto.cs
namespace RestaurantManagement.Application.Common.DTOs;

public record OrderDto(
    int Id,
    string OrderNumber,
    int TableId,
    DateTime OrderDate,
    string Status,
    decimal TotalAmount,
    string? Notes,
    List<OrderItemDto> OrderItems);

5. Result Pattern

Result pattern iki seviyede kullanılır:

  • Domain: Entity metotları DomainResult döner (bkz. Katman Yapısı bölümü).
  • Application: Handler’lardan dönen sonuçlar Result<T> ile sarılır. Handler, DomainResult hatasını Result<T>.Conflict(...) gibi uygun sonuca çevirir.

Bu pattern, exception fırlatmadan hata durumlarını açıkça ifade eder ve Interface Adapters katmanında tek bir yerden HTTP yanıtına dönüştürülür.

// src/RestaurantManagement.Application/Common/Result.cs
using FluentValidation.Results;

namespace RestaurantManagement.Application.Common;

public enum ResultType
{
    Success,
    NotFound,
    Conflict,
    Failure
}

public interface IOperationResult
{
    bool IsSuccess { get; }
    string? ErrorMessage { get; }
    ResultType ResultType { get; }
    IReadOnlyDictionary<string, object> ErrorDetails { get; }
}

// ValidationBehavior, herhangi bir Result<T> tipini generic olarak oluşturabilsin diye
public interface IResultFactory<TSelf> where TSelf : IResultFactory<TSelf>
{
    static abstract TSelf From(ValidationResult validationResult);
}

public sealed class Result<T> : IOperationResult, IResultFactory<Result<T>>
{
    private static readonly IReadOnlyDictionary<string, object> Empty = new Dictionary<string, object>();

    private IReadOnlyDictionary<string, object>? _errorDetails;
    public bool IsSuccess { get; private init; }
    public T? Data { get; private set; }
    public string? ErrorMessage { get; private init; }
    public ResultType ResultType { get; private init; }

    public IReadOnlyDictionary<string, object> ErrorDetails => _errorDetails ?? Empty;

    private Result() { }

    public static Result<T> Success(T data) =>
        new() { IsSuccess = true, Data = data, ResultType = ResultType.Success };

    public static Result<T> Failure(string errorMessage,
        ResultType resultType = ResultType.Failure,
        IReadOnlyDictionary<string, object>? errorDetails = null) =>
        new() { IsSuccess = false, ErrorMessage = errorMessage, ResultType = resultType, _errorDetails = errorDetails };

    public static Result<T> NotFound(string errorMessage, IReadOnlyDictionary<string, object>? errorDetails = null) =>
        Failure(errorMessage, ResultType.NotFound, errorDetails);

    public static Result<T> Conflict(string errorMessage, IReadOnlyDictionary<string, object>? errorDetails = null) =>
        Failure(errorMessage, ResultType.Conflict, errorDetails);

    public static Result<T> From(ValidationResult validationResult)
    {
        ArgumentNullException.ThrowIfNull(validationResult);

        var errors = validationResult.Errors;
        var message = $"Validation failed: {string.Join("; ", errors.Select(e => e.ErrorMessage))}";
        var details = errors
            .GroupBy(e => e.PropertyName)
            .ToDictionary(g => g.Key, g => (object)g.Select(e => e.ErrorMessage).ToArray());

        return Failure(message, ResultType.Failure, details);
    }
}

Interface Adapters katmanındaki ResultHelper, Result<T>‘yi HTTP yanıtına dönüştürür:

// src/RestaurantManagement.Api/Common/ResultHelper.cs
using RestaurantManagement.Application.Common;

namespace RestaurantManagement.Api.Common;

public static class ResultHelper
{
    public static IResult ToApiResult<T>(
        this Result<T> result,
        Func<T?, IResult>? onSuccess = null)
    {
        if (result.IsSuccess)
            return onSuccess?.Invoke(result.Data) ?? Results.Ok(result.Data);

        var errorDetails = new
        {
            error = result.ErrorMessage,
            errorDetails = result.ErrorDetails
        };

        return result.ResultType switch
        {
            ResultType.NotFound => Results.NotFound(errorDetails),
            ResultType.Conflict => Results.Conflict(errorDetails),
            ResultType.Failure => Results.BadRequest(errorDetails),
            _ => Results.BadRequest(errorDetails)
        };
    }
}

6. Validation

Input validasyonu Application katmanında, FluentValidation ile yapılır. Validator’lar handler’lardan ayrıdır; ValidationBehavior isimli Mediator pipeline behavior’ı handler çalışmadan önce ilgili command için kayıtlı tüm validator’ları çalıştırır. Hata varsa handler hiç çalışmaz ve Result<T> (validation hatası) döner.

// src/RestaurantManagement.Application/Orders/CreateOrder/CreateOrderCommandValidator.cs
using FluentValidation;

namespace RestaurantManagement.Application.Orders.CreateOrder;

public sealed class OrderItemInputValidator : AbstractValidator<OrderItemInput>
{
    public OrderItemInputValidator()
    {
        RuleFor(x => x.MenuItemId)
            .GreaterThan(0).WithMessage("MenuItemId must be greater than 0");

        RuleFor(x => x.Quantity)
            .GreaterThan(0).WithMessage("Quantity must be greater than 0");

        RuleFor(x => x.SpecialInstructions)
            .MaximumLength(250).WithMessage("Special instructions cannot exceed 250 characters");
    }
}

public sealed class CreateOrderCommandValidator : AbstractValidator<CreateOrderCommand>
{
    public CreateOrderCommandValidator()
    {
        RuleFor(x => x.TableId)
            .GreaterThan(0).WithMessage("TableId must be greater than 0");

        RuleFor(x => x.Items)
            .NotEmpty().WithMessage("Order must contain at least one item");

        RuleFor(x => x.Items)
            .Must(items => items is null || items.Select(i => i.MenuItemId).Distinct().Count() == items.Count)
            .WithMessage("Order cannot contain duplicate menu items");

        RuleForEach(x => x.Items)
            .SetValidator(new OrderItemInputValidator());

        RuleFor(x => x.Notes)
            .MaximumLength(500).WithMessage("Notes cannot exceed 500 characters");
    }
}
// src/RestaurantManagement.Application/Orders/UpdateOrderStatus/UpdateOrderStatusCommandValidator.cs
using FluentValidation;
using RestaurantManagement.Domain.Entities;

namespace RestaurantManagement.Application.Orders.UpdateOrderStatus;

public sealed class UpdateOrderStatusCommandValidator : AbstractValidator<UpdateOrderStatusCommand>
{
    public UpdateOrderStatusCommandValidator()
    {
        RuleFor(x => x.OrderId)
            .GreaterThan(0).WithMessage("OrderId must be greater than 0");

        RuleFor(x => x.NewStatus)
            .Must(s => Enum.TryParse<OrderStatus>(s, true, out var status) && Enum.IsDefined(status))
            .WithMessage("Invalid order status value");
    }
}

Pipeline behavior, generic ve tüm command’lar için tek bir yerde tanımlanır:

// src/RestaurantManagement.Application/Common/Behaviors/ValidationBehavior.cs
using FluentValidation;
using Mediator;

namespace RestaurantManagement.Application.Common.Behaviors;

public sealed class ValidationBehavior<TMessage, TResponse>(IEnumerable<IValidator<TMessage>> validators)
    : IPipelineBehavior<TMessage, TResponse>
    where TMessage : IMessage
    where TResponse : IResultFactory<TResponse>
{
    public async ValueTask<TResponse> Handle(
        TMessage message,
        MessageHandlerDelegate<TMessage, TResponse> next,
        CancellationToken cancellationToken)
    {
        var failures = new List<FluentValidation.Results.ValidationFailure>();
        foreach (var validator in validators)
        {
            var validationResult = await validator.ValidateAsync(message, cancellationToken);
            failures.AddRange(validationResult.Errors);
        }

        if (failures.Count == 0)
            return await next(message, cancellationToken);

        return TResponse.From(new FluentValidation.Results.ValidationResult(failures));
    }
}

Bu yaklaşım sayesinde handler’lar yalnızca iş akışına odaklanır; yeni bir command için yalnızca bir validator sınıfı eklemek yeterlidir.


7. Interface Adapters Katmanı: Minimal API Endpoint’leri

Endpoint’ler yalnızca HTTP çevirisini yapar: API sözleşme modelini (Contracts/) Command/Query’ye çevirir, ISender ile gönderir ve Result<T>‘yi HTTP yanıtına dönüştürür. Handler sınıflarını tanımazlar. API sözleşme modelleri Application katmanı modellerinden ayrı tutulur; böylece API sözleşmesi değiştiğinde Application katmanı etkilenmez.

// src/RestaurantManagement.Api/Contracts/Orders/CreateOrderRequest.cs
namespace RestaurantManagement.Api.Contracts.Orders;

public record OrderItemRequest(int MenuItemId, int Quantity, string? SpecialInstructions);

public record CreateOrderRequest(int TableId, List<OrderItemRequest> Items, string? Notes);
// src/RestaurantManagement.Api/Endpoints/OrderEndpoints.cs
using Mediator;
using RestaurantManagement.Api.Common;
using RestaurantManagement.Api.Contracts.Orders;
using RestaurantManagement.Application.Common.DTOs;
using RestaurantManagement.Application.Orders.CreateOrder;
using RestaurantManagement.Application.Orders.GetKitchenOrders;
using RestaurantManagement.Application.Orders.UpdateOrderStatus;

namespace RestaurantManagement.Api.Endpoints;

public static class OrderEndpoints
{
    public static IEndpointRouteBuilder MapOrderEndpoints(this IEndpointRouteBuilder app)
    {
        var group = app.MapGroup("api/orders").WithTags("Orders");

        group.MapPost("/", async (CreateOrderRequest request, ISender sender, CancellationToken ct) =>
            {
                // API sözleşmesinden Application katmanı modeline dönüşüm
                var items = request.Items
                    .Select(i => new OrderItemInput(i.MenuItemId, i.Quantity, i.SpecialInstructions))
                    .ToList();

                var result = await sender.Send(new CreateOrderCommand(request.TableId, items, request.Notes), ct);
                return result.ToApiResult(data => Results.Created($"/api/orders/{data?.Id}", data));
            })
            .Produces<OrderDto>(StatusCodes.Status201Created)
            .ProducesProblem(StatusCodes.Status400BadRequest)
            .ProducesProblem(StatusCodes.Status404NotFound)
            .ProducesProblem(StatusCodes.Status409Conflict);

        group.MapPut("{orderId:int}/status", async (int orderId, UpdateOrderStatusRequest request, ISender sender, CancellationToken ct) =>
            {
                var result = await sender.Send(new UpdateOrderStatusCommand(orderId, request.NewStatus), ct);
                return result.ToApiResult();
            })
            .Produces<OrderDto>()
            .ProducesProblem(StatusCodes.Status400BadRequest)
            .ProducesProblem(StatusCodes.Status404NotFound)
            .ProducesProblem(StatusCodes.Status409Conflict);

        group.MapGet("kitchen", async (ISender sender, CancellationToken ct) =>
            {
                var result = await sender.Send(new GetKitchenOrdersQuery(), ct);
                return result.ToApiResult();
            })
            .Produces<List<OrderDto>>();

        return app;
    }
}

ResultHelper (bkz. Result Pattern) ResultType değerini HTTP durum koduna çevirir: NotFound → 404, Conflict → 409, Failure → 400.


8. Frameworks & Drivers Katmanı: Infrastructure

Repository, Read Service implementasyonları ve DbContext bu katmanda yer alır. Application katmanındaki arayüzleri implement eder; ama Application katmanı bu implementasyonları bilmez.

Yazma Tarafı: Repository ve Unit of Work (EF Core)

Repository yalnızca aggregate root’u yükler. Order aggregate’i OrderItems ile birlikte gelir; Update metodu yoktur, çünkü EF Core change tracking değişiklikleri SaveChangesAsync sırasında algılar.

// src/RestaurantManagement.Infrastructure/Repositories/OrderRepository.cs
using Microsoft.EntityFrameworkCore;
using RestaurantManagement.Application.Common.Interfaces;
using RestaurantManagement.Domain.Entities;
using RestaurantManagement.Infrastructure.Data;

namespace RestaurantManagement.Infrastructure.Repositories;

public sealed class OrderRepository(RestaurantDbContext context) : IOrderRepository
{
    public async Task<Order?> GetByIdAsync(int id, CancellationToken cancellationToken = default)
    {
        return await context.Orders
            .Include(o => o.OrderItems)
            .FirstOrDefaultAsync(o => o.Id == id, cancellationToken);
    }

    public async Task AddAsync(Order order, CancellationToken cancellationToken = default)
    {
        await context.Orders.AddAsync(order, cancellationToken);
    }

    public Task DeleteAsync(Order order, CancellationToken cancellationToken = default)
    {
        context.Orders.Remove(order);
        return Task.CompletedTask;
    }
}
// src/RestaurantManagement.Infrastructure/Repositories/UnitOfWork.cs
using Microsoft.EntityFrameworkCore.Storage;
using RestaurantManagement.Application.Common.Interfaces;
using RestaurantManagement.Infrastructure.Data;

namespace RestaurantManagement.Infrastructure.Repositories;

public sealed class UnitOfWork(
    RestaurantDbContext context,
    ITableRepository tableRepository,
    IMenuItemRepository menuItemRepository,
    IOrderRepository orderRepository) : IUnitOfWork
{
    private IDbContextTransaction? _transaction;

    public ITableRepository Tables => tableRepository;
    public IMenuItemRepository MenuItems => menuItemRepository;
    public IOrderRepository Orders => orderRepository;

    public async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
    {
        try
        {
            return await context.SaveChangesAsync(cancellationToken);
        }
        catch (DbUpdateConcurrencyException)
        {
            // EF Core'a özgü exception Application katmanına sızmaz
            throw new InvalidOperationException("The data was modified by another request. Please retry.");
        }
    }

    public async Task BeginTransactionAsync(CancellationToken cancellationToken = default) =>
        _transaction = await context.Database.BeginTransactionAsync(cancellationToken);

    public async Task CommitTransactionAsync(CancellationToken cancellationToken = default)
    {
        if (_transaction != null)
        {
            await _transaction.CommitAsync(cancellationToken);
            await _transaction.DisposeAsync();
            _transaction = null;
        }
    }

    public async Task RollbackTransactionAsync(CancellationToken cancellationToken = default)
    {
        if (_transaction != null)
        {
            await _transaction.RollbackAsync(cancellationToken);
            await _transaction.DisposeAsync();
            _transaction = null;
        }
    }
}

Okuma Tarafı: Read Service (Dapper)

Okuma tarafı aggregate yüklemez; SQL ile doğrudan DTO üretir. Böylece sorgular domain modelinden ve EF Core Include zincirlerinden bağımsız olarak optimize edilebilir. Dapper, yalnızca bu katmanda kullanılır; Application katmanı IOrderReadService arayüzünü bilir.

// src/RestaurantManagement.Infrastructure/Data/DbConnectionFactory.cs
using System.Data.Common;
using Microsoft.Data.Sqlite;

namespace RestaurantManagement.Infrastructure.Data;

public interface IDbConnectionFactory
{
    DbConnection CreateConnection();
}

public sealed class SqliteConnectionFactory(string connectionString) : IDbConnectionFactory
{
    public DbConnection CreateConnection() => new SqliteConnection(connectionString);
}
// src/RestaurantManagement.Infrastructure/ReadServices/DapperTableReadService.cs
using Dapper;
using RestaurantManagement.Application.Common.DTOs;
using RestaurantManagement.Application.Common.Interfaces;
using RestaurantManagement.Domain.Entities;
using RestaurantManagement.Infrastructure.Data;

namespace RestaurantManagement.Infrastructure.ReadServices;

public sealed class DapperTableReadService(IDbConnectionFactory connectionFactory) : ITableReadService
{
    public async Task<IReadOnlyList<TableDto>> GetAllAsync(CancellationToken cancellationToken = default)
    {
        const string sql = """
            SELECT Id, TableNumber, Capacity, Status, ReservedAt
            FROM Tables
            ORDER BY TableNumber
            """;

        await using var connection = connectionFactory.CreateConnection();
        await connection.OpenAsync(cancellationToken);

        var rows = await connection.QueryAsync<TableRow>(
            new CommandDefinition(sql, cancellationToken: cancellationToken));

        return rows
            .Select(r => new TableDto(r.Id, r.TableNumber, r.Capacity, ((TableStatus)r.Status).ToString(), r.ReservedAt))
            .ToList();
    }

    private sealed class TableRow
    {
        public int Id { get; set; }
        public int TableNumber { get; set; }
        public int Capacity { get; set; }
        public int Status { get; set; }
        public DateTime? ReservedAt { get; set; }
    }
}

DapperOrderReadService aynı yaklaşımla Orders ve OrderItems tablolarını (MenuItems ile join yaparak) iki sorguda okuyup OrderDto listesine dönüştürür; tam hali örnek projededir.


9. Dependency Injection Konfigürasyonu

Her katman kendi servislerini bir extension metodla kaydeder. Program.cs (composition root) yalnızca bu metotları çağırır; Infrastructure’ın Application arayüzlerini implement ettiğini DI container’a bildiren yer bu katmandır. Bu dosyalar Frameworks & Drivers katmanına aittir.

// src/RestaurantManagement.Application/DependencyInjection.cs
using FluentValidation;
using Microsoft.Extensions.DependencyInjection;
using RestaurantManagement.Application.Common.Behaviors;

namespace RestaurantManagement.Application;

public static class DependencyInjection
{
    public static IServiceCollection AddApplication(this IServiceCollection services)
    {
        services.AddValidatorsFromAssembly(typeof(DependencyInjection).Assembly, ServiceLifetime.Scoped);

        services.AddMediator(options =>
        {
            options.ServiceLifetime = ServiceLifetime.Scoped;
            options.PipelineBehaviors = [typeof(ValidationBehavior<,>)];
        });

        return services;
    }
}
// src/RestaurantManagement.Infrastructure/DependencyInjection.cs
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using RestaurantManagement.Application.Common.Interfaces;
using RestaurantManagement.Infrastructure.Data;
using RestaurantManagement.Infrastructure.ReadServices;
using RestaurantManagement.Infrastructure.Repositories;

namespace RestaurantManagement.Infrastructure;

public static class DependencyInjection
{
    public static IServiceCollection AddInfrastructure(this IServiceCollection services, string connectionString)
    {
        services.AddDbContext<RestaurantDbContext>(options =>
            options.UseSqlite(connectionString));

        // Okuma tarafı (Dapper)
        services.AddSingleton<IDbConnectionFactory>(new SqliteConnectionFactory(connectionString));
        services.AddScoped<IOrderReadService, DapperOrderReadService>();
        services.AddScoped<ITableReadService, DapperTableReadService>();
        services.AddScoped<IMenuItemReadService, DapperMenuItemReadService>();

        // Yazma tarafı (EF Core)
        services.AddScoped<ITableRepository, TableRepository>();
        services.AddScoped<IMenuItemRepository, MenuItemRepository>();
        services.AddScoped<IOrderRepository, OrderRepository>();
        services.AddScoped<IUnitOfWork, UnitOfWork>();

        return services;
    }
}
// src/RestaurantManagement.Api/Program.cs
using RestaurantManagement.Api.Endpoints;
using RestaurantManagement.Application;
using RestaurantManagement.Infrastructure;
using RestaurantManagement.Infrastructure.Data;
using Scalar.AspNetCore;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddEndpointsApiExplorer();
builder.Services.AddOpenApi();
builder.Services.AddAuthorization();

builder.Services.AddInfrastructure(
    builder.Configuration.GetConnectionString("RestaurantDb") ?? "Data Source=restaurant.db");

builder.Services.AddApplication();

var app = builder.Build();

// Seed database
using (var scope = app.Services.CreateScope())
{
    var context = scope.ServiceProvider.GetRequiredService<RestaurantDbContext>();
    await context.Database.EnsureCreatedAsync().ConfigureAwait(false);
}

if (app.Environment.IsDevelopment())
{
    app.MapOpenApi();
    app.MapScalarApiReference();
}

app.UseHttpsRedirection();
app.UseAuthorization();
app.MapMenuItemEndpoints();
app.MapOrderEndpoints();
app.MapTableEndpoints();

await app.RunAsync().ConfigureAwait(false);

public partial class Program { }

Handler’lar ve validator’lar assembly taraması / source generator ile otomatik bulunur; yeni bir use case için Program.cs veya DI dosyalarında değişiklik gerekmez.


10. Mimari Testler (Architecture Tests)

Dependency Rule’un yalnızca dokümanla değil, otomatik testlerle korunması gerekir. Örnek projedeki RestaurantManagement.Api.ArchTests projesi NetArchTest.Rules ile kuralları derleme sonrası doğrular; bir kural ihlal edilirse test başarısız olur ve ihlal eden tipler hata mesajında listelenir.

// test/RestaurantManagement.Api.ArchTests/LayerDependencyTests.cs
public class LayerDependencyTests
{
    [Fact]
    public void Domain_ShouldNotDependOnOtherLayers() =>
        AssertNoDependency(DomainAssembly, ApplicationNs, InfrastructureNs, ApiNs);

    [Fact]
    public void Application_ShouldNotDependOnInfrastructureOrApi() =>
        AssertNoDependency(ApplicationAssembly, InfrastructureNs, ApiNs);

    [Fact]
    public void Infrastructure_ShouldNotDependOnApi() =>
        AssertNoDependency(InfrastructureAssembly, ApiNs);

    [Fact]
    public void Application_ShouldNotDependOnInfrastructurePackages() =>
        AssertNoDependency(ApplicationAssembly, [.. InfrastructurePackages, "Microsoft.AspNetCore"]);
}

Katman bağımlılıklarının dışında her katman için konvansiyon kuralları da bulunur:

Katman Örnek kurallar
Domain Entity’ler BaseEntity‘den türemeli; public setter olmamalı; Dto, Command, Handler gibi ekler kullanılmamalı
Application Her command/query için tam olarak bir handler olmalı; handler’lar sealed olmalı ve birbirine bağımlı olmamalı; command/query’ler immutable olmalı
Infrastructure Repository’ler ve read service’ler Application arayüzünü implement etmeli; yazma tarafı okuma tarafına bağımlı olmamalı
Api Endpoint’ler Domain’e, repository’lere ve DbContext‘e bağımlı olmamalı; contract’lar Request/Response ile bitmeli

Projedeki diğer test projeleri: Api.Tests (domain, handler, validator ve behavior birim testleri), Api.IntegrationTests (repository, read service ve UnitOfWork — SQLite), Api.FunctionalTests (WebApplicationFactory ile uçtan uca endpoint testleri).


11. Örnek Proje Referansı

Bu yazıdaki tüm kod örnekleri aşağıdaki GitHub deposundan alınmıştır:

🔗 DTVegaArchChapter/ArchitecturePatterns — Clean Architecture

Çalıştırmak için:

git clone https://github.com/DTVegaArchChapter/ArchitecturePatterns.git
cd ArchitecturePatterns/Examples/Clean
dotnet run --project src/RestaurantManagement.Api

API Scalar arayüzüne https://localhost:{port}/scalar adresinden erişebilirsiniz. Proje SQLite kullanır; veritabanı (restaurant.db) ilk çalışmada otomatik oluşturulur.

Testleri çalıştırmak için:

dotnet test

12. Clean Architecture vs Onion Architecture vs Hexagonal Architecture

Özellik Clean Architecture Onion Architecture Hexagonal Architecture
Kaynakça Robert C. Martin (Uncle Bob), 2012 Jeffrey Palermo, 2008 Alistair Cockburn, 2005
Katman sayısı 4 (Entities, Use Cases, Interface Adapters, Frameworks & Drivers) Esnek (Domain, Application, Infrastructure, Presentation) 3 (Domain, Ports, Adapters)
Uygulama katmanı Use Case’ler (Command/Query + Handler; Mediator tercih edilebilir) Command/Query Handler (CQRS + Mediator) Port + Use Case
Interface Adapters Açıkça tanımlanmış katman Presentation katmanına dahil Adapter sınıfları
Bağımlılık yönü İçe doğru İçe doğru Domain’e doğru
Birincil odak Use Case izolasyonu Domain modeli Port/Adapter ayrımı
Mediator/CQRS Zorunlu değil; örnek projede kullanılır Yaygın tercih Gerekmez
Test edilebilirlik Çok yüksek Çok yüksek Çok yüksek
Başlangıç karmaşıklığı Orta-Yüksek Orta-Yüksek Orta
Uygun proje tipi Domain karmaşık, çoklu UI Domain karmaşık, uzun ömürlü Çoklu dış sistem entegrasyonu

Özet Karar Rehberi

Basit CRUD, hız öncelik?
  → Vertical Slice Architecture

Çoklu dış sistem (API, mesaj kuyruğu, dosya) entegrasyonu kritik?
  → Hexagonal Architecture

CQRS/Mediator pipeline tercih edilir, büyük domain modeli?
  → Onion Architecture veya Clean Architecture (ikisi de CQRS ile uyumludur)

Use Case izolasyonu öncelik, framework bağımsızlığı kritik, çoklu UI?
  → Clean Architecture

13. Anti-Patterns ve Yaygın Hatalar

Handler içinde doğrudan DbContext kullanımı

// ❌ YANLIŞ — Handler Infrastructure'a bağımlı; test edilemez
public sealed class CreateOrderCommandHandler(RestaurantDbContext context)
    : ICommandHandler<CreateOrderCommand, Result<OrderDto>>
{
    public async ValueTask<Result<OrderDto>> Handle(CreateOrderCommand command, CancellationToken ct)
    {
        var table = await context.Tables.FindAsync(command.TableId);
        // ...
    }
}

// ✅ DOĞRU — Handler arayüze bağımlı; Infrastructure değiştirilebilir
public sealed class CreateOrderCommandHandler(IUnitOfWork unitOfWork)
    : ICommandHandler<CreateOrderCommand, Result<OrderDto>>
{
    public async ValueTask<Result<OrderDto>> Handle(CreateOrderCommand command, CancellationToken ct)
    {
        var table = await unitOfWork.Tables.GetByIdAsync(command.TableId, ct);
        // ...
    }
}

Domain entity içinde iş mantığı yerine servis çağrısı

// ❌ YANLIŞ — Entity dış servise bağımlı; Dependency Rule ihlali
public class Order
{
    public void StartPreparation(INotificationService notificationService)
    {
        Status = OrderStatus.InPreparation;
        notificationService.NotifyKitchen(this); // Domain'de infrastructure!
    }
}

// ✅ DOĞRU — Entity yalnızca kuralı uygular; çağrı Handler içinde yapılır
public class Order
{
    public DomainResult StartPreparation()
    {
        if (Status != OrderStatus.Pending)
            return DomainResult.Failure("...");

        Status = OrderStatus.InPreparation;
        return DomainResult.Success();
    }
}
// Handler içinde: order.StartPreparation(); await notificationService.NotifyKitchenAsync(order);

Katmanlar arasında doğrudan entity geçirme (API → Domain)

// ❌ YANLIŞ — Endpoint Domain entity'sini döndürüyor
group.MapGet("{id}", async (int id, IOrderRepository orders) =>
    await orders.GetByIdAsync(id)); // Order domain entity'si!

// ✅ DOĞRU — Interface Adapters yalnızca DTO dönen Query gönderir
group.MapGet("{id}", async (int id, ISender sender, CancellationToken ct) =>
{
    var result = await sender.Send(new GetOrderQuery(id), ct);
    return result.ToApiResult(); // OrderDto döner, Order değil
});

Query’lerde aggregate yükleme

// ❌ YANLIŞ — Okuma için aggregate yükleyip DTO'ya map etmek (gereksiz Include, change tracking)
public sealed class GetKitchenOrdersQueryHandler(IUnitOfWork unitOfWork) { ... }

// ✅ DOĞRU — Okuma tarafı Read Service ile doğrudan DTO üretir
public sealed class GetKitchenOrdersQueryHandler(IOrderReadService orderReadService)
    : IQueryHandler<GetKitchenOrdersQuery, Result<List<OrderDto>>> { ... }

Beklenen iş kuralı ihlallerinde exception fırlatmak

// ❌ YANLIŞ — Akış kontrolü için exception; her yerde try/catch gerektirir
public void Serve()
{
    if (Status != OrderStatus.Ready)
        throw new InvalidOperationException("Cannot serve order");
    Status = OrderStatus.Served;
}

// ✅ DOĞRU — DomainResult döndür; Handler bunu Result<T>.Conflict'e çevirir
public DomainResult Serve()
{
    if (Status != OrderStatus.Ready)
        return DomainResult.Failure($"Cannot serve order with status: {Status}");

    Status = OrderStatus.Served;
    return DomainResult.Success();
}

Tüm Use Case’leri tek bir God Handler içinde birleştirme

// ❌ YANLIŞ — OrderHandler tüm order işlemlerini biliyor
public class OrderHandler
{
    public Task CreateAsync(...) { ... }
    public Task UpdateStatusAsync(...) { ... }
    public Task GetKitchenOrdersAsync() { ... }
    public Task CancelAsync(...) { ... }
}

// ✅ DOĞRU — Her senaryo izole, bağımsız bir Command/Query + Handler çifti
public sealed class CreateOrderCommandHandler : ICommandHandler<CreateOrderCommand, Result<OrderDto>> { ... }
public sealed class UpdateOrderStatusCommandHandler : ICommandHandler<UpdateOrderStatusCommand, Result<OrderDto>> { ... }
public sealed class GetKitchenOrdersQueryHandler : IQueryHandler<GetKitchenOrdersQuery, Result<List<OrderDto>>> { ... }

FAQ

S: Clean Architecture ile Onion Architecture arasındaki temel fark nedir?

C: İkisi de bağımlılıkları içe doğru yönlendirir ve aynı temel prensibi paylaşır. Onion Architecture katman isimlerine daha az önem verir, CQRS/Mediator ile sıkça birleştirilir ve repository arayüzlerini Domain katmanında tanımlar. Clean Architecture ise Interface Adapters katmanını açıkça adlandırır, Use Case sınıflarını ön plana çıkarır ve “Use Case izolasyonu” kavramını merkeze alır. Pratik uygulamada farklar küçüktür; her ikisi de aynı testedilebilirlik ve framework bağımsızlığı hedefini taşır.

S: Use Case’ler için Mediator/CQRS şart mı?

C: Hayır. Uncle Bob’un orijinal Clean Architecture tanımında Mediator yoktur; Use Case’ler doğrudan DI container’a kayıtlı sınıflar olarak da yazılabilir ve Controller/Endpoint’e inject edilebilir. Örnek projede ise Mediator kullanılır; çünkü Api katmanını handler sınıflarından ayırır ve ValidationBehavior gibi cross-cutting concern’leri pipeline olarak merkezi yönetmeyi sağlar. CQRS de zorunlu değildir; ancak okuma ve yazma tarafını ayrı modellemek (Repository + Read Service) sorgu performansını ve domain modelinin sadeliğini artırır.

S: Okuma tarafında neden EF Core yerine Dapper (Read Service) kullanılıyor?

C: Okuma tarafı iş kuralı çalıştırmaz; yalnızca DTO üretir. Aggregate yüklemek (Include, change tracking) gereksiz maliyettir. Read Service arayüzü Application katmanında tanımlıdır, Dapper yalnızca Infrastructure’da kalır. İsterseniz aynı arayüzü EF Core AsNoTracking sorgularıyla da implement edebilirsiniz; Application katmanı etkilenmez.

S: Repository arayüzleri neden Infrastructure katmanında değil Application katmanında tanımlanır?

C: Dependency Rule gereği; Use Cases katmanı (Application) dış katmanları (Infrastructure) bilmemelidir. Arayüz Application’da tanımlanır, implementasyon Infrastructure’da yapılır. Bu sayede Infrastructure tamamen değiştirilse bile Application katmanı etkilenmez. Dependency Inversion Principle’ın doğrudan uygulamasıdır.

S: Domain entity’lerine EF Core navigasyon özellikleri eklemek Dependency Rule’u ihlal eder mi?

C: Teknik olarak evet; ancak bu pragmatik bir uzlaşıdır. Domain entity’si EF Core attribute’larından bağımsız kalır (sadece POCO class), navigasyon özellikleri sadece C# referanslarıdır. EF Core konfigürasyonu OnModelCreating içinde Infrastructure katmanında yapılır; Domain katmanında using Microsoft.EntityFrameworkCore; bulunmaz. Bu yaklaşım çoğu projede kabul görür.

S: Her Use Case için ayrı bir sınıf oluşturmak çok fazla dosya yaratmıyor mu?

C: Evet, dosya sayısı artar. Ancak her sınıfın tek sorumluluğu olduğundan test yazımı, değişiklik takibi ve kod incelemesi kolaylaşır. CreateOrderCommandHandler değiştiğinde GetKitchenOrdersQueryHandler etkilenmez; mimari testler handler’ların birbirine bağımlı olmasını da engeller. Bu tradeoff, orta-büyük projelerde net bir avantajdır; küçük projelerde Vertical Slice Architecture daha pratik olabilir.

S: Clean Architecture’da event sourcing veya domain event nasıl eklenir?

C: Domain event’ler Entities katmanına eklenir; Use Case execute edildikten sonra yayınlanır. Örnek: order.AddDomainEvent(new OrderCreatedEvent(order.Id)). Event handler’lar Application katmanında tanımlanır. Bu yapı Clean Architecture ile uyumludur çünkü event yayınlama mekanizması Use Cases aracılığıyla çalışır; Domain hiçbir event bus kütüphanesine bağımlı değildir.


Kaynaklar