Flutter’da Neden Singleton Yerine Dependency Injection Kullanmalısınız?
Merhaba, değerli arkadaşlar bu yazımda sizlere başlıktan anlaşıldığı üzere Singleton yerine neden Dependency Injection kullanmalıyız onu anlatmaya çalışacağım. Umarım sizler için faydalı bir makale olur. Sözü daha fazla uzatmadan konumuza adım adım giriş yapalım
Bir yazılım projesini, büyük ve hareketli bir restorana benzetebiliriz. Her sınıf, kendi görevini yapan bir şeftir. KullaniciRepository kullanıcı verilerini getirir, OdemeServisi ödemeleri yönetir, ArayuzWidgeti yemeği müşterinin masasına sunar. Peki bu şefler, ihtiyaç duydukları malzemelere ve aletlere nasıl ulaşır?
Bu makalede, bu temel soruya verilen iki farklı cevabı, Singleton ve Dependency Injection (DI) desenlerini derinlemesine inceleyeceğiz. Bu iki yaklaşım arasındaki farkı anlamak, sadece daha iyi kod yazmanızı sağlamaz aynı zamanda daha test edilebilir, esnek ve geleceğe dönük uygulamalar inşa etmenizin de anahtarını size verir.
Tekillik ve Paylaşım İhtiyacı
Makalemize direkt olarak başlamadan önce, en temel soruyu sormalıyız: Neden bir sınıfın tek bir nesnesine ihtiyaç duyarız ki? Neden var nesne1 = Sinif(); var nesne2 = Sinif(); diye istediğimiz kadar nesne yaratmak varken kendimizi tek bir nesneyle kısıtlayalım?
Bu ihtiyacın temelinde iki güçlü sebep yatar: Kaynak Yönetimi ve Durum Tutarlılığı.
a) Kaynak Yönetimi:
Bazı işlemler ve kaynaklar doğası gereği “pahalıdır”. Onları oluşturmak zaman, bellek ve işlem gücü gerektirir.
Düşünün ki uygulamanız, cihazdaki bir SQLite veritabanıyla konuşuyor. Kullanıcının her “Kaydet” butonuna basışında, veritabanı dosyasını sıfırdan açan, bağlantıyı kuran ve sonra kapatan yeni bir “Veritabanı Yöneticisi” nesnesi yarattığınızı hayal edin. Bu, inanılmaz derecede yavaş ve verimsiz olurdu.
Mantıklı olan, uygulama açıldığında tek bir veritabanı bağlantısı kurmak ve tüm işlemlerin bu tek bağlantı üzerinden yapılmasını sağlamaktır.
Kısacası, bazı servisler bir “fabrika” gibidir. Bir ürün almak için her seferinde yeni bir fabrika kurmazsınız; var olan tek bir fabrikanın üretim bandını kullanırsınız.
b) Durum Tutarlılığı
Bir de uygulamanın genel durumunu (state) yöneten servisler vardır. Bu servislerdeki bilginin tüm uygulama genelinde tutarlı olması hayati önem taşır.
Kullanıcı, uygulamanın temasını “Koyu Tema” olarak seçti. Bu bilgi, SettingsService nesnesinde tutuluyor. Eğer bu servisten birden fazla örnek varsa, bir sayfa koyu temayı uygularken diğeri hala açık temada kalabilir. Tutarlılık için, tema bilgisinin sorgulanacağı tek bir yetkili kaynak olmalıdır.
Bu iki temel ihtiyaç — verimli kaynak kullanımı ve tutarlı durum yönetimi — bizi şu sonuca götürür: “Bazı sınıfların, uygulama yaşam döngüsü boyunca sadece ve sadece bir tane örneği olmalıdır.”
İşte bu noktada yazılım dünyası, bu problemi çözmek için iki farklı yol haritası sunar: Singleton ve Dependency Injection. Şimdi bu yolları ve hangisinin bizi daha güvenli bir limana ulaştıracağını keşfedelim.
Makalemize, birçok geliştiricinin ilk öğrendiği ama potansiyel tehlikelerle dolu bir yaklaşımla başlayalım: Singleton.
Singleton Nedir?
Singleton, en basit haliyle, bir sınıfın uygulama boyunca sadece bir tane örneğinin (nesnesinin) olmasını garanti eden bir tasarım desenidir. Ne zaman o sınıfa ihtiyaç duysanız, sistem size her zaman aynı, tek nesneyi verir.
Betimlemek gerekirse şöyle düşünelim arkadaşlar, bir bina olsun ve bu binadaki kapıları açan “tek” bir ana anahtarımız olsun. Bina sakinlerinde bu ana anahtar veya bir kopyası yok fakat ana anahtarın bina girişindeki kutuda olduğunu herkes biliyor. Yani bir bina sakini kapısını açmak istediğinde bina girişindeki kutudan ana anahtarı alarak bu işlemi yapabiliyor. Diğer tüm sakinlerde aynı şekilde bu işlemi yapıyor.
Buradaki ana anahtar kavramı bizin Singleton yapımızı temsil ederken bina girişindeki kutu ise Singleton yapımıza eriştiğimiz global yapımızı temsil eder
Singleton kavramını bilmeyen arkadaşlar için bir örnek yapalım. Örneğimizde uygulamamızın her yerinden erişmek istediğimiz bir ApiService (sunucuyla konuşan servis) olduğunu varsayalım.
class ApiService {
// 1. Gizli kasadaki o tek anahtarı yaratıyoruz.
static final ApiService _instance = ApiService._internal();
// 2. Kasanın anahtarını sadece sınıfın kendisi bilsin diye, dışarıdan kilitliyoruz.
// Kimse 'new ApiService()' yapamaz.
ApiService._internal();
// 3. Herkesin anahtara ulaşabilmesi için bir "fabrika" metodu sağlıyoruz.
// Bu metot her zaman aynı anahtarı (_instance) verir.
factory ApiService() {
return _instance;
}
// Bu servisin görevi
Future<String> fetchData() async {
print("Sunucudan veri çekiliyor...");
await Future.delayed(Duration(seconds: 1)); // Simülasyon
return "Sunucudan Gelen Veri";
}
}
Yukarıdaki kod bloğunda görüldüğü üzere Singleton yapımız hazır. Fakat kodları biraz daha açıklamak istiyorum.
Bu sınıfın temel amacı;
- Nesneyi gizlice kendi içinde yarat.
- Dışarıdan yeni nesneler yaratılmasını engelle.
- İsteyen herkese, yarattığın o tek nesneyi ver.
olarak tanımlanabilir.
Adım adım gidersek kodlardaki yansıması şu şekildedir;
a) _instance değişkeni static olduğu için, ApiService sınıfından kaç tane referans oluşturulursa oluşturulsun, bellekte her zaman tek bir _instance bulunur.
b) _instance değişkenine ApiService._internal() ile ilk değer atandıktan sonra, kodun başka hiçbir yerinde yanlışlıkla _instance = baskaBirsey; yazılamaz. Bu, Singleton’ımızın güvenliğini artırır.
c) _instance (Alt Tire ile Başlayan İsim): Dart dilinde, bir değişkenin, metodun veya sınıfın adının _ (alt tire) ile başlaması, onun özel (private) olduğunu belirtir. “Özel” demek, o elemana sadece tanımlandığı dosyanın içinden erişilebilir demektir. Başka bir Dart dosyasından ApiService._instance yazarak bu değişkene doğrudan ulaşamazsınız. Bu, Singleton’ımızın iç yapısını dış dünyadan gizleyerek bir koruma kalkanı oluşturur.
Program çalışmaya başladığı anda, ApiService sınıfı, dış dünyadan gizli, değiştirilemez ve kendisine ait olan tek bir nesne (_instance) yaratır.
Peki bu yapıyı nasıl kullanıyoruz?
class HomePageViewModel {
void loadUserData() async {
// İhtiyaç duyduğumuzda gidip "kasadan" anahtarı kendimiz alıyoruz.
String data = await ApiService().fetchData();
print(data);
}
}
Uygulamanın neresinde olursanız olun ApiService() yazdığınızda, sunucuyla konuşacak o tek nesneye ulaşırsınız. Bu açıdan bakınca bu yapıyı oluşturup kullanmanın bir sorun teşkil etmediğini düşünebilirsiniz ama öyle değil!
- Sıkı Sıkıya Bağlılık (Tight Coupling)
HomePageViewModel sınıfımız, ApiService’e sıkı sıkıya bağlanmıştır. Bu bir aşk ilişkisi gibi değil, daha çok kurtulamadığınız bir pranga gibidir. HomePageViewModel, ApiService olmadan yaşayamaz ve bu bağımlılık kodun derinliklerine gizlenmiştir. Dışarıdan bakan birisi, HomePageViewModel’in tanımına bakarak bu bağımlılığı göremez. - Değiştirilemeyen Parçalar
Uygulamanızı test etmek istediğinizi düşünün. HomePageViewModel’i test ederken, her seferinde gerçek sunucuya bağlanmasını istemezsiniz. Bu hem yavaştır hem de internet bağlantısı gerektirir. Test sırasında gerçek ApiService yerine, sahte veriler döndüren bir SahteApiService kullanmak istersiniz.Ama yapamazsınız! HomePageViewModel kodunun içine ApiService() yazıldığı için, onu değiştiremezsiniz. Test için ona “Bugünlük şu sahte servisi kullan” deme şansınız yoktur. Bu, bir arabayı test etmek isteyip motorunu değiştirememeye benzer. - Global Durumun Tehlikeleri
Singleton nesneleri global değişkenler gibi davranır. Uygulamanın bir köşesinde ApiService’in bir ayarını (mesela timeout süresini) değiştirirseniz, bu değişiklik uygulamanın hiç beklemediğiniz başka bir köşesini etkileyebilir. Bir domino taşını devirirsiniz ve nerede duracağını bilemezsiniz.
Bu problemler, küçük projelerde fark edilmeyebilir. Ancak proje büyüdükçe, bu gizli bağımlılıklar ve test edilemeyen kodlar, projenizi bir “spagetti kod” yığınına çevirir.
İşte bu sorunlardan kurtulmak için Dart dilinde modern bir yaklaşım olan Dependency Injection kullanılır.
Dependency Injection(DI) Nedir
DI’ın temel fikri şudur: Bir sınıf, ihtiyaç duyduğu şeyleri (bağımlılıkları) kendisi yaratmaz veya arayıp bulmaz. İhtiyaç duyduğu şeyler ona dışarıdan verilir (enjekte edilir).
Lafı hiç uzatmadan yukarıdaki kod örneklerimize geri dönüyoruz
// Adım 1: Bağımlılığı bir "sözleşme" (abstract class) ile tanımla.
// Bu, "bana veri getirebilen herhangi bir şey" demektir.
abstract class IApiService {
Future<String> fetchData();
}
// Adım 2: Gerçek işi yapan somut sınıfı bu sözleşmeye uygun olarak yaz.
class ApiService implements IApiService {
@override
Future<String> fetchData() async {
print("Sunucudan veri çekiliyor...");
await Future.delayed(Duration(seconds: 1));
return "Sunucudan Gelen Veri";
}
}
// Adım 3: Sınıfın, bağımlılığını constructor'da istemesini sağla.
class HomePageViewModel {
// Bu sınıfın bir IApiService'e ihtiyacı var. Ama hangisi olduğu umrunda değil.
final IApiService _apiService;
// CONSTRUCTOR INJECTION: Bağımlılık burada "enjekte" ediliyor.
HomePageViewModel(this._apiService);
void loadUserData() async {
// Kendisine dışarıdan verilen servisi kullanıyor.
String data = await _apiService.fetchData();
print(data);
}
}
Not: abstract kavramını bilmiyorsanız buraya tıklayarak Dart Dilinde OOP Temelleri adlı serimde ele aldığım Abstract & Mixin kavramlarının ne olduğuna göz atabilirsiniz!
Uygulamada kullanımı
void main() {
// 1. İhtiyaç duyulan bağımlılık (servis) oluşturulur.
IApiService gercekApiService = ApiService();
// 2. Bağımlılık, onu isteyen sınıfa constructor üzerinden "enjekte edilir".
var viewModel = HomePageViewModel(gercekApiService);
// 3. Artık viewModel kullanıma hazır.
viewModel.loadUserData();
}
Peki bu enjecte etmenin bize kazandırdığı şeyler ne?
- Kristal Berraklığında Bağımlılıklar (Loose Coupling)
Artık HomePageViewModel’in tanımına baktığınızda, onun çalışmak için bir IApiService’e ihtiyacı olduğunu anında anlarsınız. Hiçbir şey gizli değil. Sınıflar birbirine gevşekçe bağlıdır (loose coupling). HomePageViewModel, ApiService’in somut halini bilmez, sadece sözleşmeyi (IApiService) bilir. - Test Edilebilirlik
Şimdi test yazmak bir zevktir. HomePageViewModel’i test ederken, ona gerçek servis yerine sahte bir servis enjekte edebiliriz.
// Test için sahte bir servis oluşturalım
class SahteApiService implements IApiService {
@override
Future<String> fetchData() async {
return "Bu Sahte Test Verisidir";
}
}
void testViewModel() {
// 1. Sahte bağımlılığı oluştur.
IApiService sahteServis = SahteApiService();
// 2. Sahte bağımlılığı test edeceğin sınıfa enjekte et.
var viewModel = HomePageViewModel(sahteServis);
// 3. Test et! Bu kod asla gerçek sunucuya gitmeyecek.
viewModel.loadUserData();
// Çıktı: "Bu Sahte Test Verisidir"
}
3. Esneklik ve Değiştirilebilirlik
Yarın öbür gün ApiService’i kullanmaktan vazgeçip yeni bir GraphQLApiService kullanmaya karar verdiniz diyelim. Ne yapmanız gerekir?
- GraphQLApiService adında, IApiService sözleşmesini uygulayan yeni bir sınıf yazarsınız.
- Uygulamanın başlangıç noktasındaki tek bir satırı değiştirirsiniz:
IApiService gercekApiService = GraphQLApiService(); - HomePageViewModel dahil uygulamanın geri kalan hiçbir yerinde tek bir satır kod değiştirmenize gerek kalmaz. Çünkü onlar sadece sözleşmeyi tanıyorlardı.
İyi de, onlarca servis ve yüzlerce ViewModel olduğunda, main fonksiyonunda hepsini elle yaratıp birbirine enjekte etmek çok zor olmaz mı? Zor ve bizi hayattan soğutacağını garanti edebilirim. Bu yüzden bu işi bizim yerimize yapacak, bizim işimizi kolaylaştıracak bir paket var; get_it
DI Konteynerleri (get_it)
Teoride Dependency Injection harika. Ama yüzlerce sınıf ve servis olduğunda, kimin kime bağımlı olduğunu takip edip hepsini elle birbirine bağlamak bir kabusa dönüşebilir. İşte get_it gibi bir Service Locator (Servis Bulucu) veya DI Konteyneri, bu süreci bizim için otomatikleştiren bir “akıllı asistan” görevi görür.
get_it, uygulamanızın “beyni” gibi davranır. Başlangıçta ona tüm servislerinizi ve sınıflarınızı nasıl oluşturacağını öğretirsiniz. Daha sonra, ihtiyacınız olduğunda sadece “Bana şunu ver” dersiniz, o da sizin için doğru nesneyi bulur veya yaratır.
Bunu yine örnekle açıklayalım;
Öncelikle pub dev üzerinden projenize get_it paketini eklemeniz gerekiyor.
Buradan pakete gidebilirsiniz.
dependencies:
flutter:
sdk: flutter
get_it: ^8.0.3 # ya da sizin kullanacağız farklı bir sürüm
Paketi ekledikten sonra tavsiyem ayrı bir dosya olarak locator kurmanız. Yani uygulamanızın herhangi bir yerinden erişilebilecek bir “locator” (bulucu) nesnesi oluşturmak “bence” iyi bir yoldur. Genellikle locator.dart veya service_locator.dart gibi ayrı bir dosyada yapılır. Sizde bu adı verip ayrı bir dosya oluşturun.
import 'package:get_it/get_it.dart';
import 'api_service.dart'; // Önceki örnekteki sınıflarımızı import ediyoruz
import 'home_page_view_model.dart';
// get_it'in global erişim noktasını oluşturuyoruz.
final getIt = GetIt.instance;
// Bu fonksiyon, uygulama başlamadan önce çağrılacak ve tüm bağımlılıkları kaydedecek.
void setupLocator() {
// SERVİSLERİ KAYDETME
// Kural 1: "Ne zaman birisi IApiService istese, ona HER ZAMAN AYNI ApiService nesnesini ver."
// Bu, 'lazySingleton' olarak kaydedilir. Yani, sadece ilk kez istendiğinde oluşturulur.
getIt.registerLazySingleton<IApiService>(() => ApiService());
// VIEW MODELLERİ KAYDETME
// Kural 2: "Ne zaman birisi HomePageViewModel istese, ona HER SEFERİNDE YENİ bir nesne oluştur."
// Bu, 'factory' olarak kaydedilir. Bu, state içeren ViewModel'ler için daha güvenlidir.
// getIt<IApiService>() ifadesiyle, get_it'e "bu ViewModel'i yaratırken, daha önce kaydettiğim
// IApiService'i bul ve constructor'ına parametre olarak ver" diyoruz.
getIt.registerFactory<HomePageViewModel>(() => HomePageViewModel(getIt<IApiService>()));
// Alternatif Kural 2 (Eğer ViewModel'in de tek olmasını isterseniz):
// getIt.registerLazySingleton<HomePageViewModel>(() => HomePageViewModel(getIt<IApiService>()));
}
Satır aralarında yorumlar eklediğim için uzun uzun açıklama yapmıyorum ek olarak. Sadece registerLazySingleton ve registerFactory arasındaki farka değinmek istiyorum.
- registerLazySingleton: Sınıfı “Singleton” olarak kaydeder. İlk çağrıldığında bir nesne yaratılır ve sonraki tüm çağrılarda hep aynı nesne döndürülür. Servisler, Repository’ler gibi durum (state) tutmayan sınıflar için idealdir.
- registerFactory: Sınıfı bir “fabrika” olarak kaydeder. Her çağrıldığında yeni bir nesne yaratılır. Flutter’da bir sayfaya her girildiğinde ViewModel’in temiz bir durumda başlaması istendiğinde bu çok kullanışlıdır.
Locator kurduktan sonra main dart dosyamızda bu locatoru çağırmamız gerekiyor.
import 'package:flutter/material.dart';
import 'locator.dart';
import 'home_page.dart'; // Arayüzümüzü göstereceğimiz sayfa
void main() {
// Uygulama başlamadan önce "beyni" kuruyoruz.
setupLocator();
runApp(MyApp());
}
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'GetIt Demo',
home: HomePage(),
);
}
}
Buraya kadar artık get_it bizim emrimize amade oldu. Şimdi ara yüz tarafında kullanarak ne kadar sadık olduğunu test edelim.
import 'package:flutter/material.dart';
import 'locator.dart';
import 'home_page_view_model.dart';
class HomePage extends StatefulWidget {
const HomePage({super.key});
@override
State<HomePage> createState() => _HomePageState();
}
class _HomePageState extends State<HomePage> {
// ViewModel'imizi get_it'ten istiyoruz.
// 'setupLocator'da factory olarak kaydettiğimiz için bu satır her çalıştığında yeni bir nesne gelir.
final HomePageViewModel _viewModel = getIt<HomePageViewModel>();
String _data = "Yükleniyor...";
@override
void initState() {
super.initState();
_loadData();
}
void _loadData() async {
// ViewModel'in içindeki iş mantığını çağırıyoruz.
// ViewModel, kendi bağımlılığı olan ApiService'i get_it sayesinde zaten tanıyor.
final result = await _viewModel.fetchSomeData(); // ViewModel'in metodunu çağırdık
setState(() {
_data = result;
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text("GetIt ile DI Demo"),
),
body: Center(
child: Text(
_data,
style: Theme.of(context).textTheme.headlineMedium,
),
),
floatingActionButton: FloatingActionButton(
onPressed: _loadData,
child: Icon(Icons.refresh),
),
);
}
}
Sadakati test için birde view model üzerinden test edelim
import 'api_service.dart';
class HomePageViewModel {
final IApiService _apiService;
// Constructor hala aynı, bağımlılığını dışarıdan alıyor.
// get_it, bu constructor'ı bizim için çağıracak.
HomePageViewModel(this._apiService);
Future<String> fetchSomeData() async {
// Sadece kendi işini yapıyor, ApiService'in nereden geldiğiyle ilgilenmiyor.
return await _apiService.fetchData();
}
}
Ve işte bu kadar.. Çok kısa özetlemek gerekirse;
get_it ile:
- Kurulum: Başlangıçta tüm sınıflarımızı ve onların nasıl yaratılacağını get_it’e kaydediyoruz.
- Kullanım: İhtiyaç duyduğumuz yerde getIt<IstedigimizSinif>() diyerek nesnemizi istiyoruz.
Bu yaklaşım, Singleton’ın “global erişim” kolaylığını, Dependency Injection’ın “test edilebilirlik ve esneklik” gücüyle birleştirir. HomePage sınıfımız, HomePageViewModel’in nasıl yaratıldığını veya onun ApiService’e bağımlı olduğunu bilmek zorunda değil. Sadece “Bana bir HomePageViewModel ver” der ve işine bakar.
Evet değerli okurlar, takipçiler ve geliştirici arkadaşlar biliyorum üzüleceksiniz ama bir makalenin daha sonuna geldik.
Bu makalede Neden Singleton Yerine Dependency Injection Kullanmalısınız sorusuna elimden geldiğince yanıt vermeye çalıştım. Kavramları açıklamaya çalıştım. Umarım ilgilisi ve meraklısı için faydalı bir yazı olmuştur.
Buraya kadar okuyup, destekleyen, beğenen, paylaşan ve yorum yapan herkese teşekkür ederim. Sonraki makalelerde görüşmek üzere!
Github: www.github.com/abdullah017
Linkedin: www.linkedin.com/in/abdullahtas
Stackoverflow: https://stackoverflow.com/users/13807726/abdullah-t