.NET ile Onion Architecture

Neden Onion Architecture?
Bir proje büyüdükçe en sık rastlanan sorun şudur: controller'lar EF entity'lerini doğrudan döner, servisler DbContext'i içeriden çeker, iş mantığı ile altyapı kodu birbirine geçer. Bu noktada tek bir değişiklik — mesela ORM değişimi veya cache katmanı eklenmesi — projenin yarısına dokunmayı gerektirir.
Onion Architecture bu sorunu bağımlılık yönünü sabitleyerek çözer: dış katmanlar içe bağımlıdır, içteki katmanlar dışarıyı asla görmez. Domain ve Application katmanları hiçbir altyapı detayından haberdar olmaz; sadece interface'ler aracılığıyla dış dünyayla konuşur.
Somut kazanımlar:
- İzolasyon: EF Core'u değiştirirseniz Domain ve Application koduna dokunmazsınız.
- Test edilebilirlik: Mock ile gerçek veritabanı olmadan tüm iş mantığını test edebilirsiniz.
- Paralel geliştirme: Net sorumluluklar sayesinde takım üyeleri birbirinin çalışmasını bozmaz.
Proje Yapısı
Klasik tek-Infrastructure yapısının yerine, endişeleri daha net ayırmak için yedi proje kullanıyoruz. Projeleri üç klasör altında grupluyoruz:
MyApp/
├── Core/
│ ├── MyApp.Domain/
│ └── MyApp.Application/
│
├── Infrastructure/
│ ├── MyApp.Persistence/
│ └── MyApp.Infrastructure/
│
└── Presentation/
└── MyApp.API/
Solution dosyasını oluştururken projeleri bu klasörlere yerleştirin:
BASH1dotnet new sln -n MyApp 2 3# Core 4dotnet new classlib -n MyApp.Domain -o Core/MyApp.Domain 5dotnet new classlib -n MyApp.Application -o Core/MyApp.Application 6 7# Infrastructure 8dotnet new classlib -n MyApp.Persistence -o Infrastructure/MyApp.Persistence 9dotnet new classlib -n MyApp.Infrastructure -o Infrastructure/MyApp.Infrastructure 10 11# Presentation 12dotnet new webapi -n MyApp.API -o Presentation/MyApp.API 13 14# Solution'a ekle 15dotnet sln add Core/MyApp.Domain/MyApp.Domain.csproj 16dotnet sln add Core/MyApp.Application/MyApp.Application.csproj 17dotnet sln add Infrastructure/MyApp.Persistence/MyApp.Persistence.csproj 18dotnet sln add Infrastructure/MyApp.Infrastructure/MyApp.Infrastructure.csproj 19dotnet sln add Presentation/MyApp.API/MyApp.API.csproj 20 21# Referanslar 22dotnet add Core/MyApp.Application reference Core/MyApp.Domain 23dotnet add Infrastructure/MyApp.Persistence reference Core/MyApp.Application 24dotnet add Infrastructure/MyApp.Infrastructure reference Core/MyApp.Application 25dotnet add Presentation/MyApp.API reference Core/MyApp.Application 26dotnet add Presentation/MyApp.API reference Infrastructure/MyApp.Persistence 27dotnet add Presentation/MyApp.API reference Infrastructure/MyApp.Infrastructure
Not:
MyApp.APIhem Persistence hem de Infrastructure'a referans verir — ama bu referansların amacı yalnızca DI kayıtlarını yapmaktır. Servis implementasyonlarına doğrudan erişmez.
Core / MyApp.Domain
Domain katmanı projenin kalbidir. Hiçbir NuGet paketine bağımlı değildir. Saf C# sınıfları içerir.
Klasör yapısı
MyApp.Domain/
├── Entities/
│ └── Order.cs
├── ValueObjects/
│ ├── Money.cs
│ ├── OrderId.cs
│ └── CustomerId.cs
├── Enums/
│ └── OrderStatus.cs
├── Events/
│ └── OrderConfirmedDomainEvent.cs
└── Exceptions/
└── DomainException.cs
Entity
CSHARP1// Domain/Entities/Order.cs 2public sealed class Order 3{ 4 public OrderId Id { get; private set; } 5 public CustomerId CustomerId { get; private set; } 6 public OrderStatus Status { get; private set; } 7 8 private readonly List<OrderLine> _lines = []; 9 private readonly List<IDomainEvent> _domainEvents = []; 10 11 public IReadOnlyCollection<OrderLine> Lines => _lines.AsReadOnly(); 12 public IReadOnlyCollection<IDomainEvent> DomainEvents => _domainEvents.AsReadOnly(); 13 14 private Order() { } 15 16 public static Order Create(CustomerId customerId) 17 { 18 ArgumentNullException.ThrowIfNull(customerId); 19 20 var order = new Order 21 { 22 Id = new OrderId(Guid.NewGuid()), 23 CustomerId = customerId, 24 Status = OrderStatus.Draft 25 }; 26 27 return order; 28 } 29 30 public void AddLine(ProductId productId, int quantity, Money unitPrice) 31 { 32 if (Status != OrderStatus.Draft) 33 throw new DomainException("Onaylanmış siparişe satır eklenemez."); 34 35 if (quantity <= 0) 36 throw new DomainException("Miktar sıfırdan büyük olmalıdır."); 37 38 _lines.Add(new OrderLine(productId, quantity, unitPrice)); 39 } 40 41 public void Confirm() 42 { 43 if (_lines.Count == 0) 44 throw new DomainException("Boş sipariş onaylanamaz."); 45 46 if (Status != OrderStatus.Draft) 47 throw new DomainException("Sadece taslak sipariş onaylanabilir."); 48 49 Status = OrderStatus.Confirmed; 50 _domainEvents.Add(new OrderConfirmedDomainEvent(Id)); 51 } 52 53 public void ClearDomainEvents() => _domainEvents.Clear(); 54}
Value Object
CSHARP1// Domain/ValueObjects/Money.cs 2public sealed record Money 3{ 4 public decimal Amount { get; } 5 public string Currency { get; } 6 7 public Money(decimal amount, string currency) 8 { 9 if (amount < 0) throw new DomainException("Para tutarı negatif olamaz."); 10 if (string.IsNullOrWhiteSpace(currency)) throw new DomainException("Para birimi zorunludur."); 11 12 Amount = amount; 13 Currency = currency.ToUpperInvariant(); 14 } 15 16 public static Money operator +(Money a, Money b) 17 { 18 if (a.Currency != b.Currency) 19 throw new DomainException($"Farklı para birimleri toplanamaz: {a.Currency} ve {b.Currency}"); 20 21 return new Money(a.Amount + b.Amount, a.Currency); 22 } 23 24 public Money Multiply(int factor) => new(Amount * factor, Currency); 25}
CSHARP1// Domain/ValueObjects/OrderId.cs 2public sealed record OrderId(Guid Value) 3{ 4 public static OrderId New() => new(Guid.NewGuid()); 5 public override string ToString() => Value.ToString(); 6}
Domain Event
CSHARP1// Domain/Events/OrderConfirmedDomainEvent.cs 2public sealed record OrderConfirmedDomainEvent(OrderId OrderId) : IDomainEvent 3{ 4 public DateTime OccurredOn { get; } = DateTime.UtcNow; 5} 6 7public interface IDomainEvent 8{ 9 DateTime OccurredOn { get; } 10}
Domain katmanı repository interface'i tanımlamaz. Repository bir uygulama ihtiyacıdır — use case'ler veriye nasıl erişeceğini belirler. Bu yüzden
IOrderRepositoryApplication katmanında yaşar, Domain bu sözleşmeden habersizdir.
Core / MyApp.Application
Use case'leri barındırır. Yalnızca Domain'e referans verir. MediatR ve FluentValidation dışında altyapı bağımlılığı yoktur.
XML1<!-- MyApp.Application.csproj --> 2<PackageReference Include="MediatR" Version="12.*" /> 3<PackageReference Include="FluentValidation" Version="11.*" /> 4<PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="11.*" />
Klasör yapısı
MyApp.Application/
├── Common/
│ ├── Behaviors/
│ │ ├── ValidationPipelineBehavior.cs
│ │ └── LoggingPipelineBehavior.cs
│ ├── Interfaces/
│ │ ├── IUnitOfWork.cs
│ │ └── IEmailService.cs ← dış servis interface'leri burada
│ ├── Repositories/
│ │ └── IOrderRepository.cs ← repo interface'leri burada
│ └── Exceptions/
│ └── NotFoundException.cs
└── Orders/
├── Commands/
│ ├── CreateOrder/
│ │ ├── CreateOrderCommand.cs
│ │ ├── CreateOrderCommandHandler.cs
│ │ └── CreateOrderCommandValidator.cs
│ └── ConfirmOrder/
│ ├── ConfirmOrderCommand.cs
│ ├── ConfirmOrderCommandHandler.cs
│ └── ConfirmOrderCommandValidator.cs
└── Queries/
└── GetOrderById/
├── GetOrderByIdQuery.cs
├── GetOrderByIdQueryHandler.cs
└── OrderDto.cs
Repository ve Servis Interface'leri
CSHARP1// Application/Common/Repositories/IOrderRepository.cs 2public interface IOrderRepository 3{ 4 Task<Order?> GetByIdAsync(OrderId id, CancellationToken ct = default); 5 Task<IReadOnlyList<Order>> GetByCustomerIdAsync(CustomerId customerId, CancellationToken ct = default); 6 Task AddAsync(Order order, CancellationToken ct = default); 7 Task UpdateAsync(Order order, CancellationToken ct = default); 8}
CSHARP1// Application/Common/Interfaces/IUnitOfWork.cs 2public interface IUnitOfWork 3{ 4 Task<int> SaveChangesAsync(CancellationToken ct = default); 5} 6 7// Application/Common/Interfaces/IEmailService.cs 8public interface IEmailService 9{ 10 Task SendOrderConfirmedAsync(string to, OrderId orderId, CancellationToken ct = default); 11}
Repository interface'leri Application katmanında tanımlanır çünkü bir use case ihtiyacıdır: "sipariş oluşturulduğunda bunu bir yere kaydet" kararı Application'a aittir. Domain, verinin nasıl ya da nerede saklandığını bilmez. Implementasyonlar (
OrderRepository) Persistence katmanında yaşar.
Command ve Handler
CSHARP1// Application/Orders/Commands/CreateOrder/CreateOrderCommand.cs 2public sealed record CreateOrderCommand(Guid CustomerId) : IRequest<Guid>;
CSHARP1// Application/Orders/Commands/CreateOrder/CreateOrderCommandHandler.cs 2public sealed class CreateOrderCommandHandler( 3 IOrderRepository orderRepository, 4 IUnitOfWork unitOfWork) 5 : IRequestHandler<CreateOrderCommand, Guid> 6{ 7 public async Task<Guid> Handle( 8 CreateOrderCommand request, 9 CancellationToken cancellationToken) 10 { 11 var order = Order.Create(new CustomerId(request.CustomerId)); 12 13 await orderRepository.AddAsync(order, cancellationToken); 14 await unitOfWork.SaveChangesAsync(cancellationToken); 15 16 return order.Id.Value; 17 } 18}
CSHARP1// Application/Orders/Commands/ConfirmOrder/ConfirmOrderCommandHandler.cs 2public sealed class ConfirmOrderCommandHandler( 3 IOrderRepository orderRepository, 4 IUnitOfWork unitOfWork, 5 IEmailService emailService) 6 : IRequestHandler<ConfirmOrderCommand> 7{ 8 public async Task Handle(ConfirmOrderCommand request, CancellationToken cancellationToken) 9 { 10 var order = await orderRepository.GetByIdAsync(new OrderId(request.OrderId), cancellationToken) 11 ?? throw new NotFoundException(nameof(Order), request.OrderId); 12 13 order.Confirm(); // domain logic burada çalışır 14 15 await orderRepository.UpdateAsync(order, cancellationToken); 16 await unitOfWork.SaveChangesAsync(cancellationToken); 17 18 // domain event'ler işlenebilir ya da dispatcher'a gönderilebilir 19 await emailService.SendOrderConfirmedAsync( 20 "customer@example.com", 21 order.Id, 22 cancellationToken); 23 24 order.ClearDomainEvents(); 25 } 26}
Query ve DTO
CSHARP1// Application/Orders/Queries/GetOrderById/GetOrderByIdQuery.cs 2public sealed record GetOrderByIdQuery(Guid OrderId) : IRequest<OrderDto?>;
CSHARP1// Application/Orders/Queries/GetOrderById/OrderDto.cs 2public sealed record OrderDto( 3 Guid Id, 4 Guid CustomerId, 5 string Status, 6 decimal TotalAmount, 7 string Currency, 8 IReadOnlyList<OrderLineDto> Lines); 9 10public sealed record OrderLineDto( 11 Guid ProductId, 12 int Quantity, 13 decimal UnitPrice, 14 string Currency);
CSHARP1// Application/Orders/Queries/GetOrderById/GetOrderByIdQueryHandler.cs 2public sealed class GetOrderByIdQueryHandler(IOrderRepository orderRepository) 3 : IRequestHandler<GetOrderByIdQuery, OrderDto?> 4{ 5 public async Task<OrderDto?> Handle( 6 GetOrderByIdQuery request, 7 CancellationToken cancellationToken) 8 { 9 var order = await orderRepository.GetByIdAsync( 10 new OrderId(request.OrderId), cancellationToken); 11 12 if (order is null) return null; 13 14 var lines = order.Lines 15 .Select(l => new OrderLineDto( 16 l.ProductId.Value, 17 l.Quantity, 18 l.UnitPrice.Amount, 19 l.UnitPrice.Currency)) 20 .ToList(); 21 22 var total = order.Lines.Aggregate( 23 new Money(0, "TRY"), 24 (acc, l) => acc + l.UnitPrice.Multiply(l.Quantity)); 25 26 return new OrderDto( 27 order.Id.Value, 28 order.CustomerId.Value, 29 order.Status.ToString(), 30 total.Amount, 31 total.Currency, 32 lines); 33 } 34}
Validation
CSHARP1// Application/Orders/Commands/CreateOrder/CreateOrderCommandValidator.cs 2public sealed class CreateOrderCommandValidator : AbstractValidator<CreateOrderCommand> 3{ 4 public CreateOrderCommandValidator() 5 { 6 RuleFor(x => x.CustomerId) 7 .NotEmpty().WithMessage("Müşteri ID zorunludur."); 8 } 9}
Pipeline Behaviors
CSHARP1// Application/Common/Behaviors/ValidationPipelineBehavior.cs 2public sealed class ValidationPipelineBehavior<TRequest, TResponse>( 3 IEnumerable<IValidator<TRequest>> validators) 4 : IPipelineBehavior<TRequest, TResponse> 5 where TRequest : IRequest<TResponse> 6{ 7 public async Task<TResponse> Handle( 8 TRequest request, 9 RequestHandlerDelegate<TResponse> next, 10 CancellationToken cancellationToken) 11 { 12 var failures = validators 13 .Select(v => v.Validate(request)) 14 .SelectMany(r => r.Errors) 15 .Where(e => e is not null) 16 .ToList(); 17 18 if (failures.Count > 0) 19 throw new ValidationException(failures); 20 21 return await next(); 22 } 23}
CSHARP1// Application/Common/Behaviors/LoggingPipelineBehavior.cs 2public sealed class LoggingPipelineBehavior<TRequest, TResponse>( 3 ILogger<LoggingPipelineBehavior<TRequest, TResponse>> logger) 4 : IPipelineBehavior<TRequest, TResponse> 5 where TRequest : IRequest<TResponse> 6{ 7 public async Task<TResponse> Handle( 8 TRequest request, 9 RequestHandlerDelegate<TResponse> next, 10 CancellationToken cancellationToken) 11 { 12 var name = typeof(TRequest).Name; 13 logger.LogInformation("Handling {RequestName}", name); 14 15 var response = await next(); 16 17 logger.LogInformation("Handled {RequestName}", name); 18 return response; 19 } 20}
DI Kaydı
CSHARP1// Application/DependencyInjection.cs 2public static class DependencyInjection 3{ 4 public static IServiceCollection AddApplication(this IServiceCollection services) 5 { 6 services.AddMediatR(cfg => 7 { 8 cfg.RegisterServicesFromAssembly(typeof(DependencyInjection).Assembly); 9 cfg.AddBehavior(typeof(IPipelineBehavior<,>), typeof(LoggingPipelineBehavior<,>)); 10 cfg.AddBehavior(typeof(IPipelineBehavior<,>), typeof(ValidationPipelineBehavior<,>)); 11 }); 12 13 services.AddValidatorsFromAssembly(typeof(DependencyInjection).Assembly); 14 15 return services; 16 } 17}
Infrastructure / MyApp.Persistence
Veritabanı sorumluluklarını barındırır: DbContext, repository implementasyonları, EF konfigürasyonları ve migration'lar.
XML1<!-- MyApp.Persistence.csproj --> 2<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="9.*" /> 3<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.*" />
Klasör yapısı
MyApp.Persistence/
├── AppDbContext.cs
├── UnitOfWork.cs
├── Configurations/
│ └── OrderConfiguration.cs
├── Repositories/
│ └── OrderRepository.cs
└── Migrations/
└── ...
DbContext
CSHARP1// Persistence/AppDbContext.cs 2public sealed class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(options), IUnitOfWork 3{ 4 public DbSet<Order> Orders => Set<Order>(); 5 6 protected override void OnModelCreating(ModelBuilder modelBuilder) 7 { 8 modelBuilder.ApplyConfigurationsFromAssembly(typeof(AppDbContext).Assembly); 9 } 10 11 // IUnitOfWork doğrudan DbContext'te uygulanabilir 12 public override Task<int> SaveChangesAsync(CancellationToken ct = default) 13 => base.SaveChangesAsync(ct); 14}
AppDbContextaynı zamandaIUnitOfWorkinterface'ini implemente eder. Bu sayede ayrı birUnitOfWorksınıfı yazmak zorunda kalmazsınız.
EF Konfigürasyonu
CSHARP1// Persistence/Configurations/OrderConfiguration.cs 2public sealed class OrderConfiguration : IEntityTypeConfiguration<Order> 3{ 4 public void Configure(EntityTypeBuilder<Order> builder) 5 { 6 builder.ToTable("Orders"); 7 8 builder.HasKey(o => o.Id); 9 10 builder.Property(o => o.Id) 11 .HasConversion(id => id.Value, value => new OrderId(value)); 12 13 builder.Property(o => o.CustomerId) 14 .HasConversion(id => id.Value, value => new CustomerId(value)); 15 16 builder.Property(o => o.Status) 17 .HasConversion<string>() 18 .IsRequired(); 19 20 builder.OwnsMany(o => o.Lines, lines => 21 { 22 lines.ToTable("OrderLines"); 23 lines.WithOwner(); 24 25 lines.Property(l => l.ProductId) 26 .HasConversion(id => id.Value, value => new ProductId(value)); 27 28 lines.Property(l => l.Quantity).IsRequired(); 29 30 lines.OwnsOne(l => l.UnitPrice, price => 31 { 32 price.Property(p => p.Amount) 33 .HasColumnName("UnitPrice") 34 .HasColumnType("decimal(18,2)"); 35 36 price.Property(p => p.Currency) 37 .HasColumnName("Currency") 38 .HasMaxLength(3); 39 }); 40 }); 41 42 // Domain events EF'e persist edilmez 43 builder.Ignore(o => o.DomainEvents); 44 } 45}
Repository Implementasyonu
CSHARP1// Persistence/Repositories/OrderRepository.cs 2public sealed class OrderRepository(AppDbContext context) : IOrderRepository 3{ 4 public async Task<Order?> GetByIdAsync(OrderId id, CancellationToken ct = default) 5 => await context.Orders 6 .FirstOrDefaultAsync(o => o.Id == id, ct); 7 8 public async Task<IReadOnlyList<Order>> GetByCustomerIdAsync(CustomerId customerId, CancellationToken ct = default) 9 => await context.Orders 10 .Where(o => o.CustomerId == customerId) 11 .ToListAsync(ct); 12 13 public async Task AddAsync(Order order, CancellationToken ct = default) 14 => await context.Orders.AddAsync(order, ct); 15 16 public Task UpdateAsync(Order order, CancellationToken ct = default) 17 { 18 context.Orders.Update(order); 19 return Task.CompletedTask; 20 } 21}
DI Kaydı
CSHARP1// Persistence/DependencyInjection.cs 2public static class DependencyInjection 3{ 4 public static IServiceCollection AddPersistence( 5 this IServiceCollection services, 6 IConfiguration configuration) 7 { 8 services.AddDbContext<AppDbContext>(options => 9 options.UseSqlServer( 10 configuration.GetConnectionString("DefaultConnection"), 11 b => b.MigrationsAssembly(typeof(AppDbContext).Assembly.FullName))); 12 13 services.AddScoped<IOrderRepository, OrderRepository>(); 14 services.AddScoped<IUnitOfWork>(sp => sp.GetRequiredService<AppDbContext>()); 15 16 return services; 17 } 18}
Infrastructure / MyApp.Infrastructure
Veritabanı dışındaki tüm dış sistem entegrasyonlarını barındırır: e-posta, cache, harici API istemcileri, background job'lar.
XML1<!-- MyApp.Infrastructure.csproj --> 2<PackageReference Include="StackExchange.Redis" Version="2.*" /> 3<PackageReference Include="MailKit" Version="4.*" /> 4<PackageReference Include="Hangfire.Core" Version="1.*" />
Klasör yapısı
MyApp.Infrastructure/
├── Caching/
│ └── RedisCacheService.cs
├── Email/
│ └── SmtpEmailService.cs
├── ExternalApis/
│ └── PaymentApiClient.cs
└── DependencyInjection.cs
E-posta Servisi
CSHARP1// Infrastructure/Email/SmtpEmailService.cs 2public sealed class SmtpEmailService( 3 IOptions<EmailSettings> settings, 4 ILogger<SmtpEmailService> logger) 5 : IEmailService 6{ 7 public async Task SendOrderConfirmedAsync( 8 string to, 9 OrderId orderId, 10 CancellationToken ct = default) 11 { 12 logger.LogInformation( 13 "Sipariş onay e-postası gönderiliyor. OrderId: {OrderId}, To: {To}", 14 orderId, 15 to); 16 17 // MailKit implementasyonu... 18 await Task.CompletedTask; 19 } 20}
Cache Servisi
CSHARP1// Infrastructure/Caching/RedisCacheService.cs 2public sealed class RedisCacheService(IConnectionMultiplexer redis) : ICacheService 3{ 4 private readonly IDatabase _db = redis.GetDatabase(); 5 6 public async Task<T?> GetAsync<T>(string key, CancellationToken ct = default) 7 { 8 var value = await _db.StringGetAsync(key); 9 if (value.IsNullOrEmpty) return default; 10 11 return JsonSerializer.Deserialize<T>(value!); 12 } 13 14 public async Task SetAsync<T>( 15 string key, 16 T value, 17 TimeSpan? expiry = null, 18 CancellationToken ct = default) 19 { 20 var serialized = JsonSerializer.Serialize(value); 21 await _db.StringSetAsync(key, serialized, expiry); 22 } 23 24 public async Task RemoveAsync(string key, CancellationToken ct = default) 25 => await _db.KeyDeleteAsync(key); 26}
DI Kaydı
CSHARP1// Infrastructure/DependencyInjection.cs 2public static class DependencyInjection 3{ 4 public static IServiceCollection AddInfrastructure( 5 this IServiceCollection services, 6 IConfiguration configuration) 7 { 8 // Redis 9 services.AddSingleton<IConnectionMultiplexer>( 10 ConnectionMultiplexer.Connect(configuration.GetConnectionString("Redis")!)); 11 services.AddScoped<ICacheService, RedisCacheService>(); 12 13 // E-posta 14 services.Configure<EmailSettings>(configuration.GetSection("EmailSettings")); 15 services.AddScoped<IEmailService, SmtpEmailService>(); 16 17 return services; 18 } 19}
Presentation / MyApp.API
HTTP endpoint'lerini, middleware'leri ve DI composition root'u barındırır. Bu katmanın tek sorumluluğu: HTTP isteğini alıp Application'a iletmek, dönen sonucu HTTP yanıtına çevirmek.
Klasör yapısı
MyApp.API/
├── Controllers/
│ └── OrdersController.cs
├── Middlewares/
│ └── GlobalExceptionMiddleware.cs
├── Models/
│ └── CreateOrderRequest.cs
├── appsettings.json
├── appsettings.Development.json
└── Program.cs
Program.cs
CSHARP1// API/Program.cs 2var builder = WebApplication.CreateBuilder(args); 3 4builder.Services 5 .AddApplication() 6 .AddPersistence(builder.Configuration) 7 .AddInfrastructure(builder.Configuration); 8 9builder.Services.AddControllers(); 10builder.Services.AddEndpointsApiExplorer(); 11builder.Services.AddSwaggerGen(); 12 13var app = builder.Build(); 14 15if (app.Environment.IsDevelopment()) 16{ 17 app.UseSwagger(); 18 app.UseSwaggerUI(); 19} 20 21app.UseMiddleware<GlobalExceptionMiddleware>(); 22app.UseHttpsRedirection(); 23app.UseAuthorization(); 24app.MapControllers(); 25 26app.Run();
Controller
CSHARP1// API/Controllers/OrdersController.cs 2[ApiController] 3[Route("api/[controller]")] 4public sealed class OrdersController(ISender mediator) : ControllerBase 5{ 6 [HttpPost] 7 [ProducesResponseType(StatusCodes.Status201Created)] 8 [ProducesResponseType(StatusCodes.Status400BadRequest)] 9 public async Task<IActionResult> Create( 10 CreateOrderRequest request, 11 CancellationToken ct) 12 { 13 var command = new CreateOrderCommand(request.CustomerId); 14 var orderId = await mediator.Send(command, ct); 15 16 return CreatedAtAction(nameof(GetById), new { id = orderId }, new { id = orderId }); 17 } 18 19 [HttpGet("{id:guid}")] 20 [ProducesResponseType(StatusCodes.Status200OK)] 21 [ProducesResponseType(StatusCodes.Status404NotFound)] 22 public async Task<IActionResult> GetById(Guid id, CancellationToken ct) 23 { 24 var order = await mediator.Send(new GetOrderByIdQuery(id), ct); 25 return order is null ? NotFound() : Ok(order); 26 } 27 28 [HttpPost("{id:guid}/confirm")] 29 [ProducesResponseType(StatusCodes.Status204NoContent)] 30 [ProducesResponseType(StatusCodes.Status404NotFound)] 31 public async Task<IActionResult> Confirm(Guid id, CancellationToken ct) 32 { 33 await mediator.Send(new ConfirmOrderCommand(id), ct); 34 return NoContent(); 35 } 36}
Global Exception Middleware
CSHARP1// API/Middlewares/GlobalExceptionMiddleware.cs 2public sealed class GlobalExceptionMiddleware( 3 RequestDelegate next, 4 ILogger<GlobalExceptionMiddleware> logger) 5{ 6 public async Task InvokeAsync(HttpContext context) 7 { 8 try 9 { 10 await next(context); 11 } 12 catch (ValidationException ex) 13 { 14 logger.LogWarning("Validation hatası: {Errors}", ex.Errors); 15 context.Response.StatusCode = StatusCodes.Status400BadRequest; 16 await context.Response.WriteAsJsonAsync(new 17 { 18 Errors = ex.Errors.Select(e => new { e.PropertyName, e.ErrorMessage }) 19 }); 20 } 21 catch (NotFoundException ex) 22 { 23 logger.LogWarning("Bulunamadı: {Message}", ex.Message); 24 context.Response.StatusCode = StatusCodes.Status404NotFound; 25 await context.Response.WriteAsJsonAsync(new { ex.Message }); 26 } 27 catch (DomainException ex) 28 { 29 logger.LogWarning("Domain hatası: {Message}", ex.Message); 30 context.Response.StatusCode = StatusCodes.Status422UnprocessableEntity; 31 await context.Response.WriteAsJsonAsync(new { ex.Message }); 32 } 33 catch (Exception ex) 34 { 35 logger.LogError(ex, "Beklenmedik hata"); 36 context.Response.StatusCode = StatusCodes.Status500InternalServerError; 37 await context.Response.WriteAsJsonAsync(new { Message = "Sunucu hatası oluştu." }); 38 } 39 } 40}
Test Katmanı
Yedi proje yapısının en büyük avantajı test yazım kolaylığıdır. Domain ve Application katmanları saf C# olduğu için mock ile çalışmak son derece hızlıdır.
Tests/
├── MyApp.Domain.Tests/
├── MyApp.Application.Tests/
└── MyApp.Integration.Tests/
CSHARP1// Domain.Tests/OrderTests.cs 2public sealed class OrderTests 3{ 4 [Fact] 5 public void Create_ShouldReturnDraftOrder() 6 { 7 var order = Order.Create(new CustomerId(Guid.NewGuid())); 8 9 Assert.Equal(OrderStatus.Draft, order.Status); 10 Assert.Empty(order.Lines); 11 Assert.Empty(order.DomainEvents); 12 } 13 14 [Fact] 15 public void Confirm_ShouldRaiseDomainEvent() 16 { 17 var order = Order.Create(new CustomerId(Guid.NewGuid())); 18 order.AddLine(new ProductId(Guid.NewGuid()), 1, new Money(100m, "TRY")); 19 20 order.Confirm(); 21 22 Assert.Single(order.DomainEvents); 23 Assert.IsType<OrderConfirmedDomainEvent>(order.DomainEvents.First()); 24 } 25 26 [Fact] 27 public void Confirm_WithNoLines_ShouldThrowDomainException() 28 { 29 var order = Order.Create(new CustomerId(Guid.NewGuid())); 30 31 Assert.Throws<DomainException>(() => order.Confirm()); 32 } 33 34 [Fact] 35 public void AddLine_ToConfirmedOrder_ShouldThrowDomainException() 36 { 37 var order = Order.Create(new CustomerId(Guid.NewGuid())); 38 order.AddLine(new ProductId(Guid.NewGuid()), 1, new Money(100m, "TRY")); 39 order.Confirm(); 40 41 Assert.Throws<DomainException>( 42 () => order.AddLine(new ProductId(Guid.NewGuid()), 2, new Money(50m, "TRY"))); 43 } 44}
CSHARP1// Application.Tests/CreateOrderCommandHandlerTests.cs 2public sealed class CreateOrderCommandHandlerTests 3{ 4 private readonly Mock<IOrderRepository> _repoMock = new(); 5 private readonly Mock<IUnitOfWork> _uowMock = new(); 6 7 [Fact] 8 public async Task Handle_ValidCommand_ShouldAddOrderAndSave() 9 { 10 var handler = new CreateOrderCommandHandler(_repoMock.Object, _uowMock.Object); 11 var command = new CreateOrderCommand(Guid.NewGuid()); 12 13 var result = await handler.Handle(command, CancellationToken.None); 14 15 Assert.NotEqual(Guid.Empty, result); 16 _repoMock.Verify(r => r.AddAsync( 17 It.IsAny<Order>(), 18 It.IsAny<CancellationToken>()), Times.Once); 19 _uowMock.Verify(u => u.SaveChangesAsync( 20 It.IsAny<CancellationToken>()), Times.Once); 21 } 22 23 [Fact] 24 public async Task Handle_EmptyCustomerId_ShouldThrowValidationException() 25 { 26 var validator = new CreateOrderCommandValidator(); 27 var command = new CreateOrderCommand(Guid.Empty); 28 29 var result = validator.Validate(command); 30 31 Assert.False(result.IsValid); 32 Assert.Contains(result.Errors, e => e.PropertyName == nameof(command.CustomerId)); 33 } 34}
Persistence vs. Infrastructure: Ne Fark Eder?
Bu mimariyi tek Infrastructure projesiyle de kurabilirsiniz; ama ikiye ayırmanın somut gerekçesi vardır.
| MyApp.Persistence | MyApp.Infrastructure | |
|---|---|---|
| Sorumluluk | Veri saklama ve okuma | Dış sistem entegrasyonu |
| Teknoloji | EF Core, SQL Server/PostgreSQL | Redis, SMTP, REST API, RabbitMQ |
| Test stratejisi | Integration test (gerçek/in-memory DB) | Unit test (mock) |
| Değişim sıklığı | Nadir (schema migration) | Orta (API güncellemeleri) |
| Bağımlılık yönü | Application → Persistence | Application → Infrastructure |
Persistence'ı ayrı tutarak sadece veritabanı bağımlılığı olan projede Microsoft.EntityFrameworkCore paketini bulundurursunuz. Redis veya e-posta servisi değiştiğinde Persistence kodu hiç etkilenmez.
Sık Yapılan Hatalar
1. Controller'da iş mantığı yazmak
HTTP katmanı sadece yönlendirme yapar. if (order.Status == "Draft") gibi kontroller controller'da değil, Domain entity'sinde yaşamalıdır.
2. Application'dan Persistence'a doğrudan referans vermek
Application katmanı IOrderRepository interface'ini kullanır; OrderRepository sınıfını asla doğrudan çağırmaz. Bu kuralı ihlal etmek bağımlılık yönünü bozar.
3. DTO'ları Domain entity'siyle karıştırmak
OrderDto Application katmanında yaşar ve HTTP yanıtı için özelleştirilmiştir. Domain Order entity'sini doğrudan döndürmek, domain modelini API sözleşmesine kilitler.
4. Migration'ı yanlış projeye koymak
Migration'lar her zaman MyApp.Persistence projesinde bulunmalıdır. dotnet ef migrations add komutunu bu projede çalıştırın; startup projesi olarak MyApp.API'yi belirtin:
BASH1dotnet ef migrations add InitialCreate \ 2 --project Infrastructure/MyApp.Persistence \ 3 --startup-project Presentation/MyApp.API
5. Program.cs'te her şeyi kaydetmek
Her katmanın kendi DependencyInjection.cs'i olmalıdır. Program.cs yalnızca bu extension method'ları çağırır: AddApplication(), AddPersistence(), AddInfrastructure(). Gerisini katmanlar kendi içinde çözer.
Özet: Hangi Sınıf Nerede Yaşar?
| Sınıf / Arayüz | Proje |
|---|---|
Order, OrderLine, Money | Domain |
IDomainEvent | Domain |
IOrderRepository | Application |
CreateOrderCommand, GetOrderByIdQuery | Application |
CreateOrderCommandHandler | Application |
IUnitOfWork, IEmailService | Application |
AppDbContext, OrderRepository | Persistence |
OrderConfiguration (EF) | Persistence |
SmtpEmailService, RedisCacheService | Infrastructure |
OrdersController, GlobalExceptionMiddleware | API |
Program.cs | API |