Feature-based mimari vs layer-based mimari

Kodu teknik katmana göre mi, özelliğe göre mi dilimlemeli? Layer-first projelerin nasıl yaşlandığını, feature-first'ün neye mal olduğunu, ölçekte önerdiğimiz yapıyı ve bir seferde tek feature taşıyan geçiş yolunu iOS ve Android örnekleriyle anlatıyoruz.

Bir projeyi ilk açtığınızda kök dizinde ne gördüğünüz, o projenin nasıl yaşlanacağı hakkında epey şey söyler. Network, Repositories, ViewModels, Views görüyorsanız proje teknik katmana göre dilimlenmiştir. Search, Checkout, Profile görüyorsanız özelliğe göre. İlki uygulamanın nasıl yapıldığını anlatır, ikincisi ne yaptığını. İkisi de aynı kodu içerir; fark, hangi eksenin üst seviyede olduğu ve bir değişikliğin kaç yere dokunduğudur.

Pozisyonumuz baştan belli olsun: birden fazla ekibin çalıştığı, birkaç yılı devirmiş bir native uygulamada üst seviye dilim feature olmalı, katmanlar feature'ın içinde ince halde kalmalı. Küçük bir ekip ve sınırları henüz belli olmayan genç bir ürün için ise layer-based yapı doğru karardır ve bunu yazının ilerisinde ayrıca savunacağız. Örnek olarak baştan sona hayali bir perakende uygulaması kullanıyoruz: arama, ürün detayı, sepet ve ödeme, profil.

İki dilimleme, iki seviye

Ayrım hem klasör seviyesinde hem module seviyesinde yapılabilir ve bu ikisi sık sık birbirine karıştırılır. Klasör seviyesi yalnızca bir okuma kolaylığıdır: derleyici klasörleri görmez, herkes her şeyi import edebilir. Module seviyesinde (SPM target'ı, Gradle module'ü) dilim bir derleme sınırına dönüşür ve bağımlılıkların yönü build dosyasında yazılı hale gelir.

Layer-first bir iOS projesinin SPM karşılığı genellikle şöyledir:

Packages/ShopKit/Sources/
├── Models/          ← Product, Cart, User, Order, Address, Coupon...
├── Networking/      ← APIClient + bütün endpoint'ler
├── Repositories/    ← ProductRepository, CartRepository, UserRepository...
├── ViewModels/      ← SearchViewModel, CheckoutViewModel, ProfileViewModel...
├── Views/           ← bütün ekranlar
└── Utils/           ← "nereye koyacağımı bilemedim"

Android tarafında aynı fikir, Clean Architecture şablonlarından tanıdık üç module ile gelir:

:app
:presentation   ← bütün ekranlar ve ViewModel'ler
:domain         ← bütün use case'ler ve modeller
:data           ← bütün repository'ler, Retrofit servisleri, Room
:common         ← extension'lar, Constants.kt, Utils.kt

Feature-first halinde üst seviye değişir: SearchFeature, CheckoutFeature, ProfileFeature ya da :feature:search, :feature:checkout, :feature:profile. Katmanlar kaybolmaz, bir alt seviyeye iner.

Layer-first projeler nasıl yaşlanır

İlk yıl sorun çıkmaz. Yapı tanıdıktır, yeni gelen herkes dosyayı nereye koyacağını bilir, şablonlar bu düzeni varsayar. Yaşlanma, ekran sayısı ve ekip sayısı arttığında başlar ve hep aynı belirtilerle gelir.

Tek değişiklik, beş klasör

Ödeme ekranına "hediye notu" alanı eklemek isteyen bir geliştiricinin dokunduğu dosyalar:

Models/Order.swift                        (alan eklendi)
Networking/Endpoints/OrderEndpoints.swift (istek gövdesi)
Repositories/OrderRepository.swift        (parametre)
ViewModels/CheckoutViewModel.swift        (state)
Views/Checkout/CheckoutView.swift         (TextField)
Utils/Validators.swift                    (uzunluk kontrolü)

Altı dosya, altı klasör, tek bir iş. Değişiklik mantıksal olarak tek bir yerde yaşıyor ama fiziksel olarak projenin tamamına yayılıyor. Pull request'i inceleyen kişi de beş farklı klasörde gezinmek zorunda kalıyor ve o klasörlerin her birinde başka ekiplerin alakasız dosyaları duruyor.

Her katman her şeye bağlı

Layer-first yapıyı module'lere böldüğünüzde Package.swift şuna benzer:

// swift-tools-version: 6.0
import PackageDescription

let package = Package(
    name: "ShopKit",
    platforms: [.iOS(.v17)],
    products: [
        .library(name: "ShopKit", targets: ["Views"]),
    ],
    targets: [
        .target(name: "Utils"),
        .target(name: "Models", dependencies: ["Utils"]),
        .target(name: "Networking", dependencies: ["Models", "Utils"]),
        .target(name: "Repositories", dependencies: ["Networking", "Models", "Utils"]),
        .target(name: "ViewModels", dependencies: ["Repositories", "Models", "Utils"]),
        .target(name: "Views", dependencies: ["ViewModels", "Models", "Utils"]),
    ]
)

Kağıt üzerinde düzgün bir zincir. Pratikte ise Models içindeki Coupon tipine bir alan eklediğinizde zincirin tamamı yeniden derlenir, çünkü arama da profil de ödeme de aynı Models target'ına bağlıdır. Module'lere böldünüz ama derleme açısından hâlâ tek parça bir uygulamanız var; yalnızca altı parçaya ayrılmış bir monolit.

Android'de tablo aynıdır, üstüne bir de api sızıntısı eklenir:

// presentation/build.gradle.kts
dependencies {
    implementation(project(":domain"))
    implementation(project(":data"))      // "DI için lazım"
    api(project(":common"))               // herkes kullanıyor, api olsun
}

:presentation içindeki CheckoutViewModel, :data içindeki ProductDao'yu doğrudan görebilir ve bir gün biri mutlaka görür. Gradle'ın api ve implementation ayrımı tam da bunu engellemek için var; api ile verilen bağımlılık tüketicinin compile classpath'ine taşınır, yani :common içindeki her değişiklik onu geçişli olarak gören her module'ü yeniden derletir.

Models ve Utils çöplükleri

Layer-first yapıda bir tipin yeri "ne tür bir şey" olduğuna göre belirlenir; kime ait olduğunu soran olmaz. Bu yüzden Models klasörü zamanla herkesin ortak malı olur. Arama sonucundaki ürün ile sepetteki ürün aynı Product struct'ını paylaşır, struct her ekranın ihtiyacı için bir optional alan daha kazanır ve sonunda yirmi beş alanlı, hiçbir ekranda tamamı dolu olmayan bir tipe dönüşür. Bu konuyu ortak model ile domain modeli ayrımı yazısında ayrıca ele aldık.

Utils daha da hızlı büyür. String+Extensions.swift içinde IBAN formatlayan, tarih ayrıştıran ve kargo ücretini yuvarlayan fonksiyonlar yan yana durur. Dosyanın tek ortak özelliği, yazanların hiçbirinin daha iyi bir yer bulamamış olmasıdır.

Sahiplik ve merge conflict

Üç ekibiniz varsa (arama, ödeme, hesap) ViewModels klasörünün sahibi kimdir? Hiç kimse ya da herkes. CODEOWNERS dosyasına yazabileceğiniz anlamlı bir satır yoktur, çünkü klasör sınırları ekip sınırlarıyla örtüşmez. Ortak dosyalar da çatışma üretir: Endpoints.swift, AppContainer, Routes.kt, strings.xml, tek dosyalık DI module'ü. Bu dosyalara her sprint her ekip dokunur.

Kendi projenizde bunu ölçmek kolay. En çok değişen dosyaları listeleyin:

git log --since="6 months ago" --no-merges --name-only --pretty=format: \
  | grep -v '^$' | sort | uniq -c | sort -rn | head -20

Listenin başında belirli bir özelliğe ait olmayan dosyalar varsa (Models/Product.swift, di/AppModule.kt, Utils/Extensions.swift), layer-first yapının faturasını ödemeye başlamışsınız demektir.

Feature-first neyi satın alır

Değişikliğin yerelliği. Hediye notu örneği feature-first yapıda tek bir module'ün içinde kalır. Diff'e bakan kişi neyin etkilendiğini dizin adından anlar.

Sahiplik. Sources/CheckoutFeature/ ya da feature/checkout/ tek satırla bir ekibe atanabilir. Review yükü, nöbet sorumluluğu ve "bu neden böyle yazılmış" sorusunun muhatabı belli olur.

Silinebilirlik. Bizim en çok önem verdiğimiz özellik bu. Kampanya bittiğinde ya da bir deney tutmadığında feature'ı kaldırmak, bir dizini silmek ve app target'ındaki iki satırı çıkarmaktan ibaret olmalı. Layer-first yapıda aynı iş beş klasörde arkeoloji demektir ve genellikle yapılmaz; ölü kod yıllarca derlenmeye devam eder.

Bir feature'ı bir öğleden sonrada silemiyorsanız, sınırı yanlış yerden çizmişsinizdir.

Paralel build ve önbellek. Birbirine bağlı olmayan module'ler paralel derlenebilir ve birindeki değişiklik diğerini geçersiz kılmaz. Gradle tarafında bunun karşılığı paralel çalıştırma ve build cache'tir; Xcode da birbirinden bağımsız target'ları paralel derler. Ne kadar kazanacağınız grafiğinizin şekline bağlıdır, bu yüzden rakam vermiyoruz. Ölçmek için Checkout içinde tek satır değiştirip incremental build süresine ve yeniden derlenen module sayısına bakın (Gradle'da --scan ya da build raporu, Xcode'da build timeline).

Ve neye mal olur

Feature-first bedava gelmez. Dört maliyeti var ve dördü de gerçek.

Birincisi kopyalama baskısı. Arama da sepet de fiyat formatlamak ister. Ya iki feature'da aynı on satırı yazarsınız ya da on satır için ortak bir module açarsınız. Bizim kuralımız: aynı kod üçüncü kez gerektiğinde ve gerçekten aynı sebeple değişiyorsa core'a taşınır, o ana kadar kopya kalır. İki feature'daki benzer görünen kodun çoğu, altı ay sonra farklı yönlere evrilir.

İkincisi feature'lar arası navigasyon. Ürün detayı "sepete git" demek zorunda, sepet "giriş yap" demek zorunda. Bunu doğrudan import ile çözerseniz üçüncü maliyete, yani feature'dan feature'a bağımlılığa varırsınız. CheckoutFeature bir kez ProfileFeature'ı import ettiğinde, bir sonraki sprint'te ters yönde bir ihtiyaç doğar ve döngüsel bağımlılığı kırmak için ortak tipleri bir yere taşımak gerekir. O yer genellikle yeni bir Common module'üdür ve çöplük geri gelmiş olur.

Dördüncüsü ortak kodun adresi. "Paylaşılan her şey" için tek bir Shared module'ü açmak, Utils klasörünü module'e terfi ettirmekten başka bir şey yapmaz. Ortak kod, ne iş yaptığını adıyla söyleyen az sayıda core module'de yaşamalı.

Ölçekte önerdiğimiz yapı

Feature module'leri kendi içinde ince katmanlar tutar. Altlarında küçük ve sabit bir core kümesi vardır: design system, networking, persistence, analytics. Feature'lar birbirine doğrudan bağlanmaz; onları yalnızca app target'ı bir araya getirir.

                        App (composition root, navigasyon)
        ┌──────────────┬──────┴───────┬───────────────┐
        ▼              ▼              ▼               ▼
  SearchFeature  ProductFeature  CheckoutFeature  ProfileFeature
        │              │              │               │
        └──────────────┴──────┬───────┴───────────────┘
                              ▼
        DesignSystem   Networking   Persistence   Analytics

  Yasak ok:  Feature ──► Feature
  Yasak ok:  Core ──► Feature

Martin Fowler'ın presentation-domain-data katmanlaması üzerine yazısı aynı sonuca varır: katmanlar büyüdüğünde üst seviyeyi domain odaklı module'lere bölüp katmanlamayı içeride tutmak gerekir. Android'in resmi modularization rehberi ve Now in Android örneği de feature ve core module'leri üzerine kuruludur.

iOS: SPM target'ları

Packages/ShopKit/
├── Package.swift
└── Sources/
    ├── DesignSystem/
    ├── Networking/
    ├── Persistence/
    ├── Analytics/
    ├── SearchFeature/
    ├── ProductFeature/
    ├── ProfileFeature/
    └── CheckoutFeature/
        ├── CheckoutEntry.swift        ← tek public giriş noktası
        ├── UI/                        ← CheckoutView, CheckoutViewModel
        ├── Domain/                    ← Cart, PaymentMethod, PlaceOrder
        └── Data/                      ← CheckoutAPI, DTO'lar, mapper
// swift-tools-version: 6.0
import PackageDescription

let core: [Target.Dependency] = ["DesignSystem", "Networking", "Persistence", "Analytics"]

let package = Package(
    name: "ShopKit",
    platforms: [.iOS(.v17)],
    products: [
        .library(name: "SearchFeature", targets: ["SearchFeature"]),
        .library(name: "ProductFeature", targets: ["ProductFeature"]),
        .library(name: "CheckoutFeature", targets: ["CheckoutFeature"]),
        .library(name: "ProfileFeature", targets: ["ProfileFeature"]),
    ],
    targets: [
        // Core: birbirine en fazla aşağı yönde bağlanır, feature'ları hiç tanımaz
        .target(name: "DesignSystem"),
        .target(name: "Networking"),
        .target(name: "Persistence"),
        .target(name: "Analytics"),

        // Feature'lar: yalnızca core. Listede başka bir feature adı görürseniz PR'ı durdurun.
        .target(name: "SearchFeature", dependencies: core),
        .target(name: "ProductFeature", dependencies: core),
        .target(name: "CheckoutFeature", dependencies: core),
        .target(name: "ProfileFeature", dependencies: core),

        .testTarget(name: "CheckoutFeatureTests", dependencies: ["CheckoutFeature"]),
    ]
)

Feature'ın içindeki UI, Domain, Data yalnızca klasördür. Onları ayrı target yapmıyoruz, çünkü her feature'ı üçe bölmek module sayısını üçe katlar ve karşılığında çok az şey verir (modularization'ın maliyeti yazısında bu hesabı ayrıntılı yaptık). Katman disiplini internal varsayılanıyla sağlanır: feature'ın dışarıya açtığı tek şey giriş noktasıdır.

// Sources/CheckoutFeature/CheckoutEntry.swift
import Analytics
import Networking
import SwiftUI

/// Feature'ın dışarıdan istediği her şey.
public struct CheckoutDependencies: Sendable {
    public let httpClient: any HTTPClient
    public let analytics: any AnalyticsTracking

    public init(httpClient: any HTTPClient, analytics: any AnalyticsTracking) {
        self.httpClient = httpClient
        self.analytics = analytics
    }
}

/// Feature'ın dışarıya söyleyebildiği her şey. Nereye gidileceğine app karar verir.
public enum CheckoutOutput: Sendable {
    case orderPlaced(orderID: String)
    case loginRequired
    case productTapped(productID: String)
}

public enum CheckoutEntry {
    @MainActor
    public static func makeView(
        cartID: String,
        dependencies: CheckoutDependencies,
        onOutput: @escaping @MainActor (CheckoutOutput) -> Void
    ) -> some View {
        let api = CheckoutAPI(client: dependencies.httpClient)          // internal
        let viewModel = CheckoutViewModel(                               // internal
            cartID: cartID,
            placeOrder: PlaceOrder(api: api),
            analytics: dependencies.analytics,
            onOutput: onOutput
        )
        return CheckoutView(viewModel: viewModel)                        // internal
    }
}

Dikkat edin: CheckoutOutput içinde Product yok, productID var. Feature'lar arasında nesne yerine kimlik taşıyoruz; karşı taraf ihtiyacı olan veriyi kendi modeliyle yükler. Android'in modularization rehberi de aynı şeyi önerir. App target'ı çıktıları navigasyona çevirir:

// App target: feature'ları tanıyan tek yer
import CheckoutFeature
import ProductFeature
import ProfileFeature
import SwiftUI

enum AppRoute: Hashable {
    case product(id: String)
    case checkout(cartID: String)
    case order(id: String)
}

struct AppRouter: View {
    let container: AppContainer
    @State private var path: [AppRoute] = []
    @State private var isLoginPresented = false

    var body: some View {
        NavigationStack(path: $path) {
            HomeView(onProductTapped: { path.append(.product(id: $0)) })
                .navigationDestination(for: AppRoute.self) { route in
                    switch route {
                    case .checkout(let cartID):
                        CheckoutEntry.makeView(
                            cartID: cartID,
                            dependencies: container.checkoutDependencies
                        ) { output in
                            switch output {
                            case .orderPlaced(let id): path = [.order(id: id)]
                            case .loginRequired: isLoginPresented = true
                            case .productTapped(let id): path.append(.product(id: id))
                            }
                        }
                    case .product(let id):
                        ProductEntry.makeView(productID: id, dependencies: container.productDependencies) { _ in }
                    case .order(let id):
                        OrderDetailEntry.makeView(orderID: id, dependencies: container.profileDependencies)
                    }
                }
        }
        .sheet(isPresented: $isLoginPresented) {
            LoginEntry.makeView(dependencies: container.profileDependencies)
        }
    }
}

Bir not: Swift 5.9 ile gelen package erişim seviyesi, bir sembolü aynı package içindeki diğer target'lara açıp dışarıya kapatır. Core tiplerini paket dışına sızdırmamak için işe yarar, ama feature'ın feature'ı görmesini engellemez. O kuralı target'ın dependencies listesi taşır. Xcode build'lerinde, bildirilmemiş bir target import'unun ortak build ürünleri dizini yüzünden yine de derlenebildiği bilinen bir durumdur; kendi Xcode sürümünüzde deneyin ve kuralı CI'da ayrıca kontrol edin.

Android: Gradle module'leri

// settings.gradle.kts
include(":app")

include(":core:designsystem")
include(":core:network")
include(":core:database")
include(":core:analytics")

include(":feature:search")
include(":feature:product")
include(":feature:checkout")
include(":feature:profile")
feature/checkout/
├── build.gradle.kts
└── src/main/kotlin/com/example/shop/feature/checkout/
    ├── CheckoutRoute.kt          ← tek public giriş noktası
    ├── ui/                       ← CheckoutScreen, CheckoutViewModel (internal)
    ├── domain/                   ← Cart, PlaceOrderUseCase (internal)
    └── data/                     ← CheckoutService, DTO, mapper (internal)
// feature/checkout/build.gradle.kts
plugins {
    // Projenin kendi convention plugin'i: Android library, Compose ve ortak ayarlar tek yerde
    id("shop.android.feature")
}

android {
    namespace = "com.example.shop.feature.checkout"
}

dependencies {
    implementation(project(":core:designsystem"))
    implementation(project(":core:network"))
    implementation(project(":core:database"))
    implementation(project(":core:analytics"))
    // Burada project(":feature:...") satırı olmaz.
}

shop.android.feature bizim uydurduğumuz bir ad; build ayarlarını convention plugin'e toplamak ise Gradle'ın ve Android rehberinin önerdiği yoldur. On beş feature module'ünde aynı otuz satırı kopyalamak, feature-first'ün en sinir bozucu ve en kolay önlenen maliyetidir.

Navigasyon sözleşmesi iOS'takinin aynısı: feature dışarıya ne söyleyebileceğini bir interface ile bildirir, app bunu uygular.

// :feature:checkout
package com.example.shop.feature.checkout

interface CheckoutNavigator {
    fun onOrderPlaced(orderId: String)
    fun onLoginRequired()
    fun onProductClicked(productId: String)
}

@Composable
fun CheckoutRoute(
    cartId: String,
    navigator: CheckoutNavigator,
    modifier: Modifier = Modifier,
) {
    // CheckoutViewModel ve CheckoutScreen bu module'de internal kalır
    val viewModel: CheckoutViewModel = hiltViewModel()
    CheckoutScreen(cartId = cartId, viewModel = viewModel, navigator = navigator, modifier = modifier)
}
// :app
@Serializable data class CheckoutDestination(val cartId: String)
@Serializable data class ProductDestination(val productId: String)
@Serializable data class OrderDestination(val orderId: String)
@Serializable data object LoginDestination

fun NavGraphBuilder.checkoutGraph(navController: NavController) {
    composable<CheckoutDestination> { entry ->
        val destination = entry.toRoute<CheckoutDestination>()
        CheckoutRoute(
            cartId = destination.cartId,
            navigator = object : CheckoutNavigator {
                override fun onOrderPlaced(orderId: String) {
                    navController.navigate(OrderDestination(orderId)) {
                        popUpTo<CheckoutDestination> { inclusive = true }
                    }
                }
                override fun onLoginRequired() = navController.navigate(LoginDestination)
                override fun onProductClicked(productId: String) =
                    navController.navigate(ProductDestination(productId))
            },
        )
    }
}

Örnek Navigation Compose'un type-safe route'larını kullanıyor; başka bir navigasyon kütüphanesinde de fikir aynı kalır. Now in Android burada biraz farklı bir yol izler: her feature'ı yalnızca navigasyon anahtarlarını içeren bir api ve geri kalan her şeyi içeren bir impl module'üne böler, bir feature'ın impl'i başka bir feature'ın yalnızca api'sine bağlanabilir. Feature sayısı çok arttığında ve route tanımlarını app module'ünde toplamak darboğaz olduğunda bu ayrım mantıklıdır. Daha küçük projelerde app'te bağlamak yeter ve module sayısını ikiye katlamaz.

Ortak veri nerede yaşar

"Sepetteki ürün sayısı" hem ürün detayında hem ana ekranda lazımsa, bu veri feature'a ait değildir. Onu ayrı bir core ya da data module'üne (CartStore, :core:cart) indiririz ve iki feature da oraya bağlanır. Sınırı nereden çizeceğinize karar verirken domain sınırları yazısındaki sorular işe yarar. Dikkat edilecek tek şey, core kümesinin yavaş büyümesi: her yeni core module'ü bütün feature'ların yeniden derlenme sebebidir.

Ekip yapısıyla ilişkisi

Conway 1968'deki makalesinde, bir sistemi tasarlayan organizasyonun kendi iletişim yapısının kopyası olan tasarımlar ürettiğini yazmıştı. Mobilde bunun pratik karşılığı çok somut: ekipleriniz ürün alanlarına göre kurulmuşsa (arama ekibi, ödeme ekibi, hesap ekibi) ama kodunuz katmanlara göre dilimlenmişse, her iş her ekibi aynı dosyalarda buluşturur. Organizasyon şeması bir şey söyler, klasör ağacı başka bir şey, aradaki farkı merge conflict olarak ödersiniz.

Tersi de doğru. Ekipleriniz platform ya da katman bazında kuruluysa (bir "network ekibi", bir "UI ekibi") feature module'leri doğal bir sahip bulamaz. Bu durumda önce ekip yapısını konuşmak gerekir; klasörleri taşımak organizasyon sorununu çözmez.

Feature-first yapıda CODEOWNERS dosyası okunabilir hale gelir:

# .github/CODEOWNERS
/Packages/ShopKit/Sources/SearchFeature/     @shop/search-team
/Packages/ShopKit/Sources/CheckoutFeature/   @shop/payments-team
/Packages/ShopKit/Sources/ProfileFeature/    @shop/account-team
/Packages/ShopKit/Sources/DesignSystem/      @shop/platform-team
/Packages/ShopKit/Sources/Networking/        @shop/platform-team

Bu dosyayı yazamıyorsanız, yani bir dizin için tek bir ekip adı söyleyemiyorsanız, sınır ya kodda ya organizasyonda yanlış yerdedir.

Layer-based'in doğru karar olduğu durumlar

Bunu açık söyleyelim, çünkü feature-first son yıllarda varsayılan öneri haline geldi ve her projeye körlemesine uygulanıyor.

Üç dört kişilik tek bir ekipseniz sahiplik sorununuz yoktur; herkes her dosyanın sahibidir. Ürün gençse ve ekranlar her çeyrekte yeniden şekilleniyorsa, feature sınırlarını bugün çizmek yanlış çizmek demektir. Yanlış çizilmiş bir module sınırını düzeltmek, hiç çizilmemiş bir sınırı çizmekten pahalıdır. Uygulamanın on beş yirmi ekranı varsa ve tam build bir iki dakikada bitiyorsa, module'lere bölmenin getireceği build kazancı ölçülemeyecek kadar küçüktür. Android'in modularization rehberi de aynı uyarıyı yapar: belirleyici etken kod tabanının büyüklüğüdür ve her proje için modularization anlamlı değildir.

Bu durumda önerimiz tek module, içinde katman klasörleri ve bir tek ek kural: Views ve ViewModels altında dosyaları feature adlı alt klasörlerde tutun. Sınırlar belirginleştiğinde taşıma işi klasörü sürüklemekten ibaret olur.

Geçiş: bir seferde tek feature

Büyük bir yeniden yazım önermiyoruz. Layer-first projeden feature-first'e geçiş, ürün geliştirme durmadan, her adımda yayınlanabilir bir uygulamayla yapılabilir. Sıra şöyle:

1. Önce core'u çıkarın. Design system ve networking, bütün feature'ların altında kalacak. Bunlar ayrı module olmadan çıkaracağınız ilk feature eski yapıya bağımlı kalır. Core'a yalnızca gerçekten alan bilgisi taşımayan kodu alın: APIClient evet, OrderEndpoints hayır.

2. En yalıtık feature'ı seçin. Başkalarının en az import ettiği, kendi modeli olan, tek ekibin dokunduğu bir dikey dilim. Çoğu uygulamada bu ayarlar, onboarding ya da bildirimler olur. İlk seferde ödemeyi seçmeyin; ilk taşıma her zaman en pahalı olanıdır, çünkü giriş noktası kalıbını, DI bağlantısını ve test düzenini ilk kez kuruyorsunuz.

3. Dikey dilimi taşıyın. O feature'ın view, view model, repository ve endpoint dosyalarını yeni module'e alın. Models içinden yalnızca bu feature'ın kullandığı tipleri taşıyın. Paylaşılan tipler (Product gibi) için kısa yol çekicidir: tipi public yapıp eski yerinde bırakmak. Bunun yerine feature kendi ihtiyacı kadar alan içeren kendi modelini tanımlasın ve sınırda map etsin.

4. Giriş noktasını daraltın. Taşıdıktan sonra her şeyi internal yapın, yalnızca Entry (ya da Route) ve bağımlılık tipini public bırakın. Derleyicinin şikayet ettiği her yer, feature'ın dışarıya sızmış bir parçasıdır.

5. Kuralı otomatikleştirin. İkinci feature'ı taşımadan önce "feature feature'a bağlanamaz" kuralını CI'a koyun. Basit haliyle:

# iOS: adı Feature ile biten target'ların bağımlılıklarını listele
swift package dump-package \
  | jq -r '.targets[] | select(.name | endswith("Feature"))
           | "\(.name): \([.dependencies[] | (.byName // .target // .product)[0]] | join(", "))"' \
  | grep -E ': .*Feature' && { echo "Feature, feature'a bağlanamaz"; exit 1; } || true

# Android: feature build dosyalarında başka bir feature'a project bağımlılığı var mı
grep -rn 'project(":feature' feature --include=build.gradle.kts \
  && { echo "Feature, feature'a bağlanamaz"; exit 1; } || true

dump-package çıktısının JSON biçimini kendi toolchain sürümünüzde bir kez doğrulayın. Daha sağlam yöntemleri (lint kuralları, dependency analysis plugin'leri, mimari testleri) ayrı yazılarda anlattık.

6. Tekrarlayın, sonra eski katmanları küçültün. Her taşınan feature ile ViewModels, Repositories ve Models küçülür. Sona kalanlar gerçekten paylaşılan şeylerdir; onları ya bir core module'üne ya da en çok kullanan feature'a verirsiniz. Utils için sürpriz genellikle şudur: içindekilerin yarısını tek bir feature kullanıyordur.

İşe yaradığını nasıl anlarsınız

Üç ölçüm yeter ve üçü de git ile build aracınızdan çıkar. Birincisi commit başına dokunulan üst seviye dizin sayısı:

git log --since="3 months ago" --no-merges --pretty=format:'@@%h' --name-only -- Packages/ShopKit/Sources \
  | awk '/^@@/ { if (c) print n, c; c = $0; n = 0; delete seen; next }
         NF    { split($0, p, "/"); if (!(p[4] in seen)) { seen[p[4]] = 1; n++ } }
         END   { if (c) print n, c }' \
  | sort -rn | head -20

(p[4], Packages/ShopKit/Sources/<Module>/... yolundaki module adıdır; kendi dizin derinliğinize göre ayarlayın.) Layer-first yapıda tipik bir iş, katman sayısı kadar dizine dokunur; taşınan feature'larda bu sayı bire ya da ikiye (feature ve app) inmelidir. İnmiyorsa, feature sınırı işin doğal sınırıyla örtüşmüyor demektir.

İkincisi, yukarıdaki en çok değişen dosyalar listesi: ortak dosyalar listenin başından aşağı kaymalı. Üçüncüsü, taşınan bir feature'da tek satır değiştirince yeniden derlenen module sayısı: feature ile app olmalı, başka bir şey olmamalı.

Durmanız gereken yer de belli. Kalan kod küçük ve gerçekten ortaksa, onu zorla feature'lara dağıtmayın. Yüzde seksenlik bir geçiş, bitmemiş bir yüzde yüzlükten iyidir.

Karar tablosu

Durum Öneri Hüküm
Tek ekip (2 ila 5 kişi), genç ürün, sınırlar değişken Tek module, katman klasörleri, içinde feature adlı alt klasörler Layer-based kalın
Tek ekip, ürün oturmuş, 30+ ekran, Models ve Utils şişmiş Tek module içinde feature klasörlerine geçin, core'u module olarak ayırın Klasör seviyesinde feature-first
2+ ekip, ortak dosyalarda düzenli merge conflict Feature module'leri + core module'leri, CODEOWNERS Feature-first, şimdi başlayın
Katman bazlı module'ler var (:data, :domain, :presentation) ve her değişiklik hepsini derletiyor Yeni katman module'ü eklemeyi bırakın, dikey dilimleri tek tek çıkarın Kademeli geçiş
Feature module'leri var ama birbirini import ediyor Çıktı/navigator sözleşmesi, app'te bağlama, CI kuralı Önce bağımlılıkları kesin, sonra yeni feature ekleyin
Tek bir Shared/Common module'ü her şeyi barındırıyor Adı iş söyleyen core module'lerine bölün, alan bilgisi taşıyan kodu feature'a geri verin Çöplüğü module'e terfi ettirmeyin
Ekipler katmana göre kurulu (network ekibi, UI ekibi) Önce ekip yapısını konuşun Kod taşımak tek başına çözmez
Feature sayısı çok, route tanımları app module'ünde darboğaz Feature başına api/impl ayrımı Yalnızca bu noktada değer
Seviye 5 tamamlandıİlerlemen bu tarayıcıda saklanıyor.
Sıradaki: Domain boundary nasıl belirlenir?

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.