This document explains a reusable notification architecture that can be implemented in any backend project. The reference stack is Firebase Cloud Messaging (FCM), a background job scheduler such as Hangfire, a relational database, and a backend service layer. The same design can be adapted to .NET, Node.js, Java, Go, Python, or any other stack with equivalent components.
Language options:
| Component | Purpose | Can Be Implemented With |
|---|---|---|
| Device token API | Receives and removes client FCM tokens | REST, GraphQL, gRPC |
| Device token store | Keeps token lifecycle and limits | SQL table, document DB, key-value store |
| Notification producer | Decides who should receive which message | Service class, domain handler, scheduled worker |
| Background scheduler | Runs periodic checks and delayed sends | Hangfire, Quartz, BullMQ, Celery, Sidekiq, Cloud Tasks |
| Timezone filter | Sends only during allowed local hours | GMT offset, IANA timezone id, NodaTime/date-time library |
| FCM sender | Sends push notifications to Firebase | Firebase Admin SDK |
| Retry policy | Retries transient failures safely | Scheduler retry, app retry, resilience library |
| Progress checkpoint | Prevents duplicate processing | SQL checkpoint row, event offset, outbox table |
| Feature toggle | Turns notification types on/off | DB setting, config file, feature flag service |
| Observability | Helps inspect jobs, failures, and token health | Logs, metrics, dashboard, SQL queries |
Bu dokümanın amacı, FCM tabanlı notification sistemini belirli bir projeye bağlı kalmadan anlatmaktır. Dokümanı okuyan bir geliştirici, kendi projesinde aynı mimariyi kurabilmelidir.
Sistem şu ihtiyaçları karşılar:
Bu mimari özellikle şu durumlarda faydalıdır:
Her projede aşağıdaki bileşenler kurulmalıdır:
| Bileşen | Sorumluluk |
|---|---|
DeviceTokenController veya API endpoint |
Client'tan token register/unregister isteğini alır |
DeviceTokenService |
Token doğrulama, ownership kontrolü, aktif/pasif yönetimi yapar |
DeviceTokenRepository |
Token kayıtlarını veritabanında yönetir |
NotificationService |
Notification adaylarını bulur, timezone filtresi uygular, gönderimi başlatır |
NotificationSender |
Firebase Admin SDK ile FCM gönderimi yapar |
NotificationScheduler |
Uygun olmayan saatlerdeki notification'ları ileri tarihe alır |
NotificationProgressStore |
Periyodik işlerde son başarılı işleme noktasını tutar |
FeatureToggleService |
Notification tiplerinin aktif/pasif durumunu okur |
BackgroundJobWorker |
Periyodik ve scheduled job'ları çalıştırır |
Bu isimler zorunlu değildir. Önemli olan sorumlulukların ayrılmasıdır.
Minimum model iki tabloyla kurulabilir: DeviceTokens ve NotificationProgress.
Bu tablo cihaz token yaşam döngüsünü tutar.
CREATE TABLE DeviceTokens
(
Id BIGINT IDENTITY(1,1) PRIMARY KEY,
UserId BIGINT NOT NULL,
Token NVARCHAR(MAX) NOT NULL,
TokenHash NVARCHAR(128) NULL,
Platform NVARCHAR(50) NOT NULL,
NotificationCount INT NOT NULL DEFAULT 0,
LastSentAtUtc DATETIME2 NULL,
CreatedAtUtc DATETIME2 NOT NULL DEFAULT SYSUTCDATETIME(),
UpdatedAtUtc DATETIME2 NOT NULL DEFAULT SYSUTCDATETIME(),
IsActive BIT NOT NULL DEFAULT 1
);
CREATE INDEX IX_DeviceTokens_User_Active
ON DeviceTokens(UserId, IsActive);
CREATE INDEX IX_DeviceTokens_User_Platform_Active
ON DeviceTokens(UserId, Platform, IsActive);
Öneriler:
Token uzun olabilir. Unique index gerekiyorsa TokenHash alanı üzerinden ilerlemek daha sağlıklıdır.IsActive = false yapmak daha iyi bir audit trail sağlar.Bu tablo periyodik notification işlerinde checkpoint tutar.
CREATE TABLE NotificationProgress
(
NotificationType NVARCHAR(100) NOT NULL PRIMARY KEY,
LastProcessedValue NVARCHAR(MAX) NULL,
SuccessRunTimeUtc DATETIME2 NOT NULL,
LastRunStatus BIT NOT NULL,
UpdatedAtUtc DATETIME2 NOT NULL DEFAULT SYSUTCDATETIME()
);
Bu tablo özellikle şu senaryoda gerekir: "Her 15 dakikada bir yeni kayıt var mı diye bak ve varsa notification gönder." Son başarılı çalışma zamanı bilinmezse aynı veri tekrar tekrar işlenebilir.
Register endpoint örneği:
POST /api/device-tokens/register
Authorization: Bearer <access-token>
Content-Type: application/json
{
"userId": 123,
"token": "fcm_device_token",
"platform": "ios"
}
Register sırasında yapılması gerekenler:
userId, authenticated user ile eşleşmeli.token boş olmamalı.platform kabul edilen değerlerden biri olmalı: ios, android, web gibi.NotificationCount ve LastSentAtUtc gibi bilgileri gerekiyorsa yeni token'a taşınmalı.Unregister endpoint örneği:
POST /api/device-tokens/unregister
Authorization: Bearer <access-token>
Content-Type: application/json
{
"userId": 123,
"token": "fcm_device_token",
"platform": "ios"
}
Unregister sırasında token silinmemeli, pasife çekilmelidir:
UPDATE DeviceTokens
SET IsActive = 0,
UpdatedAtUtc = SYSUTCDATETIME()
WHERE UserId = @UserId
AND TokenHash = @TokenHash
AND IsActive = 1;
Timezone-aware notification gönderebilmek için her notification payload'ı ortak bir kontrata uymalıdır.
public interface ITimezoneAwareNotification
{
long UserId { get; }
string Token { get; }
string TimezoneId { get; }
int? GmtOffsetSeconds { get; }
string GetTitle();
string GetBody();
Dictionary<string, string> GetData();
}
Tercih sırası:
TimezoneId saklayın: Europe/Istanbul, America/New_York.GmtOffsetSeconds kullanılabilir.Örnek payload:
public sealed class NewCustomerNotification : ITimezoneAwareNotification
{
public long UserId { get; init; }
public string Token { get; init; }
public string TimezoneId { get; init; }
public int? GmtOffsetSeconds { get; init; }
public string CustomerName { get; init; }
public string GetTitle() => "New Customer";
public string GetBody() => $"A new customer registered: {CustomerName}";
public Dictionary<string, string> GetData() => new()
{
["type"] = "new_customer"
};
}
Temel kural:
Örnek algoritma:
public NotificationTiming DecideTiming(
DateTime utcNow,
TimeZoneInfo userTimezone,
TimeOnly start,
TimeOnly end)
{
var localNow = TimeZoneInfo.ConvertTimeFromUtc(utcNow, userTimezone);
var localTime = TimeOnly.FromDateTime(localNow);
if (localTime >= start && localTime < end)
{
return NotificationTiming.SendNow();
}
var targetLocalDate = localTime < start
? localNow.Date
: localNow.Date.AddDays(1);
var targetLocal = targetLocalDate.Add(start.ToTimeSpan());
var targetUtc = TimeZoneInfo.ConvertTimeToUtc(targetLocal, userTimezone);
return NotificationTiming.ScheduleAt(targetUtc.AddMinutes(1));
}
Bu kural config'e alınmalıdır:
{
"Notifications": {
"AllowedLocalStartHour": 9,
"AllowedLocalEndHour": 22,
"MaxConcurrency": 20,
"MaxRetries": 3
}
}
Her notification tipi aşağıdaki pipeline'dan geçmelidir:
send now ve schedule later olarak ayır.LastSentAtUtc ve NotificationCount güncelle.Toplu notification gönderimlerinde aynı anda binlerce FCM isteği açmak risklidir. Uygulama ve Firebase tarafında basıncı kontrol etmek için concurrency limit kullanılmalıdır.
var semaphore = new SemaphoreSlim(maxConcurrency);
async Task<SendResult> SendOneAsync(NotificationPayload payload)
{
await semaphore.WaitAsync();
try
{
return await sender.SendAsync(payload);
}
finally
{
semaphore.Release();
}
}
Önerilen başlangıç değeri 20 olabilir. Bu değer sistem yüküne göre ölçülüp değiştirilmelidir.
FCM gönderim sonucu string ile değil, typed result ile temsil edilmelidir.
public enum SendStatus
{
Success,
RetryableFailure,
InvalidToken,
PermanentFailure
}
public sealed record SendResult(
SendStatus Status,
string Message,
string Token);
Retryable kabul edilebilecek FCM hataları:
UnavailableInternalQuotaExceededInvalid token kabul edilecek durumlar:
UnregisteredInvalidArgument (payload valid ise token problemi olabilir)Davranış:
Success: token istatistiklerini güncelle.RetryableFailure: app retry veya background job retry uygula.InvalidToken: token'ı pasifle.PermanentFailure: logla, retry etme.İki farklı üretim modeli desteklenmelidir.
Periyodik model:
NotificationProgress checkpoint olarak kullanılır.Event-driven model:
Sağlam sistemlerde event-driven notification için outbox pattern önerilir:
Scheduled job'a doğrudan class adı veya runtime-specific type bilgisi yazmak uzun vadede risklidir. Class adı veya namespace değişirse eski job'lar bozulabilir.
Daha güvenli yaklaşım version'lı envelope kullanmaktır:
{
"type": "new_customer",
"version": 1,
"payload": {
"userId": 123,
"token": "fcm_token",
"timezoneId": "Europe/Istanbul",
"customerName": "Ali Veli"
}
}
Job çalışırken dispatcher type ve version alanına göre doğru handler'ı çağırır.
Aktif token'ları görmek:
SELECT UserId, Platform, IsActive, NotificationCount, LastSentAtUtc, UpdatedAtUtc
FROM DeviceTokens
WHERE UserId = @UserId
ORDER BY UpdatedAtUtc DESC;
Limite takılan token'ları görmek:
SELECT UserId, Platform, NotificationCount, LastSentAtUtc
FROM DeviceTokens
WHERE IsActive = 1
AND NotificationCount >= @DailyLimit;
Progress görmek:
SELECT NotificationType, SuccessRunTimeUtc, LastRunStatus, UpdatedAtUtc
FROM NotificationProgress
ORDER BY NotificationType;
DeviceTokens tablosunu oluştur.NotificationProgress veya outbox tablosunu oluştur.userId eşleşmesini zorunlu tut.Bu mimarinin özü şudur: Notification göndermek sadece FCM'e HTTP isteği atmak değildir. Güvenilir bir notification sistemi token lifecycle, timezone, scheduling, retry, rate limit, checkpoint ve operasyonel gözlemlenebilirlik parçalarının birlikte çalışmasıyla oluşur.
This document describes a reusable FCM notification architecture that can be implemented in any backend project. A developer should be able to read this document and build the same system in their own project, regardless of framework or language.
The system provides:
| Component | Responsibility |
|---|---|
| Device token API | Receives token register/unregister requests |
| Device token service | Validates ownership and manages active/inactive state |
| Device token repository | Persists token records |
| Notification service | Finds candidates, applies timezone rules, starts sending |
| Notification sender | Sends messages through Firebase Admin SDK |
| Notification scheduler | Schedules messages for a later allowed local time |
| Progress/checkpoint store | Stores the last successful point for periodic jobs |
| Feature toggle service | Enables or disables notification types |
| Background job worker | Runs recurring and scheduled jobs |
Names can vary by stack. The important part is the separation of responsibilities.
A minimal implementation needs DeviceTokens and either NotificationProgress or an outbox table.
CREATE TABLE DeviceTokens
(
Id BIGINT IDENTITY(1,1) PRIMARY KEY,
UserId BIGINT NOT NULL,
Token NVARCHAR(MAX) NOT NULL,
TokenHash NVARCHAR(128) NULL,
Platform NVARCHAR(50) NOT NULL,
NotificationCount INT NOT NULL DEFAULT 0,
LastSentAtUtc DATETIME2 NULL,
CreatedAtUtc DATETIME2 NOT NULL DEFAULT SYSUTCDATETIME(),
UpdatedAtUtc DATETIME2 NOT NULL DEFAULT SYSUTCDATETIME(),
IsActive BIT NOT NULL DEFAULT 1
);
CREATE TABLE NotificationProgress
(
NotificationType NVARCHAR(100) NOT NULL PRIMARY KEY,
LastProcessedValue NVARCHAR(MAX) NULL,
SuccessRunTimeUtc DATETIME2 NOT NULL,
LastRunStatus BIT NOT NULL,
UpdatedAtUtc DATETIME2 NOT NULL DEFAULT SYSUTCDATETIME()
);
Do not delete tokens by default. Mark them inactive. This preserves counters, last sent time, and historical behavior.
Register request:
POST /api/device-tokens/register
Authorization: Bearer <access-token>
Content-Type: application/json
{
"userId": 123,
"token": "fcm_device_token",
"platform": "ios"
}
Register rules:
userId must match the authenticated user.Unregister should deactivate the token instead of deleting it.
Every timezone-aware notification should expose the data required for sending and scheduling.
public interface ITimezoneAwareNotification
{
long UserId { get; }
string Token { get; }
string TimezoneId { get; }
int? GmtOffsetSeconds { get; }
string GetTitle();
string GetBody();
Dictionary<string, string> GetData();
}
Prefer TimezoneId such as Europe/Istanbul or America/New_York. Fixed GMT offsets are simpler but do not handle daylight saving time correctly.
Default behavior:
These values should be configurable:
{
"Notifications": {
"AllowedLocalStartHour": 9,
"AllowedLocalEndHour": 22,
"MaxConcurrency": 20,
"MaxRetries": 3
}
}
Each notification type should follow the same pipeline:
send now and schedule later.LastSentAtUtc and NotificationCount for successful sends.Use a typed result instead of returning raw strings.
public enum SendStatus
{
Success,
RetryableFailure,
InvalidToken,
PermanentFailure
}
public sealed record SendResult(
SendStatus Status,
string Message,
string Token);
Usually retryable:
UnavailableInternalQuotaExceededUsually invalid-token cases:
UnregisteredInvalidArgument, if the payload itself is validBehavior:
Success: update token counters.RetryableFailure: retry through app logic or the job scheduler.InvalidToken: deactivate token.PermanentFailure: log and do not retry.Periodic notifications:
NotificationProgress as the checkpoint.Event-driven notifications:
Outbox flow:
Avoid storing runtime-specific class names as the long-term job contract. Use a versioned envelope.
{
"type": "new_customer",
"version": 1,
"payload": {
"userId": 123,
"token": "fcm_token",
"timezoneId": "Europe/Istanbul",
"customerName": "Ali Veli"
}
}
The job dispatcher can route by type and version.
DeviceTokens table.NotificationProgress or an outbox table.userId matching.A reliable notification system is not just an FCM call. It is a coordinated pipeline for token lifecycle, timezone rules, scheduling, retry, rate limits, checkpointing, and operational visibility.
این سند یک معماری قابل استفاده مجدد برای notification با FCM را توضیح میدهد. هدف این است که توسعهدهنده بتواند این سند را بخواند و همین سیستم را در پروژه خودش، با هر زبان یا framework، پیادهسازی کند.
این سیستم این نیازها را پوشش میدهد:
| جزء | مسئولیت |
|---|---|
| Device token API | دریافت درخواست register/unregister token |
| Device token service | اعتبارسنجی مالکیت token و مدیریت active/inactive |
| Device token repository | ذخیره tokenها در دیتابیس |
| Notification service | پیدا کردن کاندیدها و اعمال قانون timezone |
| Notification sender | ارسال پیام با Firebase Admin SDK |
| Notification scheduler | زمانبندی پیام برای ساعت مجاز |
| Progress/checkpoint store | ذخیره آخرین نقطه موفق برای jobهای دورهای |
| Feature toggle service | فعال یا غیرفعال کردن نوعهای notification |
| Background job worker | اجرای jobهای دورهای و scheduled |
نام کلاسها مهم نیست. مهم این است که مسئولیتها از هم جدا باشند.
حداقل پیادهسازی به جدول DeviceTokens و یکی از NotificationProgress یا outbox نیاز دارد.
CREATE TABLE DeviceTokens
(
Id BIGINT IDENTITY(1,1) PRIMARY KEY,
UserId BIGINT NOT NULL,
Token NVARCHAR(MAX) NOT NULL,
TokenHash NVARCHAR(128) NULL,
Platform NVARCHAR(50) NOT NULL,
NotificationCount INT NOT NULL DEFAULT 0,
LastSentAtUtc DATETIME2 NULL,
CreatedAtUtc DATETIME2 NOT NULL DEFAULT SYSUTCDATETIME(),
UpdatedAtUtc DATETIME2 NOT NULL DEFAULT SYSUTCDATETIME(),
IsActive BIT NOT NULL DEFAULT 1
);
CREATE TABLE NotificationProgress
(
NotificationType NVARCHAR(100) NOT NULL PRIMARY KEY,
LastProcessedValue NVARCHAR(MAX) NULL,
SuccessRunTimeUtc DATETIME2 NOT NULL,
LastRunStatus BIT NOT NULL,
UpdatedAtUtc DATETIME2 NOT NULL DEFAULT SYSUTCDATETIME()
);
به صورت پیشفرض token را حذف نکنید. بهتر است آن را inactive کنید. این کار باعث میشود شمارندهها و تاریخچه ارسال از بین نرود.
نمونه register:
POST /api/device-tokens/register
Authorization: Bearer <access-token>
Content-Type: application/json
{
"userId": 123,
"token": "fcm_device_token",
"platform": "ios"
}
قوانین register:
userId داخل request باید با user احراز هویتشده یکی باشد.در unregister بهتر است token حذف نشود و فقط inactive شود.
هر notification که به timezone وابسته است باید دادههای لازم برای ارسال و schedule را داشته باشد.
public interface ITimezoneAwareNotification
{
long UserId { get; }
string Token { get; }
string TimezoneId { get; }
int? GmtOffsetSeconds { get; }
string GetTitle();
string GetBody();
Dictionary<string, string> GetData();
}
بهتر است از TimezoneId مثل Europe/Istanbul یا America/New_York استفاده شود. GMT offset ثابت سادهتر است، اما daylight saving time را درست مدیریت نمیکند.
قانون پیشفرض:
این مقادیر بهتر است configurable باشند:
{
"Notifications": {
"AllowedLocalStartHour": 9,
"AllowedLocalEndHour": 22,
"MaxConcurrency": 20,
"MaxRetries": 3
}
}
هر نوع notification باید از همین pipeline عبور کند:
send now و schedule later تقسیم کن.LastSentAtUtc و NotificationCount را بهروزرسانی کن.بهتر است نتیجه ارسال به صورت typed result باشد.
public enum SendStatus
{
Success,
RetryableFailure,
InvalidToken,
PermanentFailure
}
public sealed record SendResult(
SendStatus Status,
string Message,
string Token);
خطاهای معمولا قابل retry:
UnavailableInternalQuotaExceededحالتهای token نامعتبر:
UnregisteredInvalidArgument، اگر payload درست باشدرفتار مناسب:
Success: counterهای token را بهروزرسانی کن.RetryableFailure: با app یا job scheduler retry کن.InvalidToken: token را inactive کن.PermanentFailure: log کن و retry نکن.Notification دورهای:
NotificationProgress به عنوان checkpoint استفاده میشود.Notification event-driven:
جریان outbox:
بهتر است class name یا type runtime را به عنوان قرارداد اصلی job ذخیره نکنید. از envelope دارای version استفاده کنید.
{
"type": "new_customer",
"version": 1,
"payload": {
"userId": 123,
"token": "fcm_token",
"timezoneId": "Europe/Istanbul",
"customerName": "Ali Veli"
}
}
dispatcher میتواند بر اساس type و version handler مناسب را پیدا کند.
DeviceTokens را بسازید.NotificationProgress یا outbox را بسازید.userId داخل request را اجباری کنید.یک notification system قابل اعتماد فقط یک call به FCM نیست. این سیستم از token lifecycle، timezone، scheduling، retry، rate limit، checkpoint و observability تشکیل میشود.