Clean Architecture, Robert C. Martin (Uncle Bob) tarafından önerilen, yazılım mimarisinde bağımlılıkları yönetme ve sürdürülebilir kod yazma prensiplerini içeren bir yaklaşımdır. Bu rehberde .NET projelerinde Clean Architecture'ın nasıl uygulanacağını kapsamlı bir şekilde öğreneceksiniz.
Clean Architecture Nedir?
Temel Amaç
Clean Architecture'ın temel amacı iş mantığını dış dünyadan izole etmektir. Bu sayede:
- Framework bağımsızlığı sağlanır
- Test edilebilirlik artar
- UI değişikliklerinden etkilenmez
- Veritabanı teknolojisinden bağımsız olur
Bağımlılık Kuralı
En Önemli Kural
İç katmanlar dış katmanları bilemez! Bağımlılık yönü her zaman dıştan içe doğru olmalıdır.
.NET Clean Architecture Katmanları
Domain Layer
Coreİş kurallarının kalbi - En merkezi katman
İçerdikleri:
Application Layer
Use CasesUygulama iş kuralları - Domain'i orkestra eder
İçerdikleri:
Infrastructure Layer
ExternalDış dünya bağlantıları - Framework implementasyonları
İçerdikleri:
Presentation Layer
UIKullanıcı arayüzü - Dış dünya ile etkileşim
İçerdikleri:
Bağımlılık Akışı
Proje Yapısı
Kod Örnekleri
Domain Layer - Entity Örneği
public class Product : BaseEntity
{
public string Name { get; private set; }
public decimal Price { get; private set; }
public string Description { get; private set; }
public bool IsActive { get; private set; }
private readonly List<DomainEvent> _events = new();
public IReadOnlyCollection<DomainEvent> Events => _events.AsReadOnly();
private Product() { } // EF Core için
public Product(string name, decimal price, string description)
{
if (string.IsNullOrEmpty(name))
throw new ArgumentException("Product name cannot be empty");
if (price <= 0)
throw new ArgumentException("Price must be greater than zero");
Name = name;
Price = price;
Description = description;
IsActive = true;
_events.Add(new ProductCreatedEvent(this));
}
public void UpdatePrice(decimal newPrice)
{
if (newPrice <= 0)
throw new ArgumentException("Price must be greater than zero");
var oldPrice = Price;
Price = newPrice;
_events.Add(new ProductPriceUpdatedEvent(this, oldPrice, newPrice));
}
public void Deactivate()
{
IsActive = false;
_events.Add(new ProductDeactivatedEvent(this));
}
}
Application Layer - Use Case Örneği
public record CreateProductCommand(
string Name,
decimal Price,
string Description
) : ICommand<ProductDto>;
public class CreateProductCommandHandler : ICommandHandler<CreateProductCommand, ProductDto>
{
private readonly IProductRepository _productRepository;
private readonly IUnitOfWork _unitOfWork;
private readonly IMapper _mapper;
private readonly ILogger<CreateProductCommandHandler> _logger;
public CreateProductCommandHandler(
IProductRepository productRepository,
IUnitOfWork unitOfWork,
IMapper mapper,
ILogger<CreateProductCommandHandler> logger)
{
_productRepository = productRepository;
_unitOfWork = unitOfWork;
_mapper = mapper;
_logger = logger;
}
public async Task<ProductDto> Handle(CreateProductCommand request, CancellationToken cancellationToken)
{
_logger.LogInformation("Creating product with name: {Name}", request.Name);
// Business rule validation
var existingProduct = await _productRepository.GetByNameAsync(request.Name);
if (existingProduct != null)
throw new BusinessRuleException("Product with this name already exists");
// Create domain entity
var product = new Product(request.Name, request.Price, request.Description);
// Save to repository
await _productRepository.AddAsync(product);
await _unitOfWork.SaveChangesAsync(cancellationToken);
_logger.LogInformation("Product created successfully with ID: {Id}", product.Id);
return _mapper.Map<ProductDto>(product);
}
}
Infrastructure Layer - Repository Örneği
public class ProductRepository : IProductRepository
{
private readonly ApplicationDbContext _context;
public ProductRepository(ApplicationDbContext context)
{
_context = context;
}
public async Task<Product?> GetByIdAsync(int id)
{
return await _context.Products
.FirstOrDefaultAsync(p => p.Id == id);
}
public async Task<Product?> GetByNameAsync(string name)
{
return await _context.Products
.FirstOrDefaultAsync(p => p.Name == name);
}
public async Task<IEnumerable<Product>> GetActiveProductsAsync()
{
return await _context.Products
.Where(p => p.IsActive)
.ToListAsync();
}
public async Task AddAsync(Product product)
{
await _context.Products.AddAsync(product);
}
public void Update(Product product)
{
_context.Products.Update(product);
}
public void Delete(Product product)
{
_context.Products.Remove(product);
}
}
Presentation Layer - Controller Örneği
[ApiController]
[Route("api/[controller]")]
public class ProductsController : ControllerBase
{
private readonly IMediator _mediator;
public ProductsController(IMediator mediator)
{
_mediator = mediator;
}
[HttpPost]
public async Task<ActionResult<ProductDto>> CreateProduct(CreateProductCommand command)
{
try
{
var result = await _mediator.Send(command);
return CreatedAtRoute(nameof(GetProduct), new { id = result.Id }, result);
}
catch (BusinessRuleException ex)
{
return BadRequest(ex.Message);
}
}
[HttpGet("{id}", Name = nameof(GetProduct))]
public async Task<ActionResult<ProductDto>> GetProduct(int id)
{
var query = new GetProductByIdQuery(id);
var result = await _mediator.Send(query);
if (result == null)
return NotFound();
return Ok(result);
}
[HttpGet]
public async Task<ActionResult<IEnumerable<ProductDto>>> GetProducts()
{
var query = new GetActiveProductsQuery();
var result = await _mediator.Send(query);
return Ok(result);
}
}
CQRS ve MediatR Entegrasyonu
CQRS (Command Query Responsibility Segregation)
CQRS, okuma ve yazma işlemlerini ayrı modeller halinde organize etme yaklaşımıdır. Clean Architecture ile mükemmel uyum sağlar.
CQRS + Clean Architecture Avantajları
Ayrım (Separation)
Okuma ve yazma işlemleri birbirinden bağımsız
Ölçeklenebilirlik
Her işlem türü bağımsız olarak optimize edilebilir
Daha Temiz Kod
Her handler tek bir sorumluluğa sahip
Test Edilebilirlik
Her use case ayrı ayrı test edilebilir
MediatR Konfigürasyonu
// MediatR registration
builder.Services.AddMediatR(cfg => {
cfg.RegisterServicesFromAssembly(typeof(CreateProductCommand).Assembly);
});
// FluentValidation registration
builder.Services.AddFluentValidationAutoValidation();
builder.Services.AddValidatorsFromAssembly(typeof(CreateProductCommandValidator).Assembly);
// AutoMapper registration
builder.Services.AddAutoMapper(typeof(ProductMappingProfile).Assembly);
// Repository registrations
builder.Services.AddScoped<IProductRepository, ProductRepository>();
builder.Services.AddScoped<IUnitOfWork, UnitOfWork>();
Domain Driven Design (DDD) Entegrasyonu
DDD Temel Kavramları
Entity
Benzersiz kimliği olan ve yaşam döngüsü boyunca takip edilen nesneler
Value Object
Kimliği olmayan, değerleriyle tanımlanan değişmez nesneler
Aggregate
İş kuralları açısından bir bütün olarak ele alınan nesne grubu
Domain Service
Tek bir entity'ye ait olmayan domain mantığını içeren servisler
Value Object Örneği
public class Money : ValueObject
{
public decimal Amount { get; private set; }
public string Currency { get; private set; }
private Money() { } // EF Core için
public Money(decimal amount, string currency)
{
if (amount < 0)
throw new ArgumentException("Amount cannot be negative");
if (string.IsNullOrEmpty(currency))
throw new ArgumentException("Currency cannot be empty");
Amount = amount;
Currency = currency;
}
public static Money operator +(Money a, Money b)
{
if (a.Currency != b.Currency)
throw new InvalidOperationException("Cannot add different currencies");
return new Money(a.Amount + b.Amount, a.Currency);
}
protected override IEnumerable<object> GetEqualityComponents()
{
yield return Amount;
yield return Currency;
}
public override string ToString() => $"{Amount:C} {Currency}";
}
Domain Events
public class ProductCreatedEvent : DomainEvent
{
public Product Product { get; }
public ProductCreatedEvent(Product product)
{
Product = product;
}
}
public class ProductCreatedEventHandler : INotificationHandler<ProductCreatedEvent>
{
private readonly IEmailService _emailService;
private readonly ILogger<ProductCreatedEventHandler> _logger;
public ProductCreatedEventHandler(IEmailService emailService, ILogger<ProductCreatedEventHandler> logger)
{
_emailService = emailService;
_logger = logger;
}
public async Task Handle(ProductCreatedEvent notification, CancellationToken cancellationToken)
{
_logger.LogInformation("Product created: {ProductName}", notification.Product.Name);
// Send notification email to administrators
await _emailService.SendProductCreatedNotificationAsync(notification.Product);
}
}
Test Stratejisi
Test Piramidi
Test Örnekleri
Unit Test Örneği
public class ProductTests
{
[Fact]
public void CreateProduct_WithValidData_ShouldCreateSuccessfully()
{
// Arrange
var name = "Test Product";
var price = 100m;
var description = "Test Description";
// Act
var product = new Product(name, price, description);
// Assert
product.Name.Should().Be(name);
product.Price.Should().Be(price);
product.Description.Should().Be(description);
product.IsActive.Should().BeTrue();
product.Events.Should().ContainSingle(e => e is ProductCreatedEvent);
}
[Theory]
[InlineData("")]
[InlineData(null)]
public void CreateProduct_WithInvalidName_ShouldThrowException(string invalidName)
{
// Act & Assert
var action = () => new Product(invalidName, 100m, "Description");
action.Should().Throw<ArgumentException>();
}
}
Integration Test Örneği
public class CreateProductCommandHandlerTests : IntegrationTestBase
{
[Fact]
public async Task Handle_WithValidCommand_ShouldCreateProduct()
{
// Arrange
var command = new CreateProductCommand("Test Product", 100m, "Description");
// Act
var result = await Mediator.Send(command);
// Assert
result.Should().NotBeNull();
result.Name.Should().Be(command.Name);
var productInDb = await Context.Products.FirstOrDefaultAsync(p => p.Id == result.Id);
productInDb.Should().NotBeNull();
}
[Fact]
public async Task Handle_WithDuplicateName_ShouldThrowException()
{
// Arrange
var existingProduct = new Product("Existing Product", 50m, "Description");
Context.Products.Add(existingProduct);
await Context.SaveChangesAsync();
var command = new CreateProductCommand("Existing Product", 100m, "New Description");
// Act & Assert
var action = async () => await Mediator.Send(command);
await action.Should().ThrowAsync<BusinessRuleException>();
}
}
Clean Architecture Avantajları
Test Edilebilirlik
Her katman bağımsız olarak test edilebilir
Sürdürülebilirlik
Kod değişiklikleri lokalize edilmiş
Esneklik
Framework ve teknoloji değişikliklerine açık
Ölçeklenebilirlik
Büyük ekipler paralel çalışabilir
İş Odaklılık
Domain mantığı merkeze alınmış
Bağımsızlık
Framework ve external dependency'lerden bağımsız
Dikkat Edilmesi Gerekenler
Karmaşıklık
Küçük projeler için over-engineering olabilir
Öğrenme Eğrisi
Yeni geliştiriciler için öğrenme süreci gerekli
İlk Kurulum
Başlangıçta daha fazla kod yazmanız gerekir
Ekip Uyumu
Tüm ekibin aynı prensipleri takip etmesi gerekli
En İyi Pratikler
Interface Segregation
Repository interface'lerini küçük ve odaklanmış tutun
Rich Domain Model
Entity'ler sadece data container değil, behavior içermeli
Fail Fast
Validation'ları mümkün olduğunca erken yapın
Dependency Injection
Constructor injection kullanın, service locator anti-pattern'den kaçının
Sonuç
.NET Clean Architecture, sürdürülebilir, test edilebilir ve esnek uygulamalar geliştirmek için güçlü bir yaklaşımdır. SOLID prensiplerine dayanan bu mimari, özellikle orta ve büyük ölçekli projeler için büyük avantajlar sağlar.
Ana Çıkarımlar
- Domain mantığını merkeze alın ve koruyun
- Bağımlılık yönünü her zaman dıştan içe doğru tutun
- CQRS ve MediatR ile use case'leri net bir şekilde ayrın
- DDD prensipleriyle domain modeling yapın
- Kapsamlı test stratejisi uygulayın
- Proje büyüklüğüne göre complexity seviyesini ayarlayın