Domain boundary nasıl belirlenir?

Modülerleştirmeden önce verilmesi gereken karar, bir domain'in nerede bitip diğerinin nerede başladığıdır. Ekranlara ya da endpoint'lere göre kesmek yerine dile, birlikte değişen koda ve verinin sahibine bakarak sınır çizmeyi, hayali bir seyahat uygulaması üzerinden anlatıyoruz.

Modülerleştirme projelerinin çoğu, ilk modül açılmadan önce verilen bir kararla kazanılır ya da kaybedilir: sınır nereden geçecek. Build sistemi, dependency injection, navigation gibi konular sonradan düzeltilebilir. Yanlış yerden çizilmiş bir sınır ise her yeni özellikte kendini hatırlatır: tek satırlık bir değişiklik üç modüle dokunur, iki ekip aynı dosyada çakışır, "ortak" adını verdiğiniz modül bir yıl içinde uygulamanın yarısını içine çeker.

Bu yazıda hayali bir seyahat uygulaması kullanacağız, adı Rota olsun. Uçuş ve otel aranıyor, rezervasyon yapılıyor, ödeme alınıyor, satın alınan seyahatler listeleniyor. Tek target, altı yıllık kod, dört ekip. Yönetim modülerleştirme istiyor ve ilk toplantıda tahtaya çizilen şekil büyük ihtimalle tab bar'ın kopyası.

Ekipler genellikle yanlış yerden keser

Büyük projelerde sık gördüğümüz beş kesme hattı var ve hepsi ilk bakışta makul görünüyor.

İlki ekranlar. Her ekrana bir modül açmak kolaydır çünkü ekran, kodda en görünür birimdir. Ama ekran tasarımın birimidir. Rota'da "rezervasyon özeti" ekranı hem ödeme öncesinde hem satın alma sonrasında gösteriliyor ve iki durumda farklı kurallar çalışıyor. Ekrana göre kestiğinizde bu kuralların ikisi de aynı modüle düşer.

İkincisi tab bar. Tab'lar ürün ekibinin bu çeyrekteki navigasyon tercihidir. "Fırsatlar" tab'ı kalktığında DealsModule ortada kalır, içindeki fiyat alarmı kodu da onunla birlikte.

Üçüncüsü backend endpoint'leri. /flights, /hotels, /orders diye üç modül açmak, backend'in servis sınırlarını istemciye taşır. Backend bu sınırları kendi ölçekleme ve dağıtım ihtiyaçlarına göre çizmiştir. Mobil tarafta kullanıcı tek bir rezervasyon akışı yaşıyor ve o akış üç servisin verisini birleştiriyor.

Dördüncüsü geçen yılın org şeması. Conway yasası gerçek: kod, onu yazan ekiplerin iletişim yapısına benzer. Sorun, modül isimlerinin ekip isimlerinden uzun yaşamasıdır. GrowthKit adlı bir modül, Growth ekibi dağıtıldıktan yıllar sonra da durur ve kimse içinde ne olduğunu tam bilmez.

Beşincisi "büyük olan neyse o". En çok satırı olan klasörü ayırmak ilerleme hissi verir ama büyüklük tek başına bir şey söylemez. Büyük klasör çoğu zaman birbiriyle ilgisiz üç şeyin aynı yere yığılmış halidir, onu olduğu gibi modüle çevirmek yığını kalıcılaştırır.

Bu beş sinyalin ortak kusuru, kodun nasıl değiştiğini ve neyin kime ait olduğunu hesaba katmamalarıdır.

Tutunan sinyaller

Aynı kelime, farklı anlam

En güvenilir sinyal dildir. Rota'da "fiyat" kelimesi üç yerde geçiyor. Aramada fiyat, bir gösterge değeridir: listeyi sıralamak için kullanılır, bir dakika sonra değişebilir. Rezervasyonda fiyat, son kullanma zamanı olan kilitlenmiş bir tekliftir. Ödemede fiyat, para birimi ve küçük birim cinsinden tahsil edilecek tutardır, taksit ve komisyon bununla hesaplanır. Üç ekip toplantıda aynı kelimeyi kullanır ve anlaşır, kodda ise tek bir price alanı üç anlamı birden taşımaya çalışır:

// Models/Flight.swift: uygulamanın her yerinde kullanılan "ortak" model
struct Flight: Codable {
    let id: String
    var price: Decimal             // aramada gösterge, sepette kilitli teklif, ödemede tahsil edilen tutar
    var currency: String
    var seatsLeft: Int?            // yalnızca arama sonucunda dolu
    var fareLockExpiresAt: Date?   // yalnızca rezervasyon taslağında dolu
    var pnr: String?               // yalnızca biletlendikten sonra dolu
    var paymentStatus: String?     // "3ds_pending", "captured", ...
    var isCheckedIn: Bool?         // yalnızca "Seyahatlerim" ekranında anlamlı
}

Optional alanların her biri "bu alan şu aşamada dolu" bilgisini yorum satırına havale ediyor. Bu modelde hangi optional'ların birlikte dolu olduğuna bakın: ortaya çıkan kümeler domain adaylarınızdır. Bir kelimenin anlamı değiştiği yerde bir sınır vardır, siz çizseniz de çizmeseniz de. Ortak modelin neden bu hale geldiğini ve nasıl ayrıldığını ortak model ve domain modeli yazısında ayrıca ele alıyoruz.

Birlikte değişen birlikte durur

İkinci sinyal ölçülebilir ve repo'nuzda hazır bekliyor: hangi dizinler aynı commit'lerde değişiyor. Aşağıdaki script git log çıktısını okur ve birlikte değişen dizin çiftlerini sayar. macOS'un kendi awk'ı ile çalışır, ek araç gerekmez.

#!/usr/bin/env bash
# cochange.sh: aynı commit'te birlikte değişen dizin çiftlerini listeler
# kullanım: ./cochange.sh [kök dizin] [dizin derinliği] [süre]
ROOT="${1:-.}"; DEPTH="${2:-3}"; SINCE="${3:-12 months ago}"

git log --since="$SINCE" --no-merges --name-only --pretty=format:'@%h' -- "$ROOT" |
awk -v depth="$DEPTH" -v maxdirs=8 -v minpair=5 '
function flush(   a, b, n) {
  n = 0
  for (a in seen) n++
  if (n <= maxdirs)                 # toplu refactor ve format commit’lerini atla
    for (a in seen) {
      total[a]++
      for (b in seen) if (a < b) pair[a SUBSEP b]++
    }
  split("", seen)
}
/^@/   { flush(); next }            # yeni commit başlıyor
/^$/   { next }
{
  n = split($0, p, "/")
  m = (n - 1 < depth) ? n - 1 : depth
  key = (m == 0) ? "." : p[1]
  for (i = 2; i <= m; i++) key = key "/" p[i]
  seen[key] = 1                     # dosya yolunu ilk "depth" dizine indir
}
END {
  flush()
  for (k in pair) {
    if (pair[k] < minpair) continue
    split(k, d, SUBSEP)
    lo = (total[d[1]] < total[d[2]]) ? total[d[1]] : total[d[2]]
    printf "%4d  %3d%%  %s (%d)  <->  %s (%d)\n",
      pair[k], 100 * pair[k] / lo, d[1], total[d[1]], d[2], total[d[2]]
  }
}' | sort -rn | head -25

./cochange.sh App/Sources 4 gibi çalıştırırsınız. Rota için çıktının şuna benzediğini varsayalım (sayılar örnek, gerçek bir projeden alınmadı):

  41   87%  App/Sources/Booking/Checkout (112)  <->  App/Sources/Booking/Passengers (47)
  38   31%  App/Sources/Models (187)  <->  App/Sources/Search/Results (121)
  35   30%  App/Sources/Booking/Checkout (112)  <->  App/Sources/Models (187)
  29   64%  App/Sources/Booking/Checkout (112)  <->  App/Sources/Payment/Card (45)
   6    7%  App/Sources/Search/Filters (83)  <->  App/Sources/Search/Results (121)

İlk sütun iki dizinin birlikte değiştiği commit sayısı. Parantez içindekiler her dizinin toplam commit sayısı. Yüzde, ortak commit'lerin daha az değişen dizinin toplamına oranı: "Passengers'a dokunan commit'lerin %87'si Checkout'a da dokunmuş" diye okunur.

Okurken üç şeye bakıyoruz. Aynı aday domain'in içindeki yüksek oran iyi haberdir, o iki dizin zaten birlikte yaşıyor (ilk satır). Planladığınız sınırın iki yakası arasındaki yüksek oran kötü haberdir: ya sınır yanlış yerde ya da aradaki kontrat içeriyi sızdırıyor (dördüncü satır, Checkout ve Payment/Card). Herkesle orta düzeyde bağlı görünen bir dizin ise (Models) parçalanmayı bekleyen bir ortak torbadır. Son satır da ilginç: Filters ve Results aynı klasörde ama neredeyse hiç birlikte değişmiyor, ileride ayrılmaya aday.

Çıktının sınırlarını bilin. Ekip her PR'ı squash ederek birleştiriyorsa commit bir PR'a denk gelir ve sinyal temizdir. Küçük küçük commit atılıyorsa oranlar olduğundan düşük çıkar. Ayrıca bu ölçüm yalnızca tek repo içindeki bağı görür. Aynı analizin daha gelişmiş hali için Adam Tornhill'in code-maat aracına bakabilirsiniz, biz ilk tur için yukarıdaki yirmi beş satırı yeterli buluyoruz.

Verinin sahibi ve ömrü

Üçüncü sinyal için her veri parçasına iki soru soruyoruz: bunu kim yaratıyor ve ne zaman ölüyor. Arama sonuçları dakikalar yaşar, cache'ten silinmesi kimseyi üzmez. Rezervasyon taslağı kullanıcı ödeme yapana ya da fiyat kilidi dolana kadar yaşar. Satın alınmış bilet aylarca yaşar ve uçakta, internetsiz açılabilmelidir. Kart bilgisi ise cihazda hiç yaşamamalıdır. Ömrü ve saklama kuralı bu kadar farklı olan dört veriyi aynı repository sınıfına ya da aynı veritabanı tablosuna koyduğunuzda, birinin kuralı ötekine bulaşır.

Bir verinin tek bir yazarı olmalı. İki domain aynı kaydı güncelliyorsa ya o ikisi aslında tek domain'dir ya da verinin sahibi yanlış belirlenmiştir.

Değişim hızı, release ve uyum kısıtları

Arama ekranı her sprint değişir: yeni filtre, yeni sıralama, A/B testleri. Ödeme kodu yılda birkaç kez değişir ve her değişiklik güvenlik gözden geçirmesinden geçer. Bu ikisi aynı modüldeyse aramadaki her deney ödeme kodunu yeniden derletir, ödeme kodundaki her inceleme de arama ekibini bekletir. Kart verisine dokunan kod, PCI DSS gibi uyum gereklilikleri yüzünden ayrı denetlenir ve denetim kapsamını küçük tutmak istersiniz. Üçüncü taraf bir ödeme SDK'sının tek bir modülün arkasında kalması da aynı gerekçeyle değerlidir. Farklı hızda değişen ya da farklı kurala tabi olan kod, dil ayrımı zayıf olsa bile ayrı durmayı hak eder.

Mobilde bounded context: çeviri yapmaya razı olduğunuz yer

Bounded context kavramı, büyük bir sistemi tek bir tutarlı modelle anlatmanın mümkün olmadığını kabul eder ve her modelin geçerli olduğu alanı sınırlar. Fowler bunu, aynı kelimenin kurumun farklı bölümlerinde farklı anlam taşımasıyla açıklar. Mobil uygulamada bunun karşılığı gayet somut:

Sınır, bir modeli başka bir modele çevirmeyi ve açık bir kontratın bakım maliyetini ödemeyi kabul ettiğiniz yerdir.

Bu tanımın işe yarayan tarafı maliyeti açıkça söylemesidir. Arama'nın SearchOffer tipi ile Rezervasyon'un FareSelection tipi arasına bir çeviri fonksiyonu yazacaksınız. Bu fonksiyon çoğu zaman sıkıcıdır, üç alanı kopyalar. O sıkıcı fonksiyon, iki tarafın birbirinden bağımsız değişebilmesinin bedelidir. Bir sınır adayında bu çeviriyi yazmak size anlamsız geliyorsa, yani iki taraftaki model her zaman birebir aynı olacaksa, orada sınır yoktur ve klasör yeterlidir.

Mobilin backend'den bir farkı var: sınırlar aynı process içinde, aynı binary'de yaşar. Ağ çağrısı, ayrı deployment ya da versiyonlanmış API yok. Bu, sınırı ucuzlatır ama görünmez de kılar. Derleyici dışında sizi durduran bir şey olmadığı için kontratı access control ve modül bağımlılıklarıyla sizin görünür kılmanız gerekir.

Fazla büyük ve fazla küçük sınırın belirtileri

Fazla büyük bir sınırın belirtisi insanlarda görülür. Birbiriyle ilgisi olmayan iki iş aynı dosyalarda merge conflict çıkarır. PR'ı gözden geçirmek için iki ayrı ekipten onay gerekir. Modülün ne yaptığını tek cümleyle anlatamazsınız, cümlenin ortasına "ve" girer. Co-change çıktısında modülün alt klasörleri birbirleriyle hiç birlikte değişmiyordur.

Fazla küçük bir sınırın belirtisi commit'lerde görülür. Sıradan bir özellik aynı PR'da üç modüle dokunur ve üçü her zaman birlikte release edilir. Aradaki arayüz konuşkandır: on beş metotlu bir protocol, öteki modülün iç adımlarını tek tek dışarı açar. Bir veri tipi üç modülden hiç değişmeden geçer ve her birinde aynı alanlarla yeniden tanımlanır. Bu tabloda modülleri birleştirmek, yanlış kesimi düzeltmenin en kısa yoludur.

İkisi arasında kalırsak büyük tarafta hata yapmayı tercih ediyoruz. Büyük bir modülü sonradan ikiye bölmek, içerideki bağlar görünür olduğu için yönetilebilir bir iştir. Yanlış bölünmüş beş küçük modülü birleştirmek ise önce beş kontratı sökmeyi gerektirir.

Domain'ler arası kontrat: kim kimi tanır

Rota için hedeflediğimiz bağımlılık yönleri şöyle:

App (composition root)  ──>  bütün domain’ler
Search    ──>  kimseyi tanımaz
Payment   ──>  kimseyi tanımaz
Account   ──>  kimseyi tanımaz
Booking   ──>  Payment kontratı, Account kontratı
Trips     ──>  Booking kontratı (yalnızca event’ler), Account kontratı

Kural olarak akışın sahibi olan taraf, hizmet veren tarafı tanır. Payment bir tutar ve bir referans alır, karşılığında sonuç döner, uçuş ya da otel kelimesini bilmez. Search'ün Booking'i tanımasına da gerek yok: kullanıcı bir teklif seçtiğinde Search bunu dışarı bildirir, çeviriyi composition root yapar.

Doğrudan çağrı ile event arasındaki seçim için tek bir ölçüt kullanıyoruz. Çağıran taraf devam etmek için cevaba ihtiyaç duyuyorsa doğrudan çağrıdır: Booking, ödeme sonucunu bilmeden bilet kesemez. Yayınlayan taraf kimin dinlediğiyle ilgilenmiyorsa event'tir: rezervasyon onaylandığında Trips listesini yeniler, bildirim izni istenir, analitik kayıt düşer, Booking bunların hiçbirini bilmez. Her şeyi event'e çevirmek cazip gelir çünkü bağımlılık oku kaybolur. Ok kaybolur, bağımlılık kalır, üstelik artık derleyici de göremez. Cevap bekleyen bir akışı event çiftiyle kurmayın.

Sınırdan geçen tipler küçük ve anlamca fakir olmalı. Android'in modülerleştirme rehberi de modüller arasında nesne yerine kimlik taşımayı önerir. Rota'da Search, Booking'e koca bir Flight vermez, backend'in ürettiği opak bir teklif token'ı verir.

Swift tarafında kontrat

// BookingAPI.swift: Booking’in dışarıya gösterdiği her şey bu dosyada
import UIKit

public struct FareSelection: Sendable, Hashable {
    public let offerToken: String      // Search’ün taşıdığı, Booking’in backend’e geri verdiği opak değer
    public let passengerCount: Int

    public init(offerToken: String, passengerCount: Int) {
        self.offerToken = offerToken
        self.passengerCount = passengerCount
    }
}

public struct BookingReference: Sendable, Hashable {
    public let value: String
    public init(_ value: String) { self.value = value }
}

public enum BookingEvent: Sendable {
    case confirmed(BookingReference)
    case cancelled(BookingReference)
}

@MainActor
public protocol BookingEntry {
    func makeBookingFlow(for selection: FareSelection) -> UIViewController
}

public protocol BookingEventSource: Sendable {
    /// Her çağrı yeni bir stream döner; dinleyen sayısı Booking’i ilgilendirmez.
    func events() -> AsyncStream<BookingEvent>
}

Ödeme tarafı doğrudan çağrıdır ve Booking'den hiçbir şey bilmez:

// PaymentAPI.swift
import UIKit

public struct PaymentRequest: Sendable {
    public let amountMinorUnits: Int   // 149990 = 1.499,90
    public let currencyCode: String    // ISO 4217
    public let merchantReference: String

    public init(amountMinorUnits: Int, currencyCode: String, merchantReference: String) {
        self.amountMinorUnits = amountMinorUnits
        self.currencyCode = currencyCode
        self.merchantReference = merchantReference
    }
}

public enum PaymentOutcome: Sendable {
    case authorized(receiptID: String)
    case declined
    case abandoned                      // kullanıcı akıştan çıktı
}

@MainActor
public protocol PaymentAuthorizing {
    func authorize(_ request: PaymentRequest,
                   presentingFrom presenter: UIViewController) async -> PaymentOutcome
}

Search ile Booking arasındaki çeviri composition root'ta, iki tarafı da tanıyan tek yerde durur:

import Booking
import Search
import UIKit

@MainActor
final class SearchToBookingBridge {
    private let booking: any BookingEntry
    private let navigation: UINavigationController

    init(booking: any BookingEntry, navigation: UINavigationController) {
        self.booking = booking
        self.navigation = navigation
    }

    // Search bu metodu bir closure olarak alır; Booking’in varlığından haberi olmaz.
    func userDidSelect(_ offer: SearchOffer, passengerCount: Int) {
        let selection = FareSelection(offerToken: offer.token, passengerCount: passengerCount)
        navigation.pushViewController(booking.makeBookingFlow(for: selection), animated: true)
    }
}

Swift tarafında iki araç işinizi kolaylaştırır. package access level (SE-0386, Swift 5.9) bir tipi aynı package içindeki target'lara açar ama package dışına kapatır, geçiş döneminde henüz kontrata girmemiş paylaşımlar için uygundur. internal import (SE-0409, Swift 6.0) ise bir bağımlılığın sizin public API'nize sızmasını derleme hatasına çevirir: Booking, ödeme SDK'sını internal import ile alırsa o SDK'nın hiçbir tipi Booking'in public imzalarında görünemez. Swift 6 language mode'da import'lar hâlâ varsayılan olarak public'tir, varsayılanı InternalImportsByDefault upcoming feature flag'i değiştirir. Kendi toolchain sürümünüzde davranışı doğrulayın.

Kotlin tarafında kontrat

Android'de kontratı saf Kotlin bir modüle koyuyoruz. Android bağımlılığı olmaması hem derlemeyi hızlandırır hem de kontrata Context ya da Fragment sızmasını yapısal olarak engeller.

// :booking:api modülü, com/rota/booking/api/BookingApi.kt
package com.rota.booking.api

import kotlinx.coroutines.flow.Flow

public data class FareSelection(val offerToken: String, val passengerCount: Int)

@JvmInline
public value class BookingReference(public val value: String)

public sealed interface BookingEvent {
    public data class Confirmed(val reference: BookingReference) : BookingEvent
    public data class Cancelled(val reference: BookingReference) : BookingEvent
}

public interface BookingEvents {
    /** Hot stream: dinleyen yoksa event kaybolur, Booking kimin dinlediğini bilmez. */
    public val events: Flow<BookingEvent>
}
// :payment:api modülü, com/rota/payment/api/PaymentApi.kt
package com.rota.payment.api

public data class PaymentRequest(
    val amountMinorUnits: Long,
    val currencyCode: String,
    val merchantReference: String,
)

public sealed interface PaymentOutcome {
    public data class Authorized(val receiptId: String) : PaymentOutcome
    public data object Declined : PaymentOutcome
    public data object Abandoned : PaymentOutcome
}

public interface PaymentAuthorizer {
    public suspend fun authorize(request: PaymentRequest): PaymentOutcome
}

Her yerdeki public kelimesi süs olsun diye yazılmadı. Kotlin'de varsayılan görünürlük public'tir, yani bir tipi kontrata eklemek için hiçbir şey yazmanız gerekmez ve kontrat kendiliğinden büyür. Explicit API mode bunu tersine çevirir: görünürlüğü yazılmamış her bildirim derleme hatası olur.

// booking/api/build.gradle.kts
plugins {
    kotlin("jvm")
}

kotlin {
    explicitApi()
}

dependencies {
    implementation(libs.kotlinx.coroutines.core)
}
// booking/impl/build.gradle.kts (ilgili kısım)
dependencies {
    api(project(":booking:api"))
    implementation(project(":payment:api"))
    implementation(project(":account:api"))
    // :search ya da :payment:impl burada görünmez; onları yalnızca :app tanır
}

Kotlin'de internal modül kapsamındadır, package kapsamında bir görünürlük yoktur. Bu yüzden tek Gradle modülü içinde klasörlerle çalışırken sınırı derleyici korumaz, aşağıda buna döneceğiz.

Önerilen sınıra dört test

Tahtadaki çizimi koda dökmeden önce her aday sınıra dört soru soruyoruz.

Silinebilir mi. Modülü composition root'tan çıkardığınızı düşünün. Derleme yalnızca composition root'ta ve bağımlılık tablosunda adı geçen tüketicilerde kırılmalı. Rota'da Trips'i sildiğinizde Booking derlenmeye devam etmeli. Etmiyorsa ok ters yöne de çizilmiş demektir.

Tek başına derlenip test edilebiliyor mu. Booking'in testleri, Payment'ın gerçek implementasyonu olmadan, PaymentAuthorizing için yirmi satırlık bir sahte nesneyle çalışmalı. iOS'ta bunu modülün kendi scheme'i ile, Android'de ./gradlew :booking:impl:test ile deneyin. Test kurulumunda dört başka domain'i ayağa kaldırmanız gerekiyorsa sınır kağıt üzerindedir.

Kaç tip geçiyor. Kontratı ayrı bir dizine koyduysanız saymak tek satır:

grep -rhoE "^public (struct|enum|protocol|final class|class|actor|typealias) [A-Za-z_]+" \
  Sources/BookingAPI | wc -l

Mutlak bir eşik vermek doğru olmaz, biz sayı on ikiyi geçtiğinde durup bakıyoruz. Asıl izlenmesi gereken eğilimdir: sayı her sprint artıyorsa içerideki model dışarı sızıyor.

Public API tek ekrana sığıyor mu. Yukarıdaki BookingAPI.swift kırk satır. Bir modülün dışarıya gösterdiği her şeyi kaydırmadan okuyamıyorsanız, onu kullanan kişi de kafasında tutamaz ve içeriye bakmaya başlar.

Sınırlar yer değiştirir: önce klasör, sonra modül

İlk çizdiğiniz sınırların bir kısmı yanlış çıkacak. Bu normal, yeter ki yanlışı düzeltmek ucuz olsun. O yüzden sertleştirmeyi kademeli yapıyoruz.

İlk kademe klasörlerdir. Tek target içinde kod domain adaylarına göre taşınır ve her klasörün kökünde tek bir kontrat dosyası bulunur. Bu aşamada bir dosyayı bir domain'den ötekine almak sürükle bırak işidir.

App/Sources/
  Search/     SearchAPI.swift    Internal/...
  Booking/    BookingAPI.swift   Internal/...
  Payment/    PaymentAPI.swift   Internal/...
  Trips/      TripsAPI.swift     Internal/...
  Account/    AccountAPI.swift   Internal/...

Derleyici bu kademede yardım etmez: Swift'te target içinde private ve fileprivate dışında bir bariyer yok, Kotlin'de internal bütün modüle açık. Kuralı CI'da basit bir kontrolle tutabilirsiniz. Kotlin'de import'lar package adını taşıdığı için bir grep bile iş görür:

# booking dışındaki bir dosya booking.internal paketini import ediyorsa build kırılsın
if grep -rnE "^import com\.rota\.booking\.internal\." app/src/main/java --include="*.kt" \
   | grep -v "/com/rota/booking/"; then
  echo "Booking’in iç paketine dışarıdan erişim var"; exit 1
fi

Bunun bakımı yapılabilir hali için Android'de mimari kuralları zorlamak yazısına bakın. Swift'te tek target içinde import olmadığı için aynı kontrol zayıf kalır. iOS'ta ilk gerçek bariyer ikinci kademedir.

İkinci kademe, tek bir local Swift package içinde domain başına bir target ya da Android'de domain başına bir Gradle modülüdür. Artık internal anlam kazanır ve bağımlılık yönleri manifest'te yazılıdır. package access level bu kademede sınırı henüz netleşmemiş paylaşımları idare etmenizi sağlar.

Üçüncü kademe ayrı kontrat modülleri (BookingAPI, :booking:api), ayrı test hedefleri, gerekiyorsa ayrı repo ve sahiplik kurallarıdır. Bu kademenin bedeli vardır, ayrıntısını modülerleştirmenin maliyeti yazısında tartışıyoruz.

Bir sınırı ne zaman sertleştiriyoruz: co-change çıktısı birkaç ay boyunca sınırın iki yakasını ayrı gösterdiğinde, o koda ikinci bir ekip girdiğinde, ya da uyum gereği denetim kapsamını daraltmak gerektiğinde. Bunlardan hiçbiri yoksa klasör kademesinde kalmak bilinçli bir karardır.

Rota: karışık uygulamadan beş domain'e

Başlangıç noktası tanıdık: Screens/, Models/, Services/, Helpers/ klasörleri, her şeyin kullandığı bir Flight modeli, 1.400 satırlık bir BookingManager. Yukarıdaki sinyalleri sırayla uyguladığımızda beş domain çıkıyor.

Search ilk ve en kolay kesim. Dili ayrı (teklif, filtre, sıralama), verisi dakikalar yaşıyor, en hızlı değişen kod burada ve hiçbir uyum kısıtı yok. Kimseyi tanımıyor, dışarıya yalnızca "kullanıcı şu teklifi seçti" bilgisini veriyor.

Payment ikinci kesim ve gerekçesini dilden çok kısıtlar veriyor. Kart verisi, 3-D Secure akışı ve ödeme sağlayıcısının SDK'sı tek bir sınırın arkasında kalmalı. Yavaş değişiyor, her değişikliği ayrı gözden geçiriliyor. Co-change çıktısında Checkout ile Payment/Card arasındaki %64, bugün sınırın sızdırdığını gösteriyor: BookingManager kart formunun alanlarını doğrudan okuyor. Kesimden önce o bağı PaymentRequest ve PaymentOutcome ile daraltıyoruz.

Booking, teklif seçiminden onaya kadar olan taslağın sahibi. Fiyat kilidi, yolcu bilgileri, ek hizmetler, ödeme adımının orkestrasyonu burada. Payment'ı ve Account'u kontratları üzerinden tanıyor.

Trips satın alma sonrasını alıyor: biletler, check-in, uçuş durumu, offline erişim. Booking'den ayırmamızın nedeni verinin ömrü. Taslak dakikalarla, bilet aylarla ölçülüyor. Biri ağ olmadan çalışmak zorunda, ötekinin ağsız çalışması anlamsız. Aralarındaki tek bağ BookingEvent.confirmed.

Account kimlik, oturum ve kayıtlı yolcuların sahibi. Hem Booking hem Trips ona ihtiyaç duyuyor, o ikisinden birinin içinde kalsaydı öteki yanlış yöne bağımlı olurdu.

Bilerek yapmadığımız kesim, yolcu bilgilerini Booking'den ayırmak. Aday olarak güçlü görünüyor: dört ekranı var, formu karmaşık, kendi doğrulama kuralları var, biri mutlaka PassengersModule önerir. Sinyaller ise aksini söylüyor. Passengers'a dokunan commit'lerin %87'si Checkout'a da dokunuyor. Verinin ömrü taslağın ömrüyle aynı. Doğrulama kuralları tarifeye bağlı: dış hat uçuşunda pasaport zorunlu, çocuk tarifesinde doğum tarihi uçuş gününe göre kontrol ediliyor. Ayırsaydık kontrat, Booking'in bildiği tarife ayrıntılarını Passengers'a taşıyan konuşkan bir arayüz olurdu. Booking'in içinde bir klasör olarak kalıyor. Kayıtlı yolcu listesi ise farklı bir veri: sahibi Account, ömrü hesabın ömrü, Booking onu kontrat üzerinden okuyor.

Models/Flight.swift bu sürecin sonunda siliniyor. Yerini Search'te SearchOffer, Booking'te FareSelection ve taslak modeli, Trips'te Ticket alıyor. Dört tip, eskisinden toplamda daha fazla satır eder. Karşılığında hiçbirinde "yalnızca şu aşamada dolu" notu taşıyan bir optional kalmaz.

Sınır gözden geçirme kontrol listesi

  • Sınırın gerekçesi ekran, tab, endpoint ya da ekip adı olmadan bir cümleyle söylenebiliyor.
  • Sınırın iki yakasında anlamı değişen en az bir kelime ya da ömrü farklı en az bir veri gösterilebiliyor.
  • Co-change çıktısı son 12 ay için alındı; sınırın iki yakası arasındaki oran, domain içi oranlardan belirgin biçimde düşük.
  • Her verinin tek bir yazarı var ve hangi domain olduğu yazılı.
  • Bağımlılık yönleri tablo halinde yazıldı, döngü yok, hizmet veren domain'ler kimseyi tanımıyor.
  • Her doğrudan çağrı cevap gerektirdiği için doğrudan çağrı; her event, yayınlayan dinleyeni bilmediği için event.
  • Sınırdan kimlikler ve küçük değer tipleri geçiyor, başka domain'in modeli geçmiyor.
  • Modül silindiğinde derleme yalnızca composition root'ta ve beklenen tüketicilerde kırılıyor.
  • Modül tek başına derleniyor ve testleri diğer domain'lerin implementasyonu olmadan çalışıyor.
  • Public API tek ekrana sığıyor ve sınırdan geçen tip sayısı takip ediliyor.
  • Sınır hangi kademede (klasör, target, ayrı kontrat modülü) ve bir sonraki kademeye geçiş koşulu yazılı.
  • Reddedilen en az bir kesim, gerekçesiyle birlikte not edildi.
Seviye 6 tamamlandıİlerlemen bu tarayıcıda saklanıyor.
Sıradaki: Shared model kullanmak mı, domain-specific model kullanmak mı?

Bu konuda desteğe mi ihtiyacınız var?

Uygulamanızda bu kontrollerin doğru kurulup kurulmadığını birlikte inceleyelim. Güvenlik denetimi ya da danışmanlıkla buradayız.

Yorumlar ve sorular

Katılmadığınız bir nokta ya da projenizde takıldığınız bir yer mi var? Yazın, konuşalım.