Bir üretim firmasının ERP’sinde çalışırken, ya da kendi yan ürünlerimden birinin backend’ini geliştirirken, yazılım mimarisi üzerine aldığım en kritik kararlardan biri genellikle monolith mi, yoksa daha dağıtık bir yapı mı olacağı üzerineydi. Geçmişte monolith yapıların getirdiği sıkıntıları çok yaşadım. Bu yüzden, tam anlamıyla microservice mimarisine geçmeden önce, modüler monolith yaklaşımının bana sunduğu 3 pragmatik sebebi burada ele almak istedim.
Bu geçiş kararı, sadece teknolojik bir tercih değil, aynı zamanda geliştirme süreçlerimizi, hata yönetimimizi ve teknik borcumuzu nasıl ele alacağımızı doğrudan etkileyen bir iş kararıydı. Benim için bu, “büyük resmi” daha yönetilebilir parçalara bölmek anlamına geliyordu.
1. Geliştirme Hızı ve Bağımsız Takımlar İçin Modülerleşme
Büyük ve karmaşık monolith projelerde en sık karşılaştığım sorunlardan biri, geliştirme hızının zamanla düşmesiydi. Yeni bir özellik eklemek veya mevcut bir hatayı düzeltmek bile, tüm kod tabanında potansiyel yan etkiler yaratma korkusuyla yavaşlayabiliyordu. Özellikle bir üretim ERP’sinde “satın-al/üret/sevk/fatura” gibi kritik akışlar üzerinde çalışırken, en küçük değişiklik bile haftalar süren regresyon testlerine yol açabiliyordu. Bu durum, takımların bağımsız hareket etme yeteneğini ciddi şekilde kısıtlıyordu.
Modüler bir monolith’e geçtiğimizde, her modül kendi sorumluluk alanına sahip olduğu için, farklı takımlar aynı kod tabanı üzerinde ama farklı modüller üzerinde çok daha bağımsız çalışabiliyor. Örneğin, bir takım sipariş yönetimi modülünü geliştirirken, başka bir takım stok yönetimi modülünde aynı anda ilerleyebiliyor. Bu, CI/CD pipeline’larımızın da daha verimli çalışmasını sağladı. Önceden tüm ERP’nin testlerinin koşması uzun sürerken, modülerleşme sayesinde sadece ilgili modülün testleri koşuyor ve çok daha kısa sürede tamamlanabiliyor, bu da deploy sıklığımızı belirgin şekilde artırdı.
Bu bağımsızlık, sadece geliştirme hızını artırmakla kalmıyor, aynı zamanda takımların kendi teknolojilerini veya kütüphane versiyonlarını seçme özgürlüğünü de bir dereceye kadar sağlıyor. Örneğin, bir modül FastAPI kullanırken, diğer bir modül hala Flask ile çalışmaya devam edebilir, çünkü arayüzleri net bir şekilde tanımlanmış API’ler üzerinden iletişim kurar. Bu esneklik, teknolojik borcun birikmesini de yavaşlatıyor, çünkü eski modülleri hemen güncellemek zorunda kalmıyoruz.
2. Kapsamlı Regresyon Riskini Azaltma
Monolith mimarilerde, tek bir kod değişikliğinin tüm sistemi etkileme riski her zaman en büyük korkularımdan biri olmuştur. Özellikle bir müşteri projesinde, kritik bir güvenlik yamasının uygulanması sırasında yaşanan bir olay aklıma geliyor. Basit görünen bir güncelleme, sistemin hiç beklenmedik bir yerindeki bağımlılığın kırılmasına ve tüm uygulamanın production ortamında uzun süre erişilemez hale gelmesine neden olmuştu. Bu tür kapsamlı regresyonlar, hem müşteri güvenini sarsıyor hem de ekibin motivasyonunu düşürüyor.
Modüler monolith yapıda ise, her modülün kendi sınırları ve sorumlulukları olduğu için, bir modülde yapılan değişikliklerin diğer modülleri etkileme olasılığı büyük ölçüde azalır. Modüller arası iletişim genellikle açıkça tanımlanmış API’ler veya mesaj kuyrukları üzerinden gerçekleşir, bu da bağımlılıkları daha görünür ve yönetilebilir kılar. Eğer bir modülde bir hata olursa, bu genellikle sadece o modülü etkiler ve tüm sistemin çökmesini engeller. Bu yaklaşım, özellikle hızlı tempolu geliştirme ortamlarında kritik bir avantaj sağlıyor.
# Monolith'teki olası bir risk: Tek bir ortak helper fonksiyonu
# Bu fonksiyon değiştiğinde, onu kullanan her yer potansiyel olarak etkilenebilir.
# finance_module/utils.py
def calculate_tax(amount):
# Karmaşık vergi hesaplama mantığı
return amount * 0.18
# order_module/views.py
def create_order(request_data):
total = request_data['price'] * request_data['quantity']
tax_amount = calculate_tax(total) # Ortak fonksiyon kullanımı
# ...
# inventory_module/tasks.py
def adjust_stock_value(item_id, quantity):
item_price = get_item_price(item_id)
tax_on_item = calculate_tax(item_price) # Yine ortak fonksiyon
# ...
Yukarıdaki gibi bir senaryoda, calculate_tax fonksiyonunda yapılan bir değişiklik, order_module ve inventory_module’deki beklenmedik davranışlara yol açabilir. Modüler monolith’te ise, her modülün kendi calculate_tax implementasyonu veya bir TaxService modülü üzerinden net bir API çağrısı olurdu. Bu, bağımlılıkları açıkça belirleyerek, değişikliklerin etkisini daha iyi öngörmemizi sağlıyor. Kritik bir modülün veritabanı şemasını değiştirirken, bu modüler yaklaşım sayesinde tüm sistemi baştan sona test etmek yerine sadece ilgili modülün testlerini çalıştırmak yeterli olabiliyor. Bu da, hataların erken tespit edilip izole edilmesinde büyük fayda sağlıyor.
3. Bakım Kolaylığı ve Teknik Borç Yönetimi
Yıllar içinde gördüğüm en yaygın sorunlardan biri, monolith uygulamaların zamanla biriken teknik borçları ve bunun getirdiği bakım zorluklarıdır. Kod tabanı büyüdükçe, eski teknolojiler, kötü tasarım kararları ve güncel olmayan kütüphaneler birikiyor. Bu, özellikle yeni geliştiricilerin projeye adaptasyonunu zorlaştırıyor ve mevcut kodun anlaşılmasını ve değiştirilmesini bir kabusa çevirebiliyor. Yıllardır biriken büyük bir monolith uygulamada, sadece “boot-up” süresini optimize etmek için bile ciddi zaman harcamak gerekebiliyor.
Modüler monolith, bu teknik borcu daha küçük, yönetilebilir parçalara ayırma fırsatı sunar. Her modül kendi içinde tutarlı bir teknoloji yığınına sahip olabilir ve bu modüller bağımsız olarak refactor edilebilir veya güncellenebilir. Bu, eski ve yeni teknolojilerin bir arada barınmasına olanak tanırken, genel sistemin esnekliğini korur. Örneğin, bir modül hala eski bir Python 2.7 codebase’i üzerinde çalışırken, yeni geliştirilen bir modül Python 3.10 ve FastAPI kullanabilir. Bu sayede, kritik olmayan eski modüllerin hemen güncellenmesi zorunluluğu ortadan kalkar ve teknik borç daha stratejik bir şekilde yönetilir.
Bakım kolaylığı sadece teknik borçla sınırlı değil. Ayrıca hata ayıklama ve izleme süreçlerini de basitleştirir. Modüller arası sınırlar net olduğunda, bir performans sorununun veya bir hatanın hangi modülden kaynaklandığını tespit etmek çok daha kolay hale gelir. journald ve cgroup limitlerini kullanarak, her modülün kaynak tüketimini ayrı ayrı izleyebiliyorum. Örneğin, belirli bir modülün beklenenden fazla memory kullanması durumunda, sadece o modülün cgroup memory.high değerini ayarlayarak diğer modüllerin etkilenmesini engelledim. Bu, genel sistem stabilitesini artırırken, bakım süreçlerini de hızlandırıyor.
Modüler Monolith’in Zorlukları ve Çözümlerim
Modüler monolith’e geçiş, her zaman güllük gülistanlık bir süreç değildi. Bu yaklaşımın da kendine göre zorlukları olduğunu gördüm. En başta, modül sınırlarını doğru çizmek büyük bir meydan okumaydı. İş akışlarını ve veri bağımlılıklarını anlamadan yapılan ayrıştırmalar, aslında daha kötü “dağıtık monolith” yapılarına yol açabiliyordu. Aslında çok sıkı bir şekilde birbirine bağlı iki modülü, bu bağımlılığı fark etmeden ayırmaya çalışmak, gereksiz iletişim katmanlarına ve veri tutarsızlıklarına neden olur.
Bu tür durumlarda, benim çözümüm her zaman “iş akışını” temel almak oldu. Yazılım mimarisinin çoğu zaman yazılım değil, organizasyonel akış olduğunu deneyimlerimle öğrendim. Bir “satın alma” süreci, bir “üretim emri” süreci veya bir “sevkiyat” süreci gibi net iş akışları, modül sınırlarını belirlemede en iyi rehberim oldu. Her modül, kendi veri alanından sorumlu olmalı ve diğer modüllerle sadece iyi tanımlanmış API’ler üzerinden iletişim kurmalıydı.
# Modüler monolith içinde Nginx ile modül yönlendirme örneği
# Ana uygulama hala tek bir sunucuda çalışıyor olsa da, mantıksal olarak ayrık
# modüllerin API'leri farklı path'lerden erişilebilir hale getirilebilir.
server {
listen 80;
server_name myapp.com;
location /api/orders/ {
proxy_pass http://localhost:8001; # Sipariş Modülü
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
location /api/inventory/ {
proxy_pass http://localhost:8002; # Stok Modülü
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
location / {
proxy_pass http://localhost:8000; # Ana Monolith (diğer kısımlar)
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}
Modüller arası veri paylaşımı da bir başka sorundu. İlk başta her modülün kendi veritabanı olması cazip gelse de, bu “gerçek dünya” senaryolarında karmaşık distributed transaction sorunlarına yol açabiliyordu. Ben genellikle shared database yaklaşımını tercih ettim, ancak her modülün kendi şema ön ekini kullanmasını veya belirli tabloların sahipliğini net bir şekilde tanımlamasını sağladım. Bu, PostgreSQL’de logical replication kullanarak okuma replikalarını ayrı modüller için optimize etme gibi stratejilerle desteklendi. PostgreSQL replikasyon stratejileri
Uygulama Stratejileri: Nereden Başlamalı?
Mevcut bir monolith’i modüler hale getirmek her zaman sıfırdan başlamaktan daha zordur. Benim bu konuda uyguladığım en başarılı strateji, “Strangler Fig Pattern” oldu. Bu modelde, mevcut monolith’i hemen parçalamak yerine, yeni işlevsellikleri ayrı modüller olarak geliştirir ve bunları yavaş yavaş monolith’in etrafına sararsınız. Eski monolith’ten gelen çağrıları yavaş yavaş yeni modüllere yönlendirirsiniz. Bu, riski minimize ederek ve kesintisiz bir geçiş sağlayarak yapılabilir.
Örneğin, bir üretim ERP’sinde ana “sevkiyat” akışını yeni bir modüle taşırken izlenebilecek yol şudur: ilk adım olarak yeni sevkiyat modülü için ayrı bir API tanımlanır ve bu modülün kendi veritabanı tabloları oluşturulur. Daha sonra, monolith içindeki sevkiyatla ilgili tüm çağrıları yeni modülün API’sine yönlendiren bir feature flag veya reverse proxy kuralı eklenir.
# docker-compose.yml içinde modüler monolith örneği
# Her servis ayrı bir modülü temsil ediyor.
version: '3.8'
services:
nginx:
image: nginx:latest
ports:
- "80:80"
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf:ro
depends_on:
- main_app
- order_service
- inventory_service
main_app:
build: ./main_app
environment:
DATABASE_URL: postgresql://user:pass@db:5432/main_db
# ... diğer configler
order_service:
build: ./order_service
environment:
DATABASE_URL: postgresql://user:pass@db:5432/order_db
# ... diğer configler
inventory_service:
build: ./inventory_service
environment:
DATABASE_URL: postgresql://user:pass@db:5432/inventory_db
# ... diğer configler
db:
image: postgres:14
environment:
POSTGRES_DB: main_db
POSTGRES_USER: user
POSTGRES_PASSWORD: pass
volumes:
- db_data:/var/lib/postgresql/data
# ... diğer configler
volumes:
db_data:
Bu docker-compose yapısı, modüler monolith’in “bare-metal + container hibrit dağıtım” yaklaşımına nasıl uyduğunu gösteriyor. Her modül, kendi Docker container’ında çalışıyor ve Nginx veya API Gateway ile yönlendiriliyor. Böylece her modülün systemd unit’leri, journald logları ve cgroup limitleri ayrı ayrı yönetilebiliyor. Bare-metal ve Container hibrit dağıtım Bu sayede, “sipariş modülü”nde bir build OOM sorunu yaşandığında, bu diğer modülleri etkilemedi. Bir modülün kaynak sınırını aşıp OOM-killed olması durumunda bile yalnızca o modülün etkilendiğini, geri kalan sistemin ayakta kaldığını görmek bu izolasyonun en somut faydalarından biriydi.
Benim Deneyimimden İpuçları ve Dersler
Modüler monolith mimarisine geçerken veya bu yapıyı tasarlarken edindiğim en önemli derslerden biri, modül sınırlarının net ve tutarlı olması gerektiğidir. Eğer sınırlar bulanık olursa, dağıtık monolith’e dönüşme riski çok yüksektir. Bu da, microservice’lerin getirdiği karmaşıklığı yaşarken, onların avantajlarından faydalanamamak anlamına gelir. Ben her zaman “iş alanları” ve “veri sahipliği” prensiplerini temel alarak modülleri ayırdım.
Bir diğer önemli ipucu ise, modüller arası iletişimi mümkün olduğunca basitleştirmektir. REST API’ler veya mesaj kuyrukları (örneğin Redis pub/sub) genellikle yeterli olur. Karmaşık RPC mekanizmaları veya distributed transaction’lar genellikle gereksiz karmaşıklık yaratır ve eventual consistency prensibi çoğu iş akışı için kabul edilebilir bir trade-off sunar. Bir üretim ERP’sinde üretim planlama modülü, stok modülüne doğrudan API çağrısı yapmak yerine bir mesaj kuyruğuna “ürün üretildi” eventi yayınlayabilir; stok modülü de bu eventi dinleyerek kendi içindeki stok durumunu günceller. Bu sayede modüller daha az bağımlı hale gelir.
Son olarak, observability (metrik, log, trace) bu mimaride vazgeçilmezdir. Her modülün kendi loglarını journald’e göndermesi, metriklerini Prometheus’a açması ve OpenTelemetry ile trace’lerinin takip edilmesi, sistemin genel sağlığını anlamak ve sorunları hızla tespit etmek için kritik öneme sahiptir. Ben kendi yan ürünümün backend’inde, her modül için ayrı SLO’lar (Service Level Objectives) ve error budget’lar tanımladım. Bu, hangi modülün daha fazla dikkat gerektirdiğini gösteren somut veriler sağladı.
Sonuç
Monolith’ten modüler monolith’e geçiş, benim için sadece bir mimari değişiklik değil, aynı zamanda geliştirme süreçlerimizi daha çevik, bakımı daha kolay ve riskleri daha yönetilebilir hale getiren pragmatik bir evrimdi. Geliştirme hızını artırmak, kapsamlı regresyon riskini azaltmak ve teknik borcu daha etkili yönetmek, bu yola girmemdeki ana motivasyonlar oldu. Elbette zorlukları vardı, ancak doğru stratejilerle ve sürekli öğrenme ile bu zorlukların üstesinden gelmek mümkün.
Bu yaklaşım, tam teşekküllü bir microservice mimarisinin getirdiği operasyonel karmaşıklığı henüz kaldırabilecek durumda olmayan ekipler veya projeler için harika bir ara çözüm sunuyor. Bana göre, “doğru mimari”, projenin ve ekibin mevcut ihtiyaçlarına en iyi şekilde cevap verendir. Bir sonraki yazımda, event-sourcing ve CQRS pattern’lerini modüler monolith içinde nasıl kullandığımı detaylıca anlatacağım.