Mert Özen Her Satırda Daha İleri
Yazılım Mimarisi Modular Monolith — #01

Modular Monolith Nedir? Big Ball of Mud ile Microservices Arasındaki Yol

Mert Özen 7 Kas 2026 13 dk 2 görüntülenme
Modular Monolith Nedir? Big Ball of Mud ile Microservices Arasındaki Yol

Modular monolith, tek deployment unit içinde sınırları compiler ve test'le korunan module'lerden oluşan bir uygulamadır: modülerliğin faydasını network'ün bedelini ödemeden verir, bağımsız deploy ve scaling vermez. Shopify vakası, MonolithFirst ve Microservice Premium üzerinden nerede durduğunu, klasörleri module sanmanın ve bugünden HTTP çağrısı eklemenin (ölçümde bin katın üstünde fark) neden tuzak olduğunu Bookstore örneğiyle gösteriyoruz.

Sürüm notu: Bu yazıdaki kodlar C# 14 ve .NET 10 ile derlenip denenmiştir.

Önce Tahmin Et

Bookstore ekibi büyüyor. Bugün 3 developer'lık tek bir ekip var, 12–18 ay içinde dört stream-aligned ekip olacak: Catalog & Search, Ordering & Payment, Fulfillment (Inventory ve Shipping) ve Customer Experience. Toplantıda iki öneri çıkıyor. Birincisi: "Bookstore.Web'in içinde zaten Catalog, Ordering, Inventory, Shipping klasörlerimiz var, demek ki modüler bir yapımız var." İkincisi: "Nasılsa ileride microservice'e geçeceğiz; module'ler bugünden birbirini HTTP (Hypertext Transfer Protocol) ile çağırsın, sonra bölmek kolay olur."

İki soruyu kafanda cevapla. Bir: klasörlere bölünmüş bu kodda Shipping'in Catalog'a bağımlı olup olmadığını derleyici sana söyler mi, söylemezse kim söyler? İki: aynı makinede, aynı process'in içinde bir fiyat sorusunu sıradan bir method çağrısıyla sormak ile localhost'a HTTP isteği atarak sormak arasında kaç kat fark vardır? 10 kat mı, 100 kat mı, 1.000 kat mı?

Cevapları yazının ortasında gerçek ölçümlerle vereceğiz. Önce "modular monolith" ifadesindeki iki kelimenin ayrı ayrı neyi anlattığını netleştirelim.

İki Ayrı Eksen: Deployment Unit ve Logical Module

Monolith kelimesi kodun kalitesini değil, kaç parça hâlinde deploy edildiğini anlatır: sistemin tamamı birlikte deploy edilen tek bir birimse elinde bir monolith var. Bu ayrımı, Big Ball of Mud'ı ve monolith türlerini Mimariye Giriş serisinin "Monolith'i Doğru Anlamak: Single-Process, Modular ve Distributed Monolith" yazısında ayrıntılı konuştuk; burada oradan devam ediyoruz.

Modular kelimesi ise başka bir eksende duruyor: kodun içinde, birbirinden gerçekten ayrılmış parçalar var mı? Bir module, bir business capability'nin (iş yeteneğinin) tamamını taşıyan birim: kendi kuralları, kendi verisi, dışarıya açtığı küçük bir public contract ve dışarıdan görünmeyen geniş bir iç kısım. Modular monolith bu iki eksenin kesişimi: tek deployment unit, içinde sınırları korunan birden fazla module. Sam Newman, Monolith to Microservices (O'Reilly, 2019) kitabında bunu single-process monolith'in bir alt türü olarak anlatıyor: process tek, ama içindeki module'ler üzerinde ayrı ayrı çalışılabiliyor; deploy için yine birleştiriliyorlar.

İki eksenin bağımsız olduğunu görmek için uzağa gitmeye gerek yok. ASP.NET Core'un kendisi, senin uygulamanla birlikte tek parça hâlinde gelen bir shared framework. İçine bakan küçük bir program:

using System.Reflection;
using System.Runtime.CompilerServices;
using Microsoft.AspNetCore.Routing;

// ASP.NET Core shared framework'ünün klasörü: hep birlikte deploy edilen tek birim
string folder = Path.GetDirectoryName(typeof(RouteGroupBuilder).Assembly.Location)!;
Console.WriteLine($"Microsoft.AspNetCore.App {Path.GetFileName(folder)}: {Directory.GetFiles(folder, "*.dll").Length} assemblies");

Assembly routing = typeof(RouteGroupBuilder).Assembly;
Type[] types = routing.GetTypes().Where(t => !t.Name.Contains('<')).ToArray();   // compiler-generated hariç
Console.WriteLine($"Microsoft.AspNetCore.Routing: {types.Count(t => t.IsVisible)} public of {types.Length} types");
foreach (InternalsVisibleToAttribute friend in routing.GetCustomAttributes<InternalsVisibleToAttribute>())
    Console.WriteLine($"  InternalsVisibleTo {friend.AssemblyName.Split(',')[0]}");
Microsoft.AspNetCore.App 10.0.13: 141 assemblies
Microsoft.AspNetCore.Routing: 140 public of 297 types
  InternalsVisibleTo Microsoft.AspNetCore.OpenApi.Tests
  InternalsVisibleTo Microsoft.AspNetCore.Routing.Microbenchmarks
  InternalsVisibleTo Microsoft.AspNetCore.Routing.Tests
  InternalsVisibleTo Microsoft.AspNetCore.Mvc.ApiExplorer.Test
  InternalsVisibleTo DynamicProxyGenAssembly2
  InternalsVisibleTo Microsoft.AspNetCore.Mvc.Test

141 assembly hep birlikte, tek bir sürüm numarasıyla deploy ediliyor; tek tek güncelleyemezsin. Ama içeride sınırlar ciddi: routing assembly'sindeki 297 type'ın yalnız 140'ı public, geri kalanı internal. InternalsVisibleTo ile içini açtığı assembly'lerin hepsi test, benchmark ya da mock kütüphanesinin ürettiği proxy assembly'si (DynamicProxyGenAssembly2). Yani deployment açısından monolith, kod açısından modüler. Bu serinin Bookstore için kurmaya çalıştığı şey de tam olarak bu.

01-spectrum-tr.png

"Monolith = Kötü" Efsanesi ve Microservice Premium

Monolith'in kötü ününün kaynağı genelde deployment şekli değil, iç yapının çamura dönmesi. Çamur tek process'te de oluşur, yirmi service'e dağılmış bir sistemde de. Bu yüzden "monolith'ten kurtulalım" cümlesinin arkasında çoğu zaman "sınırları olmayan bir koddan kurtulalım" isteği vardır ve bu isteğin cevabı her zaman yeni bir deployment şekli değildir.

Martin Fowler bu konuda iki kısa yazı yazdı, ikisi de 2015 tarihli. Mayıs 2015'teki Microservice Premium yazısında microservice'lerin kendi başına karmaşıklık getirdiğini söylüyor: otomatik deploy, monitoring, hata yönetimi, eventual consistency. Bu bedele "premium" diyor ve tavsiyesi net: sistem bir monolith olarak yönetilemeyecek kadar karmaşık değilse microservice'leri düşünme bile; çoğu sistem iyi bir iç modülerliği olan tek bir monolith olarak kalmalı. Haziran 2015'teki MonolithFirst yazısında da gördüğü başarılı microservice hikâyelerinin neredeyse hepsinin büyüyüp bölünen bir monolith'le başladığını, baştan microservice olarak kurulanların çoğunun ciddi sorun yaşadığını anlatıyor. Gerekçelerinden biri sınırlar: doğru sınırı baştan bulmak zor, yanlış çizilmiş bir sınırı service'ler arasında taşımak ise aynı process içinde taşımaktan çok daha pahalı.

Bu iki yazıyı birlikte okuyunca modular monolith'in yeri ortaya çıkıyor. Sınırları erken düşünürsün, ama onları ucuza değiştirebileceğin bir yerde, yani tek deployment unit içinde tutarsın. Sınır yanlış çıkarsa bir refactoring ile düzeltirsin, bir network contract'ını versiyonlamak ve iki ekibi senkronize etmek zorunda kalmazsın.

Shopify: Tek Codebase, Zorlanan Sınırlar

Bu fikrin en çok alıntılanan gerçek örneği Shopify. Kirsten Westeinde'nin Şubat 2019'da Shopify Engineering blogunda yayımlanan "Deconstructing the Monolith" yazısı, on yılı aşkın süredir binden fazla developer'ın çalıştığı ve dünyanın en büyük Ruby on Rails codebase'lerinden biri olan uygulamayı anlatıyor. Ekip microservice'lere geçmeyi değerlendirmiş ve bilinçli olarak reddetmiş; deploy, altyapı ve network latency maliyetini gerekçe gösterip codebase'i modular monolith'e evriltmeyi seçmiş.

İlk adım kodu teknik klasörlerden (models, views, controllers) iş alanlarına göre component'lere taşımak olmuş: orders, shipping, inventory, billing gibi. Her component'in bir public API (Application Programming Interface) tanımlaması ve kendi verisinin tek sahibi olması hedeflenmiş. Sınırların ne kadar ihlal edildiğini görmek için Wedge adında bir iç araç yazmışlar; CI (Continuous Integration) sırasında component'ler arası çağrıları toplayıp her component için bir skor çıkarıyormuş.

Philip Müller'in Eylül 2020 tarihli "Under Deconstruction: The State of Shopify's Monolith" yazısı hikâyenin devamı. Monolith o tarihte 2,8 milyondan fazla satır Ruby kodu ve 500.000 commit büyüklüğünde, içinde 37 component var. Wedge'in runtime çağrılarını toplayan yaklaşımı gürültülü veri üretmiş ve bir saatten uzun sürmüş; yerine statik analizle dependency ve privacy ihlallerini pull request aşamasında yakalayan Packwerk gelmiş. Yazının en öğretici kısmı dürüstlüğü: başta dependency graph o kadar yoğunmuş ki her component diğerlerinin yarısından fazlasına bağımlıymış, döngüsel bağımlılıklar da çokmuş. Sert interface kurallarını erkenden dayatmak sorunları çözmeden sürtünme yaratmış; ekip daha az kural, daha çok rehberlik yönüne dönmüş. Bir de kodu veritabanı tablosuna göre değil, işlevine göre gruplamanın daha iyi çalıştığını not ediyorlar.

Bookstore için bu vakadan üç ders çıkıyor. Modülerlik bir proje değil süreç; sınırlar kod tabanı yaşadıkça korunmalı. Koruma otomatik olmalı, code review'daki iyi niyete bırakılmamalı. Ve sahiplik sadece kodda değil veride de geçerli: bir component'in tablosuna başka biri yazıyorsa sınır kâğıt üzerinde kalır.

Bookstore'un Module'leri

Bookstore kendi stoğunu satan bir online kitapçı. Bugün tek bir ASP.NET Core uygulaması ve tek bir SQL (Structured Query Language) Server veritabanı var, haftada bir deploy ediliyor. Bu seri boyunca kodu altı module'e ayıracağız; adları DDD (Domain-Driven Design) serisinde bounded context olarak çizdiğimiz haritayla aynı:

ModuleSahip olduğu capabilitySahip olduğu veri
CatalogKitap, yazar, arama, liste fiyatıBook, Author
OrderingSepet, checkout, sipariş durumu, kampanya fiyatıOrder, OrderItem, Customer
InventoryStok, rezervasyon, depo girişiStockItem, StockReservation
PaymentsÖdeme sağlayıcısıyla authorize, capture, refundPayment
ShippingGönderi, kargo etiketi, takip numarasıShipment
NotificationSipariş ve kargo e-postalarıEmailMessage

Mutlu yol bugün de aynı: müşteri Catalog'dan kitap seçer, Ordering siparişi alır, Inventory stok ayırır, Payments ödemeyi çeker, Shipping gönderiyi oluşturur, Notification e-postayı yollar. Değişen, bu adımların birbirini nasıl tanıdığı. Seri şu sırayla ilerliyor: sınırları bulmak (02), .NET'te solution yapısı (03), module'ler arası sync (04) ve async iletişim (05), module başına veri izolasyonu (06), sınırları test'le korumak (07) ve gerektiğinde bir module'ü service'e çıkarmak (08).

Neyi Alırsın, Neyi Almazsın

Modular monolith'i doğru seçmek için onu iki komşusuyla aynı satırlarda karşılaştırmak işe yarıyor:

SoruLayered monolithModular monolithMicroservices
Deployment unit11Service başına 1
Kod sınırıLayer'lar arası, iş alanları arasında yokModule'ler arası, compiler ve test'le korunurService'ler arası, network ile korunur
Module'ler arası çağrıDoğrudan class kullanımıIn-process contractNetwork çağrısı
Tek transactionHer yerde mümkünModule içinde doğal, module'ler arası bilinçli istisnaYok, saga ve eventual consistency
Bağımsız scaling ve deployYokYokVar
Operasyonel maliyetDüşükDüşükYüksek

Tablodaki kritik satır "bağımsız scaling ve deploy". Modular monolith bunu vermez ve vermiyormuş gibi yapmamalı. Bookstore'un mimari kararlarında independent deployability zaten bilinçli olarak "şimdilik değil" diye işaretlendi. Karşılığında alınan şey, modülerliğin faydalarının (anlaşılır kod, yerel değişiklik, ekip sahipliği) network'ün bedeli ödenmeden alınması ve bir gün bir module'ü dışarı çıkarmanın yolunun açık kalması.

Sık Düşülen Tuzaklar

Klasörleri module diye adlandırıp coupling'i aynen bırakmak

Toplantıdaki ilk önerinin kodu bu. Tek bir project, dört klasör, her şey public:

namespace Bookstore.Web.Catalog;

public class Book
{
    public Guid Id { get; set; }
    public string Title { get; set; } = "";
    public decimal ListPrice { get; set; }
    public int StockOnHand { get; set; }   // "nasılsa aynı tablo" diye stok buraya eklendi
}
using Bookstore.Web.Catalog;
using Bookstore.Web.Inventory;

namespace Bookstore.Web.Ordering;

public class Order
{
    public Guid Id { get; } = Guid.CreateVersion7();
    public List<Book> Books { get; } = [];   // Catalog'un entity'si siparişin içinde
    public decimal Total => Books.Sum(b => b.ListPrice);
}

public class OrderService(StockService stock)
{
    public Order Place(Book book, int quantity)
    {
        if (!stock.TryReserve(book, quantity))
            throw new InvalidOperationException("Out of stock");
        Order order = new();
        for (int i = 0; i < quantity; i++) order.Books.Add(book);
        return order;
    }
}
using Bookstore.Web.Ordering;

namespace Bookstore.Web.Shipping;

public class ShipmentService
{
    public string CreateLabel(Order order) =>
        $"{order.Id}: {order.Books.Count} book(s), first: {order.Books[0].Title}";
}

Kod derleniyor ve çalışıyor. Hangi klasörün hangisine bağımlı olduğunu görmek için reflection ile type imzalarını tarayan küçük bir araç yazdık:

using System.Reflection;
using Bookstore.Web.Catalog;

// "Bookstore.Web.<Module>.X" -> "<Module>"
static string? ModuleOf(Type t) =>
    t.Namespace is { } ns && ns.StartsWith("Bookstore.Web.") ? ns.Split('.')[2] : null;

// Bir type'ın imzasında (field, property, method, constructor) geçen tüm type'lar
static IEnumerable<Type> SignatureTypes(Type t)
{
    const BindingFlags all = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Static | BindingFlags.DeclaredOnly;
    IEnumerable<Type> raw =
        t.GetFields(all).Select(f => f.FieldType)
        .Concat(t.GetProperties(all).Select(p => p.PropertyType))
        .Concat(t.GetMethods(all).SelectMany(m => m.GetParameters().Select(p => p.ParameterType).Append(m.ReturnType)))
        .Concat(t.GetConstructors(all).SelectMany(c => c.GetParameters().Select(p => p.ParameterType)));
    return raw.SelectMany(x => x.IsGenericType ? x.GetGenericArguments().Append(x) : [x]);
}

Assembly app = typeof(Book).Assembly;
var edges = app.GetTypes()
    .Where(t => ModuleOf(t) is not null && !t.Name.Contains('<'))
    .SelectMany(t => SignatureTypes(t).Select(dep => (From: t, To: dep)))
    .Where(e => ModuleOf(e.To) is { } to && to != ModuleOf(e.From))
    .Select(e => $"{ModuleOf(e.From),-9} -> {ModuleOf(e.To),-9} {e.From.Name} uses {e.To.Name}")
    .Distinct()
    .Order();

foreach (string edge in edges) Console.WriteLine(edge);
Console.WriteLine($"Public types: {app.GetExportedTypes().Length} of {app.GetTypes().Count(t => !t.Name.Contains('<'))}");
Inventory -> Catalog   StockService uses Book
Ordering  -> Catalog   Order uses Book
Ordering  -> Catalog   OrderService uses Book
Ordering  -> Inventory OrderService uses StockService
Shipping  -> Ordering  ShipmentService uses Order
Public types: 5 of 5

Beş type'ın beşi de public ve Ordering, Catalog'un Book entity'sini doğrudan siparişin içine koymuş. Inventory de stok tutmak için Catalog'un entity'sini değiştiriyor; yani bir kitabın fiyat sayfasına dokunan biri, farkında olmadan stok mantığına da dokunuyor. Daha sinsi olanı, listede görünmeyen bağımlılık: ShipmentService, order.Books[0].Title ile Catalog'un Book'una method gövdesinin içinden uzanıyor ve imzaya bakan aracımız bunu kaçırıyor. Gövdedeki bağımlılıkları görmek için IL (Intermediate Language) seviyesinde okuyan bir araç lazım; 07. yazıda bunu ArchUnitNET ile yapacağız. Sorunun cevabı da burada: derleyici bu koda hiçbir şey demiyor, çünkü aynı assembly'de, aynı access modifier'larla yazılmış klasörler derleyici için tek bir module.

"İleride böleriz" diye bugünden network çağrısı eklemek

İkinci öneri, module'lerin bugünden birbirini HTTP ile çağırması. Aynı fiyat sorusunu, aynı process'in içinde iki yoldan sorduk: bir interface üzerinden method çağrısıyla ve aynı makinedeki Kestrel'e HTTP isteğiyle:

using System.Diagnostics;
using System.Globalization;
using System.Net.Http.Json;

CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;

WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
builder.Logging.ClearProviders();
builder.WebHost.UseUrls("http://127.0.0.1:5087");
builder.Services.AddSingleton<ICatalogApi, CatalogApi>();
WebApplication app = builder.Build();
app.MapGet("/books/{isbn}/price", (string isbn, ICatalogApi catalog) => catalog.GetPrice(isbn));
await app.StartAsync();

ICatalogApi inProcess = app.Services.GetRequiredService<ICatalogApi>();
using HttpClient http = new() { BaseAddress = new Uri("http://127.0.0.1:5087") };
const int calls = 5_000;

// Aynı soru iki yoldan: aynı process'te method çağrısı ve aynı makinede HTTP
double localMean = await MeanMicrosecondsAsync(() => Task.FromResult(inProcess.GetPrice("978-0441013593")));
double remoteMean = await MeanMicrosecondsAsync(() => http.GetFromJsonAsync<decimal>("/books/978-0441013593/price"));

Console.WriteLine($"In-process call : {localMean * 1000,9:F0} ns per call");
Console.WriteLine($"HTTP (loopback) : {remoteMean * 1000,9:F0} ns per call");
Console.WriteLine($"Ratio           : {remoteMean / localMean,9:N0}x");
await app.StopAsync();

static async Task<double> MeanMicrosecondsAsync(Func<Task<decimal>> call)
{
    for (int i = 0; i < 500; i++) await call();   // ısınma: JIT ve connection pool
    long start = Stopwatch.GetTimestamp();
    for (int i = 0; i < calls; i++) await call();
    return Stopwatch.GetElapsedTime(start).TotalMicroseconds / calls;
}

public interface ICatalogApi
{
    decimal GetPrice(string isbn);
}

internal sealed class CatalogApi : ICatalogApi
{
    private readonly Dictionary<string, decimal> _prices = new() { ["978-0441013593"] = 12.50m };
    public decimal GetPrice(string isbn) => _prices[isbn];
}
In-process call :        43 ns per call
HTTP (loopback) :     52746 ns per call
Ratio           :     1,228x

Ölçüm birkaç kez tekrarlandığında HTTP tarafı 47–53 mikrosaniye arasında oynuyor, oran hep bin katın üstünde. Bu, network'ün en iyi hâli: aynı makine, loopback, hata yok, serialization küçük. Gerçek bir network'te araya kablo, load balancer, TLS (Transport Layer Security) ve timeout'lar girer; hata ihtimali de sıfırdan büyük olur. Üstelik bu bedeli, karşılığında bağımsız deploy almadan ödüyorsun, çünkü iki taraf hâlâ aynı process'te. Modular monolith'te module'ün dışa açtığı yüz bir interface olarak kalır; bir gün gerçekten service'e çıkarmak gerekirse, aynı interface'in arkasına bir HTTP client koymak 08. yazının konusu.

Module sayısını erkenden şişirmek

Bookstore'da altı module var ve bu sayıya iş alanlarından vardık. "Her entity bir module" ya da "her ekran bir module" yaklaşımı, Shopify'ın yoğun dependency graph'ının küçük bir kopyasını üretir: module'ler sürekli birbirini çağırır ve sınır her feature'da yeniden pazarlık konusu olur. Doğru sayının nasıl bulunacağı bir sonraki yazının konusu.

Veritabanını unutmak

Kodda sınırları ne kadar iyi çizersen çiz, Ordering'in sorgusu Catalog'un tablosuna join atabiliyorsa sınır delinmiştir. Module başına schema ve "bu tabloya kim yazar" sorusu 06. yazıda.

Ne Zaman Kullanma

Çok küçük bir uygulama, tek developer ve tek bir iş alanı varsa module sınırlarının getirdiği tören fazla gelir; düzgün katmanlanmış sade bir uygulama yeter. Aynı şekilde domain'i henüz hiç tanımıyorsan, ürünün ne olacağı her hafta değişiyorsa, sınırları sertleştirmek için erken olabilir: önce kodu iş alanına göre klasörle, birkaç ay hangi değişikliklerin birlikte geldiğine bak, sınırları ondan sonra zorla.

Öbür uçta, farklı ekiplerin gerçekten farklı release ritimlerine, bir parçanın tamamen farklı bir ölçekleme profiline ya da compliance gereği ayrı bir çalışma ortamına ihtiyacı varsa, modular monolith o parça için bir ara durak olabilir ama son durak olmayabilir. Bu durumda bile çoğu zaman doğru hamle her şeyi bölmek değil, tek bir module'ü dışarı çıkarmak.

Küçük Bir Deneme

★☆☆ Kendi çalıştığın bir uygulamada iş alanlarını yazı tahtasına listele. Her birinin sahip olduğu tabloları yanına yaz. Kaç tablonun birden fazla iş alanına "ait" göründüğünü say; bu sayı bir sonraki yazının başlangıç noktası.

★★☆ Yazıdaki ModuleScan aracını kendi solution'ında, tek bir assembly üzerinde çalıştır ve namespace'leri module kabul et. Hangi module çiftleri arasında en çok bağımlılık var? Bu bağımlılıkların kaçı bir entity'nin başka bir module'e taşınmasından kaynaklanıyor?

★★★ LatencyProbe'u değiştir: HTTP çağrısının yarısında, sunucu tarafında 1 ms gecikme ve yüzde 1 ihtimalle 500 hatası üret. Çağıran tarafa bir retry ekle. Ortalama süre ve kodun karmaşıklığı nasıl değişiyor? Bu karmaşıklığı bugün, bağımsız deploy almadan taşımaya değer mi?

Bu yazıda modular monolith'in neyi vaat ettiğini ve neyi vaat etmediğini konuştuk. Bir sonraki yazıda en zor soruya geçiyoruz: module sınırları nereden geçmeli? Business capability, bounded context ve veri sahipliği üzerinden pratik heuristic'ler kuracak, "siparişin içindeki kitap" sorusunun neden iki ayrı model gerektirdiğini kodla göreceğiz.