Büyük bir native projeyi açtığımızda ilk baktığımız yerlerden biri Core/Models klasörüdür. Orada neredeyse her zaman bir User, bir Product ve projenin işine göre bir Booking ya da Order durur. Dosya uzundur, alanların yarısı optional’dır ve git geçmişinde ekipteki herkesin imzası vardır. Bizim tavrımız net: bu tipler paylaşılmamalı. Her feature ihtiyacı olan şeklin sahibi olmalı, ortak kalan kısım da bir elin parmaklarıyla sayılacak kadar küçük bir shared kernel’de durmalı. Yazının geri kalanı bu tavrın gerekçesi, kodu ve mevcut bir projede oraya nasıl gidileceği.
Örnek olarak hayali bir seyahat uygulaması kullanacağız: uçuş arama, checkout, “seyahatlerim” ve profil ekranları olan, dört beş ekibin çalıştığı bir kod tabanı.
Altmış alanlı Booking böyle oluşur
Kimse altmış alanlı bir model tasarlamaz. İlk sürümde Booking sekiz alanlıdır ve backend’in /bookings/{id} yanıtını birebir karşılar. Sonra arama ekranı aynı tipi teklif listesi için kullanır, çünkü “zaten neredeyse aynı şey”. Checkout vergi kırılımını ekler, seyahatlerim ekranı PNR’ı ve biniş kartını, sadakat ekibi puanları. Büyük projelerde sık gördüğümüz tablo şudur:
// Core/Models/Booking.swift
public struct Booking: Codable, Equatable {
public var id: String
public var pnr: String?
public var status: String? // "CONFIRMED", "confirmed", bazen "OK"
public var origin: String?
public var destination: String?
public var departure_date: String? // aramada yalnızca tarih, detayda tarih ve saat
public var return_date: String?
public var pax_list: [Passenger]?
public var price: Double? // aramada kişi başı, checkout'ta toplam
public var total_price: Double?
public var currency: String?
public var taxes: [TaxLine]?
public var fare_rules: FareRules?
public var payment: PaymentInfo?
public var boarding_pass_url: String?
public var loyalty_points: Int?
public var is_refundable: Bool?
public var isSelected: Bool? // UI durumu, sunucudan hiç gelmez
public var isExpanded: Bool?
// ... kırk alan daha
}
Alanların yarısının optional olmasının sebebi dikkatsizlik değildir. Tek tip, üç farklı endpoint’in yanıtını taşıyor: arama özet döner, detay her şeyi döner, checkout fiyatlanmış halini döner. Üçünü de decode edebilmesi için ortak olmayan her alanın optional olması gerekir. Sonuçta tip, “bu ekranda hangi alanlar kesin var” sorusuna cevap veremez hale gelir ve bu bilgi kodun içine dağılır:
// Features/Checkout/CheckoutSummaryView.swift
Text("\(booking.total_price ?? booking.price ?? 0, specifier: "%.2f") \(booking.currency ?? "TRY")")
Bu satırda üç ayrı sorun var. Checkout ekranı toplam fiyat olmadan var olamaz, ama tip bunu söylemiyor. price alanının anlamı ekrana göre değişiyor. Backend’in snake_case adlandırması, string tarihleri ve tutarsız status değerleri de view katmanına kadar gelmiş. Android tarafında tablo aynıdır: :core:model içinde @Serializable data class Booking, her alan ? = null, her ekranda !! ya da ?: "".
Bedelin ikinci yarısı build’de ödenir. Core module’ünü herkes import ettiği için Booking’e eklenen tek bir alan, onu hiç okumayan feature’ları da yeniden derletir. Bu maliyetin nasıl büyüdüğünü modularization’ın gerçek maliyeti yazısında ayrıca anlatıyoruz.
Module grafiği temiz, coupling tipin içinde
Bu projelerin dependency grafiğini çizdiğinizde çoğu zaman şikâyet edecek bir şey bulamazsınız:
Search ──┐
Checkout ─┼──▶ Core (Models, Networking)
Trips ───┤
Profile ─┘
Feature’lar birbirini import etmiyor, döngü yok, oklar tek yöne bakıyor. Ama Search ile Checkout aynı Booking.price alanını okuyor ve ikisi ona farklı anlam yüklüyor. Checkout ekibi price’ı vergiler dahil toplam olarak doldurmaya başladığı gün arama listesi yanlış fiyat gösterir, üstelik derleme hatası da çıkmaz. Grafikte olmayan bir Search → Checkout bağımlılığı tipin içinden geçmiştir.
Martin Fowler’ın Bounded Context yazısında “Customer” ve “Product” için kullandığı kelime polysemy’dir: aynı ad, kurumun farklı yerlerinde farklı şey ifade eder. Mobilde bunun karşılığı çok somut. Arama için bir uçuş, fiyatı değişebilen ve yarım saat sonra geçersiz olacak bir tekliftir. Checkout için fiyatı garanti edilmiş, yolcuları belli bir sözleşme taslağıdır. Seyahatlerim için PNR’ı olan, check-in saati yaklaşan bir kayıttır. Üçüne de Booking demek kodu sadeleştirmez, yalnızca farkı tipten alıp if’lere taşır. Sınırı nereden çizeceğiniz konusunu domain sınırları yazısında ayrıntılı işledik; burada o sınırın veri modelindeki karşılığına bakıyoruz.
Aynı tipi import eden iki feature, birbirini import etmiş kadar bağlıdır.
Her feature kendi şeklinin sahibi
Önerdiğimiz düzende arama feature’ı FlightOffer’ı, checkout ise PricedItinerary’yi tanımlar. İkisi de aynı gerçek dünya nesnesinden söz eder ama birbirinin tipini görmez. Aralarında yalnızca kimlik taşınır.
Swift
// Features/Search/Domain/FlightOffer.swift
import SharedKernel
struct FlightOffer: Identifiable, Hashable, Sendable {
let id: OfferID
let route: Route
let departure: Date
let arrival: Date
let stops: Int
let pricePerAdult: Money // gösterge fiyat
let seatsLeft: Int? // backend her zaman göndermiyor: bu gerçekten optional
}
// Features/Checkout/Domain/PricedItinerary.swift
import SharedKernel
struct PricedItinerary: Sendable {
let offerID: OfferID
let passengers: [PassengerFare]
let taxes: [TaxLine]
let total: Money // vergiler dahil, backend'in garanti ettiği tutar
let priceGuaranteedUntil: Date
let refundPolicy: RefundPolicy
}
FlightOffer’da tek optional kaldı ve o optional bir şey anlatıyor. PricedItinerary’de total zorunlu, çünkü toplamı olmayan bir checkout ekranı yok. Arama sonucu seçildiğinde Checkout’a FlightOffer geçmiyoruz, OfferID geçiyoruz. Checkout kendi endpoint’inden kendi modelini kuruyor. Feature’lar arasında neyin taşınacağı sorusunun genel hali için global flow context yazısına bakabilirsiniz.
Kotlin
// feature/search/domain/FlightOffer.kt
data class FlightOffer(
val id: OfferId,
val route: Route,
val departure: Instant,
val arrival: Instant,
val stops: Int,
val pricePerAdult: Money,
val seatsLeft: Int?,
)
// feature/checkout/domain/PricedItinerary.kt
data class PricedItinerary(
val offerId: OfferId,
val passengers: List<PassengerFare>,
val taxes: List<TaxLine>,
val total: Money,
val priceGuaranteedUntil: Instant,
val refundPolicy: RefundPolicy,
)
En sık duyduğumuz itiraz “ama bunlar yüzde seksen aynı alanlar” olur. Bugün öyle. Biz alanların bugünkü benzerliğine bakmıyoruz, değişme sebeplerine bakıyoruz. Arama modeli sıralama, filtre ve kampanya etiketleri yüzünden değişir. Checkout modeli ödeme, vergi ve iade kuralları yüzünden değişir. Değişme sebepleri farklı olan iki tipin bugün aynı görünmesi tesadüftür.
DTO, domain ve UI model: üç katman ne zaman fazla?
Katman sayısını bir ilke olarak savunmuyoruz. Varsayılanımız iki katmandır: data katmanında DTO, feature içinde tek bir model. Android’in resmi mimari rehberi de data layer bölümünde network modelini (ArticleApiModel) diğer katmanlara açılan modelden ayırmayı önerir, domain layer’ı ise açıkça optional sayar. Bu ayrım bizim pratiğimizle örtüşüyor.
DTO ile feature modelini neredeyse her zaman ayırırız. Backend sizin ekibinizin elinde olsa bile JSON’un şekli ile ekranın ihtiyacı farklı hızlarda değişir. İstisnamız, backend’in ekrana özel yanıt döndüğü (BFF) ve yanıtın doğrulanacak ya da dönüştürülecek hiçbir şey içermediği salt okunur ekranlardır. Orada DTO’yu doğrudan gösterip geçeriz ve bunu bir borç saymayız.
Üçüncü katmanı, yani ayrı bir UI modelini, yalnızca şu durumlarda ekliyoruz: aynı domain modeli birden fazla ekranda farklı biçimde gösteriliyorsa, biçimlendirme (para, tarih, çoğul ekleri) test edilmeye değecek kadar karmaşıksa ya da ekranın seçili, açık, yükleniyor gibi kendi durumu varsa. Beş satırlık bir liste hücresi için FlightOffer → FlightOfferUIModel → FlightOfferRowViewData zinciri kurmak, mapper’ların mapper’ını yazmak demektir. Üç katmanın her birinde aynı altı alanı görüyorsanız birini silin.
Mapping’i sıkıcı tutmak
Domain-specific modellerin bedeli mapping kodudur ve bu bedel gerçektir. Onu katlanılır yapan şey mapper’ların aptal, kısa ve tek yerde olmasıdır.
Kurallarımız şunlar. Mapper, DTO’nun yanında, feature’ın data katmanında durur. Saf bir fonksiyondur: ağa gitmez, saate bakmaz, DI’dan bir şey almaz. Geçersiz veriyi sessizce varsayılan değerle doldurmaz, hata fırlatır. Repository’nin dışarıya açtığı imzalarda yalnızca domain tipleri görünür.
Swift: DTO, mapper ve test
// Features/Search/Data/FlightOfferDTO.swift
struct FlightOfferDTO: Decodable {
let offerId: String
let origin: String
let destination: String
let departureAt: Date
let arrivalAt: Date
let stopCount: Int
let price: PriceDTO
let seatsLeft: Int?
struct PriceDTO: Decodable {
let amountMinor: Int64
let currency: String
}
}
enum MappingError: Error, Equatable {
case invalid(field: String)
}
extension FlightOffer {
init(dto: FlightOfferDTO) throws {
guard dto.arrivalAt > dto.departureAt else { throw MappingError.invalid(field: "arrival_at") }
guard dto.stopCount >= 0 else { throw MappingError.invalid(field: "stop_count") }
self.init(
id: OfferID(dto.offerId),
route: Route(origin: dto.origin, destination: dto.destination),
departure: dto.departureAt,
arrival: dto.arrivalAt,
stops: dto.stopCount,
pricePerAdult: Money(minorUnits: dto.price.amountMinor, currencyCode: dto.price.currency),
seatsLeft: dto.seatsLeft
)
}
}
DTO’da CodingKeys yok, çünkü decoder’ı keyDecodingStrategy = .convertFromSnakeCase ve dateDecodingStrategy = .iso8601 ile data katmanında bir kez kuruyoruz. Dikkat edilecek nokta domain tipinin Codable olmamasıdır. Derleyicinin ürettiği Codable uyumu property adlarını anahtar olarak kullanır. FlightOffer’ı Codable yaptığınız anda bir property’yi yeniden adlandırmak wire formatını ya da diskteki cache’i bozan bir değişikliğe dönüşür. Diskte saklamanız gerekiyorsa onun için de data katmanında ayrı bir kayıt tipi tutun.
Bir dürüstlük notu: Swift’te internal module kapsamındadır. Search tek bir target ise view kodu DTO’yu görebilir ve bunu yalnızca review ile engellersiniz. Derleyicinin engellemesini istiyorsanız data katmanını ayrı bir target yapmanız gerekir. Küçük feature’larda bunu yapmıyoruz, klasör düzeni ve repository imzası yetiyor.
Mapper testleri, yazması en ucuz testlerdir. Gerçek yanıtlardan alınmış birkaç JSON fixture ve Swift Testing yeterli (Fixtures bizim küçük yardımcı tipimiz, bundle’dan dosyayı okuyup aynı decoder ile çözer):
import Testing
@testable import SearchFeature
struct FlightOfferMappingTests {
@Test func mapsAValidOffer() throws {
let dto = try Fixtures.decode(FlightOfferDTO.self, from: "offer_valid.json")
let offer = try FlightOffer(dto: dto)
#expect(offer.pricePerAdult == Money(minorUnits: 124_990, currencyCode: "TRY"))
#expect(offer.stops == 0)
}
@Test func rejectsArrivalBeforeDeparture() throws {
let dto = try Fixtures.decode(FlightOfferDTO.self, from: "offer_arrival_before_departure.json")
#expect(throws: MappingError.invalid(field: "arrival_at")) {
try FlightOffer(dto: dto)
}
}
}
Kotlin: DTO, mapper ve test
// feature/search/data/FlightOfferDto.kt
@Serializable
internal data class FlightOfferDto(
@SerialName("offer_id") val offerId: String,
val origin: String,
val destination: String,
@SerialName("departure_at") val departureAt: String,
@SerialName("arrival_at") val arrivalAt: String,
@SerialName("stop_count") val stopCount: Int,
val price: PriceDto,
@SerialName("seats_left") val seatsLeft: Int? = null,
)
@Serializable
internal data class PriceDto(
@SerialName("amount_minor") val amountMinor: Long,
val currency: String,
)
internal fun FlightOfferDto.toDomain(): FlightOffer {
val departure = OffsetDateTime.parse(departureAt).toInstant()
val arrival = OffsetDateTime.parse(arrivalAt).toInstant()
require(arrival > departure) { "arrival_at, departure_at'ten önce (offer $offerId)" }
require(stopCount >= 0) { "stop_count negatif (offer $offerId)" }
return FlightOffer(
id = OfferId(offerId),
route = Route(origin, destination),
departure = departure,
arrival = arrival,
stops = stopCount,
pricePerAdult = Money(price.amountMinor, price.currency),
seatsLeft = seatsLeft,
)
}
class FlightOfferMappingTest {
private val json = Json { ignoreUnknownKeys = true }
@Test
fun `gecerli teklifi map eder`() {
val dto = json.decodeFromString<FlightOfferDto>(fixture("offer_valid.json"))
assertEquals(Money(124_990, "TRY"), dto.toDomain().pricePerAdult)
}
@Test
fun `varis kalkistan onceyse reddeder`() {
val dto = json.decodeFromString<FlightOfferDto>(fixture("offer_arrival_before_departure.json"))
assertFailsWith<IllegalArgumentException> { dto.toDomain() }
}
}
Kotlin’de internal da Gradle module’ü kapsamındadır, yani aynı not burada da geçerli: DTO’nun feature dışına çıkmasını derleyici engeller, feature içindeki ViewModel’in onu kullanmasını engellemez. Data katmanından çıkmaması gereken tiplerin listesi kısadır: @Serializable sınıflar, @SerialName taşıyan her şey, JsonElement ve Json örneğinin kendisi. kotlinx.serialization varsayılan olarak bilinmeyen anahtarda hata verir. Backend’in alan eklemesini kırıcı değişiklik saymak istemiyorsanız ignoreUnknownKeys = true ayarını data katmanında bir kez yapın. Swift tarafında derleyicinin ürettiği Decodable bilinmeyen anahtarları zaten yok sayar.
Paylaşmak serbest olanlar: küçük bir shared kernel
“Hiçbir şey paylaşmayın” demiyoruz. Her feature kendi Money tipini yazarsa bir gün birisi kuruşları Double ile toplar. DDD’nin Shared Kernel dediği şey tam bunun içindir: ekiplerin bilerek paylaştığı, açıkça sınırlanmış ve öbür ekiplere danışmadan değiştirilmeyen küçük bir alt küme.
Bizim kernel’imizde genellikle şunlar olur: Money, tipli kimlikler (OfferID, BookingID, UserID), DateRange ve ağ ya da oturum seviyesindeki hata tipleri. Bir tipi oraya almak için kullandığımız kural dört sorudan oluşur ve dördüne de evet demek gerekir:
- Her feature’da aynı anlama mı geliyor ve aynı kuralları mı taşıyor?
- Alan sayısı sabit mi? “Buna bir de şunu ekleyelim” talebi gelmeyeceğinden emin miyiz?
- Standart kütüphane dışında hiçbir şeye bağımlı olmadan yazılabiliyor mu?
- Bugün en az üç feature tarafından gerçekten kullanılıyor mu?
Booking ilk soruda elenir. Money dördünden de geçer.
Swift
// SharedKernel/Money.swift
public struct OfferID: Hashable, Sendable {
public let rawValue: String
public init(_ rawValue: String) { self.rawValue = rawValue }
}
public struct Money: Hashable, Sendable {
public let minorUnits: Int64 // kuruş, cent
public let currencyCode: String // ISO 4217
public init(minorUnits: Int64, currencyCode: String) {
self.minorUnits = minorUnits
self.currencyCode = currencyCode
}
public func adding(_ other: Money) throws -> Money {
guard currencyCode == other.currencyCode else { throw MoneyError.currencyMismatch }
return Money(minorUnits: minorUnits + other.minorUnits, currencyCode: currencyCode)
}
}
public enum MoneyError: Error { case currencyMismatch }
Kotlin
// core/kernel/Money.kt
@JvmInline
value class OfferId(val value: String)
data class Money(val minorUnits: Long, val currencyCode: String) {
init { require(currencyCode.length == 3) { "ISO 4217 kodu bekleniyor: $currencyCode" } }
operator fun plus(other: Money): Money {
require(currencyCode == other.currencyCode) { "Para birimleri farklı" }
return copy(minorUnits = Math.addExact(minorUnits, other.minorUnits))
}
}
Tipli kimlikler bu işin en ucuz kazancıdır: cancel(bookingId: String, userId: String) imzasında argümanların yerini karıştırmak derleyici hatasına döner. Kotlin’de value class JVM’de mümkün olan yerde alttaki tipe derlenir, yani çoğu çağrıda ek nesne oluşmaz. Dikkat edilecek iki şey var: value class alan fonksiyonların adı JVM’de mangle edilir, Java’dan çağrılacaksa @JvmName gerekir. Ayrıca kernel tiplerine @Serializable ya da Codable eklemiyoruz. DTO’da String durur, mapper onu OfferId’ye sarar. Böylece kernel’in serialization kütüphanesine bağımlılığı olmaz ve üçüncü soru geçerliliğini korur.
Hata tiplerinde çizgiyi şöyle çekiyoruz: “bağlantı yok”, “oturum düştü”, “sunucu hatası” gibi her feature’ın aynı şekilde ele aldığı durumlar kernel’dedir. “Fiyat garantisi doldu” checkout’un hatasıdır ve orada kalır.
Backend değişince tek yer değişir
Diyelim ki backend fiyatı düz sayı olarak gönderiyordu ve v2’de nesneye çevirdi:
{ "price": 1249.90, "currency": "TRY" }
{ "price": { "amount_minor": 124990, "currency": "TRY" } }
God-model dünyasında bu değişiklik booking.price okuyan her ekrana dokunur. Feature’a ait modellerde değişen yer DTO ve mapper’dır. FlightOffer.pricePerAdult hâlâ Money’dir, view’lar ve view model’ler derlenmeye bile gerek duymaz. Geçiş sürecinde iki sürümü birden desteklemeniz gerekiyorsa bu da data katmanında kalır. Aşağıdaki PriceFieldDTO, kendi init(from:)’unda önce nesneyi, olmazsa sayıyı deneyen iki case’li bir enum:
extension Money {
init(dto: PriceFieldDTO, fallbackCurrency: String?) throws {
switch dto {
case .v2(let price):
self.init(minorUnits: price.amountMinor, currencyCode: price.currency)
case .v1(let major):
guard let currency = fallbackCurrency else { throw MappingError.invalid(field: "currency") }
// v1 yalnızca iki ondalıklı para birimleriyle çalışıyordu
self.init(minorUnits: Int64((major * 100).rounded()), currencyCode: currency)
}
}
}
Aynı yer, backend’in tutarsızlıklarını emdiğiniz yerdir. status alanını DTO’da enum olarak decode etmeyin: varsayılan ayarlarda hem Swift’in raw value’lu enum’ları hem kotlinx.serialization bilinmeyen bir değerde decode’u hata ile bitirir ve backend’in eklediği yeni bir durum bütün listeyi düşürür. DTO’da String tutup mapper’da karar vermek daha sağlamdır:
internal fun String.toTripStatus(): TripStatus = when (lowercase()) {
"confirmed", "ok" -> TripStatus.Confirmed
"cancelled", "canceled" -> TripStatus.Cancelled
"pending" -> TripStatus.AwaitingPayment
else -> TripStatus.Unknown(raw = this)
}
"OK" ile "confirmed"’ın aynı şey olduğu bilgisi artık tek bir dosyada ve testi var. Daha önce bu bilgi dört ekranda dört farklı if olarak yaşıyordu.
God-model’i tüketici tüketici bölmek
Mevcut bir projede Booking’i bir sprint’te silmeye çalışmayın. Bizim izlediğimiz sıra şöyle.
Önce kimin neyi okuduğunu çıkarın. En güvenilir yöntem ilkeldir: bir alanı geçici olarak yeniden adlandırın, derleyin, hata veren dosyaları feature’a göre not edin. Altmış alan için bir öğleden sonra sürer ve elinizde alan başına tüketici tablosu olur. Çoğu zaman alanların hatırı sayılır kısmının tek bir feature tarafından okunduğunu, bir kısmının da hiç okunmadığını görürsünüz.
Sonra modeli dondurun. Yeni alan eklemek yasak, tip deprecated:
// core/model/Booking.kt
@Deprecated("Yeni alan eklemeyin. Feature'a ait modele geçin: docs/models.md")
@Serializable
data class Booking(/* ... */)
Swift’te karşılığı @available(*, deprecated, message: "..."). Uyarılar gürültü yapar, bu aşamada istediğimiz de budur.
Üçüncü adımda en dar kullanımı olan tüketiciyi seçin ve ona kendi modelini verin, ama ağ katmanına henüz dokunmayın. Yeni model eski modelden üretilir:
// Features/Trips/Data/Trip+Legacy.swift
extension Trip {
init(legacy booking: Booking) throws {
guard let pnr = booking.pnr else { throw MappingError.invalid(field: "pnr") }
guard let departure = booking.departure_date.flatMap(LegacyDates.parse) else {
throw MappingError.invalid(field: "departure_date")
}
self.init(
id: BookingID(booking.id),
pnr: pnr,
departure: departure,
status: TripStatus(raw: booking.status ?? "")
)
}
}
Bu adaptör çirkindir ve geçicidir. Faydası, feature’ın içindeki bütün ?? ve force unwrap’lerin tek bir dosyaya toplanmasıdır. Feature’ın geri kalanı o andan itibaren Trip ile çalışır ve Booking’i import eden dosya sayısı bire iner.
Dördüncü adımda feature kendi DTO’sunu ve kendi endpoint çağrısını alır, adaptör silinir. Bu noktada feature’ın Booking ile bağı kopmuştur. Sonra bir sonraki tüketiciye geçersiniz. Her tüketici ayrıldığında yalnızca onun okuduğu alanları god-model’den silin. Derleyici yanılıp yanılmadığınızı söyler. Sona kalan birkaç alan ya kernel’e girecek kadar ortaktır ya da kimsenin okumadığı ölü alanlardır.
Sırayı tüketici bazında tutmanın sebebi her adımın tek başına yayınlanabilir olmasıdır. Yarıda bırakılmış bir göç, başlanmamış olandan daha kötü değildir: iki feature kurtulmuştur, kalanı eskisi gibi çalışır.
İşe yaradığını nasıl anlarsınız
Burada size bir yüzde vermeyeceğiz, kendi projenizde ölçebileceğiniz şeyleri sayacağız.
- God-model’deki alan sayısı ve onu import eden dosya sayısı her sprint düşüyor mu? İkisi de tek satırlık bir script ile sayılır.
- Bir feature modeline alan ekleyip incremental build aldığınızda hangi module’ler yeniden derleniyor? Xcode’un build timeline’ına ya da Gradle’ın çalıştırdığı task listesine göçten önce ve sonra bakın. Hedef, değişikliğin kendi feature’ı dışına taşmamasıdır.
- Feature kodundaki
??,!!ve force unwrap sayısı azalıyor mu? Kalan optional’lar gerçekten “olmayabilir” anlamına mı geliyor? - Bir backend alanı değiştiğinde açılan PR kaç feature klasörüne dokunuyor? Hedef bir.
- Test fixture’ları küçüldü mü? Altmış parametreli
Booking.mock()yerine altı alanlı birFlightOfferkurmak, testlerin okunabilirliğinde hemen fark edilir.
Nerede değmez
Tek ekip, on ekran ve ekrana özel yanıt dönen bir backend varsa mapping katmanı size iş çıkarır, bir şey kazandırmaz. Tek bir model katmanıyla devam edin ve yukarıdaki belirtiler görünmeye başladığında bölün. Belirti genellikle şudur: aynı tipe ikinci bir ekip alan eklemek ister ve alanın optional olması gerektiğini söyler.
Karar tablosu
| Tip | Karar | Gerekçe |
|---|---|---|
| Backend yanıt tipleri (DTO) | Feature’a ait | Data katmanında kalır, repository imzasında görünmez |
Booking, User, Product gibi “entity”ler |
Feature’a ait | Her feature kendi projeksiyonunu tanımlar, aralarında kimlik taşınır |
Tipli kimlikler (BookingID, UserID) |
Shared kernel | Her yerde aynı anlam, sıfıra yakın değişim |
Money, DateRange |
Shared kernel | Kuralları evrensel, yanlış yazması pahalı |
| Ağ ve oturum hataları | Shared kernel | Her feature aynı şekilde ele alır |
| Feature’a özgü hatalar (fiyat süresi doldu) | Feature’a ait | Başka kimse anlamlı bir şey yapamaz |
| UI model, view state | Feature’a ait | Ekranla birlikte değişir, paylaşmak için bir sebep yok |
| Oturumdaki kullanıcının kimliği ve görünen adı | Paylaş | Sahibi session module’üdür, dar bir arayüzle açılır, Models içinde durmaz |
| Design system bileşenlerinin girdi tipleri | Paylaş | Design system module’ünün public API’sidir |
BookingStatus gibi enum’lar |
Feature’a ait | Durum kümeleri feature’a göre farklılaşır, anlamın aynı kaldığı kanıtlanırsa kernel’e alınır |
| Bugün birebir aynı görünen iki feature modeli | Feature’a ait | Kopyalayın. Değişiklikler üç kez birlikte gelirse yeniden değerlendirin |
| Disk cache’i için saklanan kayıtlar | Feature’a ait | Wire formatı gibi, data katmanının iç meselesi |
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.
Kaynaklar
- Martin Fowler · Bounded Context
- Domain Language · DDD Reference
- Android Developers · Data layer
- Android Developers · Domain layer
- Kotlin · Inline value classes
- Kotlin · Visibility modifiers
- kotlinx.serialization · JSON features
- Apple · Encoding and decoding custom types
- Apple · JSONDecoder.KeyDecodingStrategy
- Apple · Swift Testing
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.