.NET Core
.NET Background Services: Hosted Services ve Worker Pattern
Arka plan işlemleri, ciddi her .NET uygulamasının temel taşıdır. E-posta göndermek, rapor oluşturmak, veri senkronize etmek veya domain event'leri yayınlamak gibi işlemler HTTP istek-yanıt döngüsünün içinde yapılmamalıdır. Bu görevleri background service'lere taşımak yanıt sürelerini iyileştirir, hataları izole eder ve sisteminizi ölçeklenebilir hale getirir.
Bu yazıda .NET'teki temel implementasyon seçeneklerini, production ortamlarında test edilmiş pattern'leri ve gerçek projelerde en sık karşılaştığım hataları paylaşıyorum.
Temel Implementasyon Seçenekleri
IHostedService
IHostedService, en düşük seviyeli soyutlamadır. Size StartAsync ve StopAsync olmak üzere iki hook verir, başka bir şey sunmaz. Uygulama başlangıcında bağlantı kurmak, cache'i ısıtmak veya bir listener kaydetmek gibi senaryolar için idealdir.
public class CacheWarmupService : IHostedService
{
private readonly ICacheProvider _cache;
private readonly ILogger<CacheWarmupService> _logger;
public CacheWarmupService(ICacheProvider cache, ILogger<CacheWarmupService> logger)
{
_cache = cache;
_logger = logger;
}
public async Task StartAsync(CancellationToken cancellationToken)
{
_logger.LogInformation("Cache ısıtma başlatılıyor...");
await _cache.LoadFrequentDataAsync(cancellationToken);
_logger.LogInformation("Cache ısıtma tamamlandı.");
}
public Task StopAsync(CancellationToken cancellationToken)
{
_logger.LogInformation("Uygulama kapanıyor, cache warmup servisi durduruluyor.");
return Task.CompletedTask;
}
}Program.cs içinde kayıt:
builder.Services.AddHostedService<CacheWarmupService>();BackgroundService
BackgroundService, asıl iş gücüdür. IHostedService'den türer ve uygulamanın tüm yaşam döngüsü boyunca çalışan bir ExecuteAsync metodu sunar. Sürekli çalışan worker'lar — kuyruk tüketicileri, polling döngüleri, dosya izleyiciler — burada implement edilir.
public class OrderProcessorWorker : BackgroundService
{
private readonly IServiceScopeFactory _scopeFactory;
private readonly ILogger<OrderProcessorWorker> _logger;
public OrderProcessorWorker(
IServiceScopeFactory scopeFactory,
ILogger<OrderProcessorWorker> logger)
{
_scopeFactory = scopeFactory;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("Sipariş işlemci başlatıldı.");
while (!stoppingToken.IsCancellationRequested)
{
try
{
using var scope = _scopeFactory.CreateScope();
var orderQueue = scope.ServiceProvider.GetRequiredService<IOrderQueue>();
var handler = scope.ServiceProvider.GetRequiredService<IOrderHandler>();
var order = await orderQueue.DequeueAsync(stoppingToken);
if (order is not null)
{
await handler.ProcessAsync(order, stoppingToken);
_logger.LogInformation("Sipariş {OrderId} işlendi.", order.Id);
}
else
{
await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken);
}
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
// Graceful shutdown — beklenen durum, hata olarak loglanmaz
break;
}
catch (Exception ex)
{
_logger.LogError(ex, "Sipariş işlenirken hata. 10 saniye sonra tekrar denenecek.");
await Task.Delay(TimeSpan.FromSeconds(10), stoppingToken);
}
}
_logger.LogInformation("Sipariş işlemci durduruldu.");
}
}Kritik bir detay: BackgroundService singleton olarak kayıt edilir, ancak DbContext ve çoğu iş servisi scoped'dur. Döngü içinde her zaman yeni bir IServiceScope oluşturmanız gerekir. Bunu unutmak, karşılaştığım en yaygın hatalardan biridir.
Channel\<T\> ile Kuyruk Tabanlı İşleme
Süreç içi producer-consumer senaryoları için System.Threading.Channels.Channel<T> hafif ve son derece hızlıdır. Harici bir message broker'a gerek kalmadan API controller'dan iş yükünü arka plana taşımak istediğimde bu yapıyı kullanırım.
public class BackgroundTaskQueue
{
private readonly Channel<Func<IServiceProvider, CancellationToken, Task>> _channel;
public BackgroundTaskQueue(int capacity = 100)
{
var options = new BoundedChannelOptions(capacity)
{
FullMode = BoundedChannelFullMode.Wait
};
_channel = Channel.CreateBounded<Func<IServiceProvider, CancellationToken, Task>>(options);
}
public async ValueTask EnqueueAsync(
Func<IServiceProvider, CancellationToken, Task> workItem,
CancellationToken cancellationToken = default)
{
await _channel.Writer.WriteAsync(workItem, cancellationToken);
}
public async ValueTask<Func<IServiceProvider, CancellationToken, Task>> DequeueAsync(
CancellationToken cancellationToken)
{
return await _channel.Reader.ReadAsync(cancellationToken);
}
}
public class QueueProcessorWorker : BackgroundService
{
private readonly BackgroundTaskQueue _queue;
private readonly IServiceScopeFactory _scopeFactory;
private readonly ILogger<QueueProcessorWorker> _logger;
public QueueProcessorWorker(
BackgroundTaskQueue queue,
IServiceScopeFactory scopeFactory,
ILogger<QueueProcessorWorker> logger)
{
_queue = queue;
_scopeFactory = scopeFactory;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
var workItem = await _queue.DequeueAsync(stoppingToken);
using var scope = _scopeFactory.CreateScope();
try
{
await workItem(scope.ServiceProvider, stoppingToken);
}
catch (Exception ex)
{
_logger.LogError(ex, "Kuyruktaki iş öğesi çalıştırılırken hata oluştu.");
}
}
}
}Hangfire ile Zamanlanmış ve Tekrarlayan Job'lar
Kalıcı, zamanlanmış veya tekrarlayan job'lara, dashboard'a ve otomatik yeniden deneme mekanizmasına ihtiyaç duyduğunuzda Hangfire, .NET ekosistemindeki en pratik seçenektir.
// Program.cs
builder.Services.AddHangfire(config =>
config.UseSqlServerStorage(builder.Configuration.GetConnectionString("HangfireDb")));
builder.Services.AddHangfireServer();
// Tekrarlayan job tanımla
RecurringJob.AddOrUpdate<IReportService>(
"gunluk-satis-raporu",
service => service.GenerateDailySalesReportAsync(CancellationToken.None),
Cron.Daily(hour: 2, minute: 0));
// Fire-and-forget — controller'dan tetikle
BackgroundJob.Enqueue<IEmailService>(
service => service.SendWelcomeEmailAsync(userId, CancellationToken.None));
// Gecikmeli job
BackgroundJob.Schedule<ICleanupService>(
service => service.RemoveExpiredTokensAsync(CancellationToken.None),
TimeSpan.FromHours(1));Production sistemlerde Hangfire için her zaman ayrı bir veritabanı yapılandırır ve açık kuyruk isimleri belirlerim. Bu sayede background job depolaması uygulamanın ana veritabanıyla rekabet etmez ve worker'ları bağımsız olarak ölçeklendirebilirsiniz.
Outbox Pattern ile Güvenilir Event Yayınlama
Dağıtık sistemlerdeki en zor problemlerden biri, veritabanı yazma işlemi ile event yayınlamanın atomik olarak gerçekleşmesini sağlamaktır. Siparişi veritabanına kaydederseniz ama message broker çökmüşse event kaybolur. Event'i yayınlarsanız ama veritabanı transaction'ı geri alınırsa hayalet bir event ortaya çıkar.
Outbox Pattern bu sorunu, event'leri aynı veritabanı transaction'ı içinde bir outbox tablosuna yazarak ve ardından bir background service ile bu event'leri message broker'a ileterek çözer.
// Adım 1: Aynı transaction içinde outbox'a yaz
public class OrderService
{
private readonly AppDbContext _db;
public OrderService(AppDbContext db) => _db = db;
public async Task PlaceOrderAsync(Order order, CancellationToken ct)
{
_db.Orders.Add(order);
_db.OutboxMessages.Add(new OutboxMessage
{
Id = Guid.NewGuid(),
Type = "OrderPlaced",
Payload = JsonSerializer.Serialize(new OrderPlacedEvent(order.Id, order.Total)),
CreatedAt = DateTime.UtcNow,
ProcessedAt = null
});
await _db.SaveChangesAsync(ct); // Tek transaction
}
}
// Adım 2: Background worker outbox mesajlarını iletir
public class OutboxPublisherWorker : BackgroundService
{
private readonly IServiceScopeFactory _scopeFactory;
private readonly ILogger<OutboxPublisherWorker> _logger;
public OutboxPublisherWorker(
IServiceScopeFactory scopeFactory,
ILogger<OutboxPublisherWorker> logger)
{
_scopeFactory = scopeFactory;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var bus = scope.ServiceProvider.GetRequiredService<IMessageBus>();
var pending = await db.OutboxMessages
.Where(m => m.ProcessedAt == null)
.OrderBy(m => m.CreatedAt)
.Take(50)
.ToListAsync(stoppingToken);
foreach (var message in pending)
{
try
{
await bus.PublishAsync(message.Type, message.Payload, stoppingToken);
message.ProcessedAt = DateTime.UtcNow;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Outbox mesajı {Id} yayınlanamadı.", message.Id);
break; // Sıralamayı koru — sonraki döngüde tekrar dene
}
}
await db.SaveChangesAsync(stoppingToken);
await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken);
}
}
}Production sistemlerde outbox tablosuna her zaman bir RetryCount kolonu eklerim ve eşik değerini aşan mesajları atlarım. Ölü mesajlar, manuel inceleme için ayrı bir tabloya taşınır.
Hata Yönetimi ve Yeniden Deneme Stratejisi
İlk exception'da çöken bir background service, hiç olmamasından daha kötüdür. Sağlam hata yönetimi tartışmaya açık değildir.
Polly ile Üstel Geri Çekilme (Exponential Backoff)
// Yeniden deneme politikası tanımla
private static readonly AsyncRetryPolicy RetryPolicy = Policy
.Handle<HttpRequestException>()
.Or<TimeoutException>()
.WaitAndRetryAsync(
retryCount: 5,
sleepDurationProvider: attempt => TimeSpan.FromSeconds(Math.Pow(2, attempt)),
onRetry: (exception, delay, attempt, context) =>
{
Log.Warning(exception,
"{Operation} için {Attempt}. deneme, {Delay}s sonra.",
context.OperationKey, attempt, delay.TotalSeconds);
});
// Worker içinde kullanım
await RetryPolicy.ExecuteAsync(
async ct => await externalApi.SyncDataAsync(ct),
stoppingToken);CancellationToken ile Zarif Kapanış
ExecuteAsync'e geçirilen CancellationToken, host kapanırken sinyal verir. Buna saygı göstermek kritiktir — worker bunu yok sayarsa, host kapanma zaman aşımından (varsayılan 30 saniye) sonra onu zorla sonlandırır.
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
var batch = await FetchBatchAsync(stoppingToken);
foreach (var item in batch)
{
if (stoppingToken.IsCancellationRequested)
{
_logger.LogInformation("Kapanış talebi alındı. Batch ortasında durduruluyor.");
return;
}
await ProcessItemAsync(item, stoppingToken);
}
await Task.Delay(TimeSpan.FromSeconds(10), stoppingToken);
}
}Production sistemlerde kapanma zaman aşımını Program.cs içinde her zaman açıkça yapılandırırım:
builder.Host.ConfigureHostOptions(opts =>
{
opts.ShutdownTimeout = TimeSpan.FromSeconds(60);
});Bu, uzun süren batch işlemlerin süreç kapanmadan önce mevcut öğeyi bitirmesine yeterli zaman tanır.
Background Job'ları İzleme (Monitoring)
Background service'ler sessizce çalışır. Uygun izleme olmadan hatalar saatlerce hatta günlerce fark edilmez. Worker'ın haftalardır çökmüş olduğu ve kimsenin fark etmediği sistemler gördüm.
Health Check'ler
public class WorkerHealthCheck : IHealthCheck
{
private static DateTime _lastHeartbeat = DateTime.MinValue;
private static readonly TimeSpan Threshold = TimeSpan.FromMinutes(5);
public static void RecordHeartbeat() => _lastHeartbeat = DateTime.UtcNow;
public Task<HealthCheckResult> CheckHealthAsync(
HealthCheckContext context,
CancellationToken cancellationToken = default)
{
var elapsed = DateTime.UtcNow - _lastHeartbeat;
if (elapsed < Threshold)
return Task.FromResult(HealthCheckResult.Healthy($"Son heartbeat {elapsed.TotalSeconds:F0}s önce."));
return Task.FromResult(HealthCheckResult.Unhealthy($"{elapsed.TotalMinutes:F1} dakikadır heartbeat yok."));
}
}Worker'ınızda her başarılı döngü iterasyonunun sonunda WorkerHealthCheck.RecordHeartbeat() çağrısını yapın.
Yapılandırılmış Metrikler
// .NET 8 Metrics API kullanarak
private readonly Counter<long> _processedCounter;
private readonly Histogram<double> _processingDuration;
public OrderProcessorWorker(IMeterFactory meterFactory, /* ... */)
{
var meter = meterFactory.Create("App.BackgroundJobs");
_processedCounter = meter.CreateCounter<long>("jobs.processed");
_processingDuration = meter.CreateHistogram<double>("jobs.duration_ms");
}
// İşleme döngüsü içinde
var sw = Stopwatch.StartNew();
await handler.ProcessAsync(order, stoppingToken);
sw.Stop();
_processedCounter.Add(1, new KeyValuePair<string, object?>("job_type", "order"));
_processingDuration.Record(sw.Elapsed.TotalMilliseconds);Bu metrikleri Prometheus, Grafana veya Application Insights'a aktarın ve işleme hızı düştüğünde veya hata sayısı arttığında alarm kurun.
Yaygın Background Service Hataları
1. Service Scope Oluşturmamak
En sık karşılaşılan hata budur. BackgroundService singleton'dır. DbContext gibi scoped bir servisi doğrudan constructor üzerinden inject ederseniz, sonunda ObjectDisposedException fırlatan veya daha kötüsü — sessizce eski bir context'i yeniden kullanan bir captive dependency elde edersiniz.
// YANLIŞ — DbContext scoped, worker singleton
public class KotuWorker : BackgroundService
{
private readonly AppDbContext _db; // Captive dependency!
public KotuWorker(AppDbContext db) => _db = db;
}
// DOĞRU — her iterasyonda scope oluştur
public class IyiWorker : BackgroundService
{
private readonly IServiceScopeFactory _scopeFactory;
public IyiWorker(IServiceScopeFactory scopeFactory) => _scopeFactory = scopeFactory;
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
// db'yi burada güvenle kullanın
}
}
}2. Exception'ları Sessizce Yutmak
.NET 6+'da ExecuteAsync içinde yakalanmayan bir exception worker'ı sessizce durdurur. Host çalışmaya devam eder, ancak background service ölmüştür. Döngü gövdesini her zaman try-catch ile sarın ve hatayı loglayın.
3. Host Başlangıcını Engellemek
ExecuteAsync, host başlangıcında çağrılır. Implementasyonunuz yield etmezse (örneğin await olmadan senkron while(true) döngüsü), diğer tüm hosted service'lerin başlamasını engeller. Döngü içinde her zaman await kullanın — döngünün başında await Task.Yield() bile başlangıcın engellenmesini önlemeye yeterlidir.
4. Idempotency'yi Göz Ardı Etmek
Background job'lar birden fazla kez çalışabilir. Ağ zaman aşımları, süreç yeniden başlatmaları ve tekrarlanan kuyruk mesajları hep yeniden denemeye yol açar. Handler'ınız idempotent değilse, mükerrer e-postalar, çift tahsilatlar veya bozulmuş verilerle karşılaşırsınız. Her job handler'ı yeniden çalıştırılmaya güvenli olacak şekilde tasarlayın.
5. Dead-Letter Stratejisi Olmaması
Bir mesaj sürekli başarısız olduğunda, kuyruğu sonsuza kadar bloklamalamalıdır. Yeniden deneme sayacı uygulayın ve belirli bir deneme sayısından sonra zehirli mesajları dead-letter kuyruğuna veya tablosuna taşıyın. Production sistemlerde bunu her zaman bir alarmla eşleştiririm, böylece ekip başarısız mesajları hızlıca araştırır.
Sonuç
Background service'ler ikincil bir konu değildir — temel altyapıdır. İyi tasarlanmış bir background processing katmanı hataları zarif bir şekilde ele alır, outbox pattern aracılığıyla event'leri güvenilir bir şekilde yayınlar ve health check'ler ile metrikler sayesinde ekibinize tam görünürlük sağlar. Burada anlattığım pattern'ler, bu servisleri yıllarca production ortamında çalıştırma deneyimimden geliyor ve en acı verici olayları tutarlı bir şekilde önlüyor.
Background job mimarinizi birlikte tasarlayabiliriz.
İlgili Makaleler
ASP.NET Core ile RESTful API Geliştirme
ASP.NET Core ile production-ready REST API geliştirmenin temellerini öğrenin. Controller, routing ve best practice'ler.
.NET ile Mikroservis Mimarisi: Tasarım ve Uygulama
.NET ile mikroservis mimarisi tasarlayın. Service communication, Docker ve orchestration stratejileri.
.NET Performance Optimizasyonu: Profiling ve Best Practices
.NET uygulamalarının performansını optimize edin. Profiling araçları, memory management ve async patterns.
Flutter Projeniz mi Var?
iOS, Android ve web için yüksek performanslı Flutter uygulamaları geliştiriyorum.
İletişime Geç