← Back to list

Tuist ile Modüler Uygulama Geliştirme

Herkese Merhaba

Engin Gülek · 2026-02-10 13:45 · 0 claps · 17.4 min read
#tuist #swift #viper #modularization #modular-architecture
Open on Medium ↗
Wiki topics: 📱 · Mobile Development 🏛️ · Architecture

Tuist ile Modüler Uygulama Geliştirme

Herkese Merhaba

Daha önce SPM ile modüler uygulama nasıl geliştirilebileceği hakkında bir yazı yazmıştım. Bu yazı iki parttan oluşmaktaydı. Bu yazılar bu link ulaşabilirsiniz.

Şimdi bu işlemin Tuist ile nasıl yapılabileceğine dair sizlerle yeni bir yazı paylaşacağım. Öncelikle Tuist’in bize neler sağladığına dair bilgi vermek isterim.

Tuist Nedir?

Tuist; özellikle büyük ölçekli iOS, macOS, watchOS veya tvOS projelerini yönetmeyi kolaylaştıran açık kaynaklı bir komut satırı aracıdır (CLI).

Peki, bu ne demek oluyor? Xcode projelerimiz aslında arka planda devasa metin dosyalarından (.pbxproj) oluşur. Modüler uygulamalarda modüllerin birbiri ile olan bağlantılarının nasıl kurulduğunu belirleyen yer bu dosyalardır. Proje dosyanızda Git üzerinden bir değişiklik yaptığınızda, ekip arkadaşınız da aynı dosya üzerinde bir değişiklik yaparsa karşımıza Merge Conflict çıkar. Tuist, bu sürecin rahat bir şekilde yönetilmesini sağlar.

Bağımlılık Yönetimi ve Kolaylıkları

Tuist ile bağımlılık yönetimi çok daha kolaydır. Eğer Tuist kullanmadan bir bağımlılık ekleseydik; projemize yeni bir modül (framework) ya da Core ve Network gibi Kitler eklediğimizde yeni bir target oluşturulması gerekirdi. Bağımlılıkların tek tek eklenmesi, ayrıca Link Binary With Libraries ve Embed Frameworks ayarlarının doğru yapılması hayati önem taşır.

  • Link Binary With Libraries: Kodun derlenebilmesi için bağlantı kurmak.
  • Embed Frameworks: Kütüphaneyi uygulama paketine fiziksel olarak dahil etmek.

Bu ayarlardaki eksiklik durumunda uygulama crash alabilmektedir. Tuist, bu ayarları bizim yerimize otomatik yaparak hata payını ortadan kaldırır.

Merge Conflict ve Kod Yönetimi

Diyelim ki Ana Sayfa modülüne siz Detay modülünü eklediniz, ekip arkadaşınız ise Favoriler modülünü ekledi. İkiniz de aynı anda Xcode proje dosyasında değişiklik yaptığınız için bu durum bir merge conflict ortaya çıkaracaktır. Eğer Tuist kullanırsanız, sadece Swift kodlarını birleştirirsiniz; Xcode projesini ise tuist generate diyerek sıfırdan ve hatasız bir şekilde üretirsiniz.

Circular Dependency ve Görselleştirme

Bir de Circular Dependency (Döngüsel Bağımlılık) durumu bulunmaktadır. Örnek vermek gerekirse; A modülünün çalışması için B modülüne, B modülünün çalışması için ise A modülüne ihtiyaç duymasıdır. Bunun sonucunda bellek sızıntıları ve sonsuz döngüler ortaya çıkabilir. Bu durum Dependency Injection ile çözülebilir.

Ancak Tuist kullanılmazsa, dosya dosya dolaşarak “kim kimi import etmiş” diye kontrol edilmesi gerekir. Tuist kullanıldığında ise proje dizininde tuist graph diyerek hangi modülün neyi import ettiğine dair görsel bir harita alabiliriz. Ayrıca bu komut sadece proje genelinde değil, her modül ve her kit içinde çalıştırılabilir.

Evet Tuist’in artıları ve bize modüler proje geliştirirken ne gibi faydaladığına sizlere bilgi verdim. Şimdi iCommunity‘nin Advanced iOS Bootcamp’den öğrendiğim bilgiler ile ICTMDBApp uygulamasını Tuist ile nasıl yaptığıma dair bilgi vereceğim.

ICTMDBApp Uygulamasını Tuist ile Geliştirmek

İlk başta SPM kullanarak her modülün ve her kitin ayrı bir GitHub reposunda geliştirildiği, bunların da tek bir app içerisinde Dependency Injection ile birleştirildiği modüler uygulamaya bu linkten ulaşabilirsiniz. İşte bu app’i Tuist kullanarak nasıl daha iyi yönetilebilir hale getirdiğimden bahsedeceğim.

Tuist projesi oluşturma

Öncelikle bir Tuist projesi nasıl oluşturulur onun hakkında konuşalım. Terminal üzerinde:

mkdir MyAwesomeProject
cd MyAwesomeProject

diyerek proje klasörünü oluşturuyoruz. Daha sonra Tuist’in temel bir şablonunun oluşturulması gerekmektedir. Bu komut; standart bir uygulama yapısı için gerekli olan Project.swift dosyasını ve klasör yapısını hazırlar.

Öncelikle tuist init diyoruz. Yeni bir proje oluşturacağımız için çıkan seçenekten “Create a project” (veya kullandığınız versiyona göre benzeri) seçilmesi gerekmektedir. Şimdi bizden projemizin ismini isteyecek; proje ismimizi gireceğiz. Daha sonra hangi platform için geliştirme yapmak istediğimizi bize soracak, buradan ios seçeceğiz. Bize “Would you like to use server features (e.g. selective testing, previews)? diye soracak. Bu soru, Tuist projenizi kurarken Tuist Cloud (veya yeni adıyla Tuist Server) özelliklerini kullanıp kullanmak istemediğinizi soran bir tercihtir.

Kısaca özeti şu: “Projenizi Tuist’in sunucu tabanlı gelişmiş performans araçlarıyla optimize etmek ister misiniz?” Buna No diyerek geçelim. Artık projemiz oluşmuş durumdadır. Şimdi tuist edit diyerek projemizin yapılandırma dosyalarını (manifest) düzenlemek için özel bir moda geçeceğiz.

Normalde Project.swift gibi dosyalar tek başına birer Swift dosyasıdır. Bunları Xcode ile doğrudan açarsanız, Xcode bunların ne olduğunu anlamaz; dolayısıyla size kod tamamlama (autocompletion) veya hata ayıklama desteği sunmaz. tuist edit komutu, bu manifest dosyalarını geçici bir Xcode projesi içinde açarak size bu desteği sağlar.

Düzenlemeler bittikten sonra tuist generate diyeceğiz. Bu, Tuist’in en kritik ve en çok kullanacağınız komutudur. Kısaca: “Kodla yazdığın proje tanımını, Xcode’un anlayacağı gerçek bir projeye (.xcodeproj) dönüştür” demektir.

Karşımıza böyle bir dosya yapısı çıkacaktır.

tuist edit

tuist edit

Tuist kullanan projelerde bildiğimiz .xcodeproj veya .xcworkspace dosyaları normalde klasörde bulunmaz (veya bulunmamalıdır). Bu dosyaları sizin için Tuist anlık olarak oluşturur.

tuist generate

tuist generate

Tuist, projeyi varsayılan olarak SwiftUI yapısı ile oluşturmaktadır. Biz projemizi UIKit ile yapacağımız için bazı ayarlamalar yapılması gerekmektedir. Bunun için öncelikle tekrardan tuist edit diyerek Project.swift dosyamıza ulaşalım.

Project.swift dosyası, projemizin ana ayarlarını yaptığımız yerdir. Info.plist ayarlarından target’ların yapılandırılmasına ve bağımlılıkların yönetilmesine kadar her şey burada yapılır. Ayrıca şunu unutmamak gerekir: Xcode üzerinden Info.plist üzerinde yaptığınız hiçbir değişiklik kalıcı olmaz; çünkü tuist generate komutu her zaman Project.swift içerisindeki tanımlamaları esas alır ve dosyalarımızı baştan yaratır.

let project = Project(
    name: "MyAwsomeProject",
    targets: [
        .target(
            name: "MyAwsomeProject",
            destinations: .iOS,
            product: .app,
            bundleId: "dev.tuist.MyAwsomeProject",
            infoPlist: .extendingDefault(
                with: [
                    "UILaunchScreen": [
                        "UIColorName": "",
                        "UIImageName": "",
                    ],
                ]
            ),
            buildableFolders: [
                "MyAwsomeProject/Sources",
                "MyAwsomeProject/Resources",
            ],
            dependencies: []
        ),
        .target(
            name: "MyAwsomeProjectTests",
            destinations: .iOS,
            product: .unitTests,
            bundleId: "dev.tuist.MyAwsomeProjectTests",
            infoPlist: .default,
            buildableFolders: [
                "MyAwsomeProject/Tests"
            ],
            dependencies: [.target(name: "MyAwsomeProject")]
        ),
    ]
)

Project Nesnesi

let project = Project(name: "MyAwsomeProject", ...)

Bu dosya projenizin ana çatısıdır ve Xcode’daki .xcodeproj dosyasına karşılık gelir. Projenin adını ve içinde barındıracağı hedefleri (targets) burada tanımlarız.

Ana Uygulama Hedefi (App Target)

İlk target bloğu, kullanıcıların telefonuna yüklenecek olan asıl uygulamayı tanımlar.

  • name: Hedefin adı. Xcode’da sol üstteki “Scheme” listesinde bu ismi görürsünüz.
  • destinations: Uygulamanın hangi cihazlarda çalışacağını belirler. .iOS seçildiği için bu bir iPhone/iPad uygulamasıdır.
  • product: Bu hedefin çıktısı nedir? .app seçeneği, bunun son kullanıcıya ulaşacak bir “Uygulama” olduğunu belirtir.
  • bundleId: Uygulamanın dünyadaki eşsiz kimliğidir. App Store'da bu kimlik kullanılır.
  • infoPlist: Info.plist ayarlarını yönetir.
  • .extendingDefault: Tuist’in sunduğu varsayılan ayarlara ekleme yapacağınızı söyler.
  • UILaunchScreen: Uygulama açılırken görünen açılış ekranı (Launch Screen) ayarlarıdır. Şu an içi boş bırakılmış, yani varsayılan bir renk veya resim atanmamış.
  • dependencies: Uygulamanın çalışması için gereken dış kütüphaneler veya diğer modüller. Şu an içi boştur.

Test Hedefi (Unit Tests Target)

İkinci .target bloğu, uygulamanın kodlarını test etmek için kullanılan bir yapıdır.

  • product: .unitTests: Bu hedefin bir uygulama değil, bir test paketi olduğunu belirtir.
  • buildableFolders: Test kodlarının MyAwsomeProject/Tests klasörü altında olduğunu belirtir.
  • dependencies: İşte burası kritik! Testlerin çalışabilmesi için asıl uygulamadaki kodlara erişmesi gerekir. Bu yüzden .target(name: “MyAwsomeProject”) bağımlılığı eklenmiştir.

UIKit ile çalışacağımızı söylemiştik. Bunun için infoplist şu yapıda olacaktır.

infoPlist: .extendingDefault(
                with: [
                    "UIApplicationSceneManifest": [
                        "UIApplicationSupportsMultipleScenes": false,
                        "UISceneConfigurations": [
                            "UIWindowSceneSessionRoleApplication": [
                                [
                                    "UISceneConfigurationName": "Default Configuration",
                                    "UISceneDelegateClassName": "$(PRODUCT_MODULE_NAME).SceneDelegate"
                                ]
                            ]
                        ]
                    ],
                    "UILaunchStoryboardName": "LaunchScreen" 
                ]
            )

Ayrıca tuist generate demeden önce, proje içerisindeki Sources klasöründe AppDelegate ve SceneDelegate sınıflarının manuel olarak oluşturulması gerekmektedir.

import UIKit
@main
class AppDelegate: UIResponder, UIApplicationDelegate {
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
        // Override point for customization after application launch.
        return true
    }
    // MARK: UISceneSession Lifecycle
    func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration {
        // Called when a new scene session is being created.
        // Use this method to select a configuration to create the new scene with.
        return UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role)
    }
    func application(_ application: UIApplication, didDiscardSceneSessions sceneSessions: Set<UISceneSession>) {

        // Called when the user discards a scene session.
        // If any sessions were discarded while the application was not running, this will be called shortly after application:didFinishLaunchingWithOptions.
        // Use this method to release any resources that were specific to the discarded scenes, as they will not return.
    }
}

Scene Delegeye yapısını bağımlılıklarını tanımlarken güncelleyeceğiz.

class SceneDelegate: UIResponder, UIWindowSceneDelegate {

    var window: UIWindow?
}

LaunchScreen için ise Resources içerisinde “New File from Template” diyerek bir LaunchScreen.storyboard oluşturulmalıdır. Son olarak Project.swift içerisinde (varsa) buildableFolders kaldırılıp sources ve resourceskısımları şu şekilde güncellenmelidir:

sources: ["ICTMDBApp/Sources/**"],
 resources: ["ICTMDBApp/Resources/**"],

ICTMDBApp’in Tuist yapısı 2 Kit den ve 3 Modülden oluşmaktadır. İlk önce Kitlerde nasıl bir yapı kullandığıma dair bilgi vereceğim.

Kitler

Projemizde 2 adet kitimiz bulunduğuna dair bilgi vermiştim. Bunlar CoreKit ve NetworkManagerKit’dir.

CoreKit

İçerisinde tekrarlı bir şekilde kullanılan UI bileşenlerinin, Assetlerin ve Foundation yapılarının bulunduğu kittir. Ayrıca bu belirttiğim yapıları ayrı target’lara ayırdım. Bu sayede, örnek olarak VIPER mimarisinin Presenter katmanının UI yapılarını import etmesinden (ve dolayısıyla gereksiz UI bağımlılığından) kaçınılması sağlandı.

Nasıl Oluşturdum? İlk önce tuist edit diyoruz, daha sonra Manifest içerisindeki proje dizininde Kits adında bir klasör oluşturuyoruz. Bunun içerisine de CoreKit adında bir klasör açıyoruz. Bu klasörün içerisinde ise projemizin CoreKit yapısına dair tüm işlemlerimizi gerçekleştireceğimiz Project.swift dosyasını oluşturuyoruz.

import ProjectDescription
let coreKit = Project(
    name: "CoreKit",
    packages: [
        .package(url: "https://github.com/onevcat/Kingfisher.git", from: "8.1.0")
    ],
    targets: [
        // 1. ASSETS:
        .target(
            name: "CoreKitAssets",
            destinations: .iOS,
            product: .staticFramework,
            bundleId: "com.engingulek.CoreKitAssets",
            deploymentTargets: .iOS("26.0"),
            sources: [],
            resources: ["Targets/CoreKitAssets/Resources/**"]
        ),
        // 2. FOUNDATION:
        .target(
            name: "CoreKitFoundation",
            destinations: .iOS,
            product: .staticFramework,
            bundleId: "com.engingulek.CoreKitFoundation",
            deploymentTargets: .iOS("26.0"),
            sources: ["Targets/CoreKitFoundation/Sources/**"],
            dependencies: [
                .target(name: "CoreKitAssets")
            ]
        ),
        // 3. UI (UIKit):
        .target(
            name: "CoreKitUI",
            destinations: .iOS,
            product: .staticFramework,
            bundleId: "com.engingulek.CoreKitUI",
            deploymentTargets: .iOS("26.0"),
            sources: ["Targets/CoreKitUI/Sources/**"],
            dependencies: [
                .target(name: "CoreKitAssets"),
                .package(product: "Kingfisher")
            ]
        )
    ]
)

CoreKit’e ait olan Project.swift dosyasının yapısı içerisinde yer alan packages, projenize dahil etmek istediğiniz dış kütüphanelerin (third-party libraries) listesidir. Buradaki Kingfisher, iOS dünyasında internetten resim indirmek ve önbelleğe (cache) almak için kullanılan çok popüler bir kütüphanedir.

Projemizde CoreKit’i CoreKitAssets, CoreKitFoundation ve CoreKitUI gibi küçük parçalara (Target) ayırdık. Proje büyüdükçe her şeyi tek bir devasa kutuya koymak yerine, onları işlevlerine göre odalara ayırmış olduk.

Product Tanımlaması: product değerinin .staticFramework olarak tanımlanma nedeni; hızlı açılış, kolay kaynak yönetimi ve kısa derleme süreleridir. Modüler projelerde Static Framework kullanmak en mantıklı ve sektör standardı olan yoldur.

Dependencies: dependencies ile de bu target’lar için gerekli olan bağımlılıkları eklemiş olduk. Örneğin; Kingfisher kütüphanesini sadece CoreKitUI target’ı kullanacak şekilde yapılandırdık. Eğer bir dış paket yerine kendi içimizdeki bir target’ı bağımlılık olarak eklemek isteseydik, bunu .target(name: “CoreKitAssets”) şeklinde yapacaktık.

Sadece CoreKitUI içinde bir değişiklik yaparsanız, Xcode diğer Target’ların değişmediğini anlar ve onları önbellekten (cache) kullanır. Bu da özellikle büyük projelerde size her gün onlarca dakika kazandırır.

Her Target’ın kendine has bir görevi vardır:

  • CoreKitAssets: Sadece resimler, renkler ve yazı tipleri. Kod içermez.
  • CoreKitFoundation: İş mantığı (logic) veya yardımcı araçlar (extensions). UI ile işi yoktur.
  • CoreKitUI: Sadece tasarım bileşenleri (custom butonlar, viewlar). Bu sayede, Foundation katmanında çalışan bir geliştirici yanlışlıkla UI kodlarını bozamaz.

Şimdi artık CoreKit içerisinde her target’a ait olan tasarımları, logic’leri ve yardımcı araçları ekleyebiliriz.

Resources ve Sources yollarını Targets/CoreKitAssets/ şeklinde belirttik. Bu dosyaların fiziksel olarak o klasör yapısında olduğundan emin olunması gerekmektedir. Eğer klasör yapısı Project.swift içindeki yollarla eşleşmezse Tuist hata verecektir.

İlk önce sadece CoreKit içerisinde çalışmak için projenin terminal dizininde şu komutların çalıştırılması gereklidir:

tuist clean
 tuist install
 tuist generate --path Kits/CoreKit

tuist clean:

Ne yapar: Tuist’in daha önceki işlemlerden kalan tüm geçici dosyalarını ve önbelleğini (cache) temizler.

  • Neleri siler: Oluşturulan Xcode projelerini, workspace dosyalarını ve derleme sırasında oluşturulan geçici verileri.
  • Ne zaman kullanılır: “Az önce çalışıyordu, şimdi neden hata veriyor?” dediğin durumlarda veya proje yapısında köklü bir değişiklik yaptığında her şeye sıfırdan başlamak için kullanılır.

tuist install:

Ne yapar: Project.swift dosyasında packages kısmında belirttiğin dış bağımlılıkları (örneğin senin Kingfisher kütüphaneni) indirir ve projeye hazırlar.

  • Mantığı: Swift Package Manager (SPM) gibi çalışır. Tuist’in modern sürümlerinde dış kütüphaneleri yönetmek için bu komutu çalıştırman gerekir.
  • Ne zaman kullanılır: Projeye yeni bir kütüphane eklediğinde veya başka bir bilgisayarda projeyi ilk kez açtığında çalıştırılır.

tuist generate — path Kits/CoreKit:

Ne yapar: Belirttiğin dizindeki (Kits/CoreKit) yapılandırma dosyalarını okuyarak gerçek bir Xcode Projesi (.xcodeproj) oluşturur.

  • **--path parametresi: **Tuist’e “Tüm sistemi değil, sadece şu klasörün içindeki Project.swift dosyasını baz alarak projeyi üret” talimatı verir.
  • Sonuç: Kits/CoreKit klasörünün içinde Xcode ile açıp kod yazabileceğin bir proje dosyası belirir.

Ortaya çıkacak olan Xcode Projesi (.xcodeproj), gerekli logic’leri ve extension’ları ekledikten sonra aşağıdaki gibi görünecektir:

NetworkManagerKit

Bu kit içerisinde network bağlantısı için gerekli işlemleri gerçekleştireceğiz. Öncelikle terminalde proje dizininde tuist edit diyoruz. Daha sonra Kits klasörü içerisinde NetworkManagerKit klasörü oluşturarak içerisinde Project.swift dosyası oluşturacağız.

import Foundation
import ProjectDescription
let networkManagerKit = Project(
    name: "NetworkManagerKit",
    packages: [.package(url: "https://github.com/Alamofire/Alamofire.git", from: "5.10.0")],
    targets: [
        .target(
            name: "NetworkManagerKit",
            destinations: .iOS,
            product: .staticFramework,
            bundleId: "dev.tuist.NetworkManagerKit",
            sources: ["Sources/**"],
            resources: ["Resources/**"],
            dependencies: [
                .package(product: "Alamofire")
            ]

        ),

    ]
)

CoreKit’te olduğu gibi içerisinde packages kısmında third-party kütüphanesi olarak Alamofire kullanıyoruz. Ayrıca dependencies kullanarak Alamofire paketini NetworkManagerKit target’ına ekleyerek, bu target’ın kullanımına açmış olduk. Yapılandırmayı tamamladıktan sonra, sadece bu modülü ayağa kaldırmak için aşağıdaki komutları terminalde çalıştıracağız:

tuist clean
tuist install
tuist generate --path Kits/NetworkManagerKit

Bu komutları çalıştırdıktan sonra açılan .xcodeproj içerisinde gerekli sınıfları (NetworkManager, Endpoint vb.) ve tanımlamaları yaptıktan sonra projenin son görünümü modüler bir yapıda olacaktır.

NetworkManagerKit’i de Static Framework yaparak uygulamanın açılış hızını korumuş olduk. Ayrıca eğer bir modül içerisinde Alamofire değil de URLSession kullanmak istersek, URLSession için ayrı bir target açarak kolaylıkla kullanabileceğiz.

Bu yaklaşımın teknik avantajı şudur: Üst katmandaki modüller (örneğin HomeModule), verinin nasıl çekildiğini (Alamofire mı yoksa URLSession mı) bilmezler. Onlar sadece bir NetworkManager protokolüne bakarlar. Sen alt tarafta kitleri değiştirdiğinde, üst katmandaki UI ve Business Logic kodlarına dokunmana bile gerek kalmaz.

Modüller

Evet, Kitler ile işlerimiz bitti. Şimdi sırada modüllerimizi oluşturma aşamasındayız.

Projemizde 3 adet modülümüz bulunmaktadır. Bunlar: ICTMDBHomeModule, ICTMDBDetailModule ve ICTMDBAllListModule’dur. Modüllerin oluşturulma mantığı birbirine benzer olduğu için sadece ICTMDBHomeModule oluşturulmasını detaylandıracağım. Diğer modüllerin ise sadece navigasyon yönetimi için gerekli olan kısımlarına değineceğim.

Modüllerin her üçü de temel olarak iki target’tan oluşacak; bunlara ek olarak test yazılacak ise bir de Unit Test target’ı eklenmesi gerekmektedir.

ICTMDBHomeModule Oluşturulması

İlk önce ICTMDBHomeModule’un Project.swift dosyasını oluşturalım. Proje dizininde terminale tuist edit diyoruz. Daha sonra Manifest klasörü içerisinde bir Modules klasörü, onun içinde de ICTMDBHomeModule klasörü oluşturuyoruz. Bu klasörün içerisinde Project.swift dosyamızı hazırlıyoruz. Dosyamızın yapısı aşağıdaki gibi olacak:

import ProjectDescription
let homeModule = Project(
    name: "ICTMDBHomeModule",
    packages: [
        .package(url: "https://github.com/engingulek/GenericCollectionViewKit.git", from: "0.0.7"),
        .package(url: "https://github.com/SnapKit/SnapKit.git", from: "5.0.1"),
        .package(url: "https://github.com/engingulek/HPDependencyKit", from: "1.0.0")
    ],
    targets: [
        .target(
            name: "ICTMDBHomeModule",
            destinations: .iOS,
            product: .staticFramework,
            bundleId: "com.engingulek.ICTMDBHomeModule",
            deploymentTargets: .iOS("26.0"),
            infoPlist: .default,
            sources: ["Sources/**"],
            resources: [],
            dependencies: [
                .target(name: "HomeModuleProtocol"),
                .project(target: "NetworkManagerKit", path: .relativeToRoot("Kits/NetworkManagerKit")),
                .project(target: "CoreKitFoundation", path: .relativeToRoot("Kits/CoreKit")),
                .project(target: "CoreKitUI", path: .relativeToRoot("Kits/CoreKit")),
                .package(product: "GenericCollectionViewKit"),
                .project(target: "AllListModuleProtocol", path: .relativeToRoot("Modules/ICTMDBAllListModule")),
                .project(target: "DetailModuleProtocol", path: .relativeToRoot("Modules/ICTMDBDetailModule")),
                .package(product: "SnapKit"),
                .package(product: "DependencyKit")
            ]
        ),

            .target(name:"HomeModuleProtocol",
                    destinations: .iOS,
                    product: .staticFramework,
                    bundleId: "com.engingulek.HomeModuleProtocol",
                    sources: "ProtocolSource/**"

                   ),
        .target(
            name: "HomeModuleTests",
            destinations: .iOS,
            product: .unitTests,
            bundleId: "dev.tuist.ICTMDBAppTests",
            infoPlist: .default,
            sources: ["Tests/**"],
            dependencies: [.target(name: "ICTMDBHomeModule")]
        )

    ]
)

Görüldüğü üzere burada üç adet target bulunmaktadır. Şimdi Project.swift içerisinde yer alan bu tanımlamaları ve neden iki ana target (Module ve Protocol) kullandığımızı açıklayalım.

Packages (Dış Bağımlılıklar)

  • GenericCollectionViewKit: Bu benim kendi yazdığım bir framework’tür. CollectionView yapısını generic bir mimari ile daha kolay ve tekrar kullanılabilir hale getirmeyi sağlamaktadır. Bu kütüphane hakkında daha fazla bilgiye bu link’den ulaşabilirsiniz.
  • HPDependencyKit: Yine kendi yazdığım bir kütüphanedir. Modüller arası bağımlılık yönetimini (Dependency Injection) profesyonelce çözmek için kullanılmaktadır.
  • SnapKit: iOS geliştirmede Auto Layout (arayüz kısıtlamaları) kodla yazılırken okunabilirliği ve yazım hızını artırmak için standart haline gelmiş bir kütüphanedir.

Targets (Hedefler):

Modülümüzü üç ana parçaya (target) böldük. Bu yaklaşım, projenin test edilebilirliğini ve derleme hızını (build time) artırmayı sağlamaktadır.

  1. ICTMDBHomeModule (Ana Uygulama Mantığı):

Bu target bizim ana Feature modülümüzdür. Ana ekranın tüm UI kodları, VIPER bileşenleri ve iş mantığı burada yer alır.

  • Neden staticFramework? Uygulama açılış hızını (launch time) optimize etmek ve kaynak yönetimini kolaylaştırmak için tercih edilmiştir.
  • Dependencies: Bu modülün çalışması için ihtiyaç duyduğu alt modülleri (NetworkManagerKit, CoreKit vb.) ve dış kütüphaneleri burada belirtiyoruz.
.project(target: "AllListModuleProtocol", path: .relativeToRoot("Modules/ICTMDBAllListModule")),

Ayırca buradaki project içerisindeki target kullanılacak target’ın ismini path ise bulunduğu yerin yolunu belirtmektedir.

Dependencies içerisinde yer alan AllListModuleProtocol ve DetailModuleProtocol target’ları, bir sonraki maddede anlatacağım HomeModuleProtocol yapısında olduğu gibi modüller arası iletişim için oluşturulmuştur.

import Foundation
// MARK: - All List Module Protocol
/// Represents the type of list to be displayed in the All List module.
/// For example, popular TV shows or shows airing today.
public enum AllListType {
    case popular
    case airingToday
}
public protocol AllListModuleProtocol {

    ///Creates and returns a configured All List module for the given type.
    /// - Parameter type: The list type (e.g., `.popular` or `.airingToday`).
    /// - Returns: A `UIViewController` configured to display the corresponding list.
    func createAllListModule(type: AllListType) -> UIViewController
}
import Foundation
import UIKit
// MARK: - TV Show Detail Module Protocol
/// A protocol responsible for creating and returning
/// the detail screen of a selected TV show.
public protocol DetailModuleProtocol {

    /// Creates and returns the detail view controller for a TV show.
    /// - Parameter id: The unique identifier of the TV show (optional).
    /// - Returns: A configured `UIViewController` displaying the TV show details.
    func createTvShowDetailModule(id: Int?) -> UIViewController
}

2. HomeModuleProtocol (İletişim Arayüzü)

İşte modüler mimarimizin kalbi tam olarak burasıdır.

Neden Ayrı Bir Target? Diğer modüller (örneğin App modülü), ICTMDBHomeModule’ün tüm detaylarını (içindeki private sınıfları, ağır görselleri vb.) bilmek zorunda değildir. Sadece “Ben bir Home ekranı oluşturmak istiyorum” demesi yeterlidir.

Dependency Inversion (Bağımlılıkların Tersine Çevrilmesi): Modüller birbirine göbekten bağlanmak yerine (Tight Coupling), bu protokoller üzerinden konuşur. Bu sayede HomeModule değişse bile protokol sabit kaldığı sürece diğer modüller bu değişiklikten etkilenmez ve bozulmaz.

Bu target içerisinde sadece bir adet protocol bulunur. O da şöyledir:

import Foundation
import UIKit
// MARK: - Home Module Protocol
/// Defines the contract for creating the Home module's view controller.
/// Used to assemble and return the main screen of the application.
public protocol HomeModuleProtocol {

    /// Creates and returns the Home module's main view controller.
    /// - Returns: A configured `UIViewController` representing the Home screen.
    func createHomeModule() -> UIViewController
}

Bu Sayede Ne Sağlandı?

  • Gizlilik: Diğer modüllerin, Home modülünün içindeki sınıfların ismini bilmesine gerek kalmaz.
  • Basitlik: Sadece createHomeModule() fonksiyonunu çağırarak tüm modülü ayağa kaldırabilirler.
  • Esneklik: Yarın bir gün Home ekranını tamamen silip SwiftUI ile baştan yazsanız bile, aynı protokolü uyguladığınız sürece uygulamanın geri kalanı bu değişimden etkilenmez.

3. HomeModuleTests (Test Katmanı)

Yazdığın kodun doğruluğunu kontrol etmek için kullandığımız alandır. Sadece ana modüle (ICTMDBHomeModule) bağlanır ve bu modül için gerekli olan Unit Testlerin yazıldığı yerdir.

Protokoller Nerede ve Nasıl Kullanılıyor?

Peki bu HomeModuleProtocol, AllListModuleProtocol ve DetailModuleProtocol tam olarak nerede kullanılacak? Her modülün kendi protokol yapısı, o modülün “kurulmasını” (assembly) sağlamaktadır. Örnek olarak ICTMDBHomeModule’ü ele alalım. Bu modül içerisinde yer alan ICTMDBHomeModule.swift dosyası aşağıdaki gibi bir yapıya sahip olacaktır:

import Foundation
import UIKit
import NetworkManagerKit
import HomeModuleProtocol
public class ICTMDBHomeModule : @MainActor HomeModuleProtocol {
    public init () {}

   @MainActor public func createHomeModule() -> UIViewController {
        let viewController = HomeViewController()

        let router = HomeRouter()
        let interactor = HomeInteractor(network: NetworkManager())

        let presenter : any ViewToPresenterHomeProtocol & InteractorToPresenterHomeProtocol
        = HomePresenter(view: viewController, interactor: interactor,router: router)
        viewController.presenter = presenter
        interactor.presenter = presenter
        return viewController
    }



}

Bu yapı sayesinde dışarıya sadece yapılandırılmış bir UIViewController gönderilir. Modül içerisinde yer alan diğer mimari katmanların (Presenter, Interactor, Router) ve özel tanımlamaların diğer modüller tarafından bilinmesine gerek kalmaz.

Import edilen diğer iki protokol hedefi (AllListModuleProtocol ve DetailModuleProtocol) ise navigasyon işlemleri için kullanılacaktır. HomeRouter içerisinde bu protokolleri kullanarak başka bir modüle nasıl geçiş yaptığımıza bakalım:

import UIKit
import DependencyKit
import AllListModuleProtocol
import DetailModuleProtocol
/// `HomeRouter` is responsible for navigation in the Home module.
public class HomeRouter : PresenterToRouterHomeProtocol {

    // MARK: - Navigation to All List Page

    /// Navigates to the All List screen based on the selected section type.
    /// - Parameters:
    ///   - view: The current view conforming to `PresenterToViewHomeProtocol`.
    ///   - type: The section type (e.g., popular or airingToday).

    func toAllListPage(view: PresenterToViewHomeProtocol?, type: SectionType) {
        // Determine the list type based on section
        let listType: AllListType = type == .popular ? .popular : .airingToday

        // Resolve AllList module dependency from DependencyRegister
        let allListModule = DependencyRegister.shared.resolve(AllListModuleProtocol.self)

        // Create the AllList view controller using module
        let allListViewController = allListModule.createAllListModule(type: listType)

        view?.pushViewControllerAble(allListViewController, animated: true)
    }

    // MARK: - Navigation to Detail Page

    /// Navigates to the TV show detail page.
    /// - Parameters:
    ///   - view: The current view conforming to `PresenterToViewHomeProtocol`.
    ///   - id: The TV show ID to display details for.
func toDetailPage(view: (any PresenterToViewHomeProtocol)?, id: Int?) {
        // Resolve Detail module dependency from DependencyRegister
        let detailModule = DependencyRegister.shared.resolve(DetailModuleProtocol.self)
        // Create the Detail view controller using module
        let detailViewController = detailModule.createTvShowDetailModule(id: id)

        view?.pushViewControllerAble(detailViewController, animated: true)
    }
}

DependencyKit bilgilere ve kullanımını öğrenmek için bu link’i ziyaret edebilirsiniz.

Modülü Ayağa Kaldırma

Modülün yapılandırmasını tamamladıktan sonra terminalde şu komutları çalıştırıyoruz:

tuist clean
 tuist install
 tuist generate --path Modules/ICTMDBHomeModule

Bu komutları çalıştırdıktan sonra açılan .xcodeproj içerisinde gerekli sınıfları ve dosyaları oluşturduğumuzda , modülümüz izole bir şekilde çalışmaya hazır hale gelir.

Diğer iki modül (ICTMDBAllListModule ve ICTMDBDetailModule) de yine aynı bu mantıkla oluşturulacaktır.Aşağıda bu iki modülün Project.swift dosyaları yer almaktadır.

Yazımızın başında Tuist’in bize sağladığı avantajlardan bir tanesininde bağımlılıkları görsel bir şekilde bize göstermesinden bahsetmiştik. Şimdi bu işlemi ICTMDBHomeModule için gerçekleştirelim.

Aşağıda yer alan komutu proje dizini içerisinde çalıştırmamız gerekmektedir.

tuist graph ICTMDBHomeModule

Bu komut sonucunda bize görsel olarak bir harita projemizin ana klasöründe graph.png şeklinde oluşturulacaktır. Her tuist graph …. komutunda yeni bir tane harita oluşturulup diğeri silinecektir.

ICTMDBHomeModule Graph

ICTMDBHomeModule Graph

Harita görüldüğü üzere diğer iki modül ile sadece protocol’leri ile bağımlılık kurulması sağlanmıştır. Ayrıca third-party ‘lerin görselleri farklılaştırılarak daha iyi bir görünüm sağlanmıştır.

ICTMDBAllListModule:

import ProjectDescription
let allListModule = Project(
    name: "ICTMDBAllListModule",
    packages: [
        .package(url: "https://github.com/engingulek/GenericCollectionViewKit.git", from: "0.0.7"),
        .package(url: "https://github.com/SnapKit/SnapKit.git", from: "5.0.1"),
        .package(url: "https://github.com/engingulek/HPDependencyKit", from: "1.0.0")
    ],
    targets: [
        .target(
            name: "ICTMDBAllListModule",
            destinations: .iOS,
            product: .staticFramework,
            bundleId: "com.engingulek.ICTMDBAllListModule",
            deploymentTargets: .iOS("26.0"),
            infoPlist: .default,
            sources: ["Sources/**"],
            resources: [],
            dependencies: [
                .target(name: "AllListModuleProtocol"),
                .project(target: "NetworkManagerKit", path: .relativeToRoot("Kits/NetworkManagerKit")),
                .project(target: "CoreKitFoundation", path: .relativeToRoot("Kits/CoreKit")),
                .project(target: "CoreKitUI", path: .relativeToRoot("Kits/CoreKit")),
                .project(target: "DetailModuleProtocol", path: .relativeToRoot("Modules/ICTMDBDetailModule")),
                .package(product: "GenericCollectionViewKit"),
                .package(product: "SnapKit"),
                .package(product: "DependencyKit")
            ]
        ),

            .target(name:"AllListModuleProtocol",
                    destinations: .iOS,
                    product: .staticFramework,
                    bundleId: "com.engingulek.AllListModuleProtocol",
                    sources: "ProtocolSource/**"

                   ),

    ]
)

ICTMDBDetailModule:

import ProjectDescription
let detailModule = Project(
    name: "ICTMDBDetailModule",
    packages: [
        .package(url: "https://github.com/engingulek/GenericCollectionViewKit.git", from: "0.0.7"),
        .package(url: "https://github.com/SnapKit/SnapKit.git", from: "5.0.1")
    ],
    targets: [
        .target(
            name: "ICTMDBDetailModule",
            destinations: .iOS,
            product: .staticFramework,
            bundleId: "com.engingulek.ICTMDBDetailModule",
            deploymentTargets: .iOS("26.0"),
            infoPlist: .default,
            sources: ["Sources/**"],
            resources: [],
            dependencies: [
                .target(name: "DetailModuleProtocol"),
                .project(target: "NetworkManagerKit", path: .relativeToRoot("Kits/NetworkManagerKit")),
                .project(target: "CoreKitFoundation", path: .relativeToRoot("Kits/CoreKit")),
                .project(target: "CoreKitUI", path: .relativeToRoot("Kits/CoreKit")),
                .package(product: "GenericCollectionViewKit"),
                .package(product: "SnapKit")
            ]
        ),

            .target(name:"DetailModuleProtocol",
                    destinations: .iOS,
                    product: .staticFramework,
                    bundleId: "com.engingulek.DetailModuleProtocol",
                    sources: "ProtocolSource/**"

                   ),

    ]
)

Ana Projenin Yapılandırılması (ICTMDBApp)

Şimdi en son olarak, tüm bu hazırladığımız modüllerin ana proje içerisinde bağımlılıklarının (dependencies) tanımlanması gereklidir.

Hatırlarsanız Tuist projesi oluşturduğumuzda, Tuist bize ana app için bir Project.swift vermişti. Onun üzerinde UIKit için bazı güncellemeler yapmıştık. Şimdi bu Project.swift içerisine oluşturduğumuz tüm modülleri ekleyeceğiz:

let project = Project(
    name: "ICTMDBApp",
    packages: [
        .package(url: "https://github.com/engingulek/HPDependencyKit", from: "1.0.0")
    ],
    targets: [
        .target(
            name: "ICTMDBApp",
            destinations: .iOS,
            product: .app,
            bundleId: "dev.tuist.ICTMDBApp",
            infoPlist: .extendingDefault(
                with: [
                    "UIApplicationSceneManifest": [
                        "UIApplicationSupportsMultipleScenes": false,
                        "UISceneConfigurations": [
                            "UIWindowSceneSessionRoleApplication": [
                                [
                                    "UISceneConfigurationName": "Default Configuration",
                                    "UISceneDelegateClassName": "$(PRODUCT_MODULE_NAME).SceneDelegate"
                                ]
                            ]
                        ]
                    ],
                    "UILaunchStoryboardName": "LaunchScreen" 
                ]
            ),
            sources: ["ICTMDBApp/Sources/**"],
            resources: ["ICTMDBApp/Resources/**"],
            dependencies: [
                .package(product: "DependencyKit"),
                .project(target: "HomeModuleProtocol", path: .relativeToRoot("Modules/ICTMDBHomeModule")),
                .project(target: "ICTMDBHomeModule", path: .relativeToRoot("Modules/ICTMDBHomeModule")),

                .project(target: "ICTMDBAllListModule", path: .relativeToRoot("Modules/ICTMDBAllListModule")),
                .project(target: "AllListModuleProtocol", path: .relativeToRoot("Modules/ICTMDBAllListModule")),

                .project(target: "ICTMDBDetailModule", path: .relativeToRoot("Modules/ICTMDBDetailModule")),
                .project(target: "DetailModuleProtocol", path: .relativeToRoot("Modules/ICTMDBDetailModule")),

            ]
        ),
    ]
)

Yukarıda görülebileceği gibi hem module target’ları hem de protocol target’ları ana projeye eklenmiştir. Bunun çok kritik bir nedeni var: Normalde bir modül başka bir modülle konuşurken birbirlerine bağımlı olmamaları (tight coupling’den kaçınmak) için sadece Protocol’ü ekleriz. Ancak ICTMDBApp (yani ana proje), tüm parçaların birleştiği, hangi protokolün hangi sınıfa karşılık geldiğini bilen bir “Orkestra Şefi” görevindedir. Bu yüzden tüm somut sınıfların (implementation) ana app tarafından bilinmesi gerekir.

Bağımlılıkların Kaydedilmesi (Dependency Registration)

Bu modüllerin bağımlılıklarının yönetilmesini (hangi protokol hangi modülü temsil ediyor) SceneDelegate.swift içerisinde, uygulama ayağa kalkarken yapacağız.

import UIKit
import DependencyKit
import HomeModuleProtocol
import ICTMDBHomeModule
import DetailModuleProtocol
import ICTMDBDetailModule
import AllListModuleProtocol
import ICTMDBAllListModule
class SceneDelegate: UIResponder, UIWindowSceneDelegate {

    var window: UIWindow?
    let container = DependencyRegister.shared.container


    func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {

        guard let windowScene = (scene as? UIWindowScene) else {return }
        window = UIWindow(frame: windowScene.coordinateSpace.bounds)
        window?.windowScene = windowScene


        container.register(HomeModuleProtocol.self) { _ in
            ICTMDBHomeModule()
        }

        container.register(AllListModuleProtocol.self) { _ in
            ICTMDBAllListModule()
        }

        container.register(DetailModuleProtocol.self) { _ in
            ICTMDBDetailModule()
        }

        let homeModule: HomeModuleProtocol = DependencyRegister.shared.resolve(HomeModuleProtocol.self)
        let homeViewController = homeModule.createHomeModule()

        window?.rootViewController =  UINavigationController(rootViewController: homeViewController)
        window?.makeKeyAndVisible()
    }
}

“Orkestra Şefi” Görevi Tam Olarak Neyi Sağlar?

Ana uygulamamızın neden hem protokollere hem de somut modüllere ihtiyaç duyduğunu şu maddelerle açıklayabiliriz:

1. Register (Kayıt) İşlemi İçin Gereklilik

Bağımlılık yönetimi yaparken aşağıdaki gibi bir yapı kullanıyoruz ve bu işlem için her iki tarafa da ihtiyacımız vardır:

container.register(HomeModuleProtocol.self) { _ in
    ICTMDBHomeModule() 
}
  • HomeModuleProtocol: “Kutunun üzerine ne yazacağız?” (Tip belirleme).
  • ICTMDBHomeModule: “Kutunun içine hangi somut nesneyi koyacağız?” (Nesne üretimi).

Eğer ana projene ICTMDBHomeModule’ü dahil etmezsen, container.register satırında “Böyle bir sınıf bulamadım” hatası alırsın. Çünkü protokol bir sözdür; ancak o sözü yerine getirecek gerçek sınıf (Implementation) ICTMDBHomeModule içindedir.

2. Dependency Injection (Bağımlılık Enjeksiyonu)

Senin yapında SceneDelegate, uygulamanın Composition Root (Bileşim Kökü) noktasıdır. Tüm modülleri burada ayağa kaldırır ve birbirine bağlarız.

  • Protokolleri neden ekliyoruz? Tip güvenliği sağlamak ve resolve ederken hangi arayüzü (interface) istediğimizi belirtmek için.
  • Target’ları (Implementation) neden ekliyoruz? Uygulamanın çalışabilmesi için o kodların derlenip bir binary oluşturması gerekmektedir. Ana uygulama bu modülleri dependency olarak eklemezse, uygulama çalışma zamanında (runtime) o kodlara erişemez ve çöker.

3. Katmanlı Bağımlılık Yapısı

Bu yapıda bağımlılık akışı şu şekilde gerçekleşir:

  • HomeModule: Sadece HomeModuleProtocol’ü bilir (Kendi protokolünü implement eder).
  • Diğer Feature Modüller: Sadece HomeModuleProtocol’ü import ederler (Eğer Home’a gitmek istiyorlarsa). Asla ICTMDBHomeModule somut sınıfını import etmezler.

App Target (SceneDelegate): Hem Protokolü hem de Modülü import eder. Çünkü o, tüm parçaları bir araya getiren “birleştirici” katmandır.

4. Yapının Sağladığı Kritik Avantaj

Şu satır aslında modülerliğin gücünü özetliyor:

let homeModule: HomeModuleProtocol = DependencyRegister.shared.resolve(HomeModuleProtocol.self)

Burada homeModule değişkeni ICTMDBHomeModule tipinde değil, HomeModuleProtocol tipindedir. Bu sayede ileride ICTMDBHomeModule yerine tamamen farklı bir HomeModuleV2 yazarsak, sadece SceneDelegate içindeki register kısmını değiştirmemiz yeterli olacaktır. Uygulamanın geri kalanı ve navigasyon yapan diğer modüller bu değişiklikten hiçbir şekilde etkilenmez.

Şimdi tüm projemizin graph haritasına bakarak modüllerin ve kitlerin nasıl bir bağımlılık kurduğunu görebiliriz. Ana proje dizininde sadece tuist graph demek bu işlemi gerçekleştirecektir.

Haritayı dikkatlice incelediğinizde Modüllerin kendi aralarında sadece protocol targetları ile bağımlılık kurduğunu göreceksiniz.

Son olarak bu projenizi eğer Github reponuza yükleyecekseniz ana proje klasöründeki .xcodeproj’ları silmeniz gereklidir. Çünkü bunlar aşağıda komutlar ile anlık olarak oluşturulan dosyalardır.

tuist clean
tuist install
tuist generate

ICTMDBApp Yapısı

ICTMDBApp Yapısı

Projenin Github reposun: https://github.com/engingulek/ICTMDBAppTuist

Sonuç

Evet, Tuist ile nasıl daha yönetilebilir ve modüler bir uygulama yapılabileceğini tüm detaylarıyla anlattım. Şahsen modüler yapılarla uğraşmak, bu mimariyi kurgulamak benim en sevdiğim ve yapmaktan büyük keyif aldığım süreçlerdir. Bir projeyi sadece “kod yazmak”tan çıkarıp, mühendislik prensipleriyle (SOLID, Dependency Inversion vb.) sağlam bir zemine oturtmak, projenin gelecekteki başarısı için en kritik adımdır.

Bu yazıda paylaştığım yapıda bir eksik olduğunu düşünüyorsanız, eklemek istediğiniz bir nokta varsa ya da bir hata fark ettiyseniz lütfen belirtmekten çekinmeyin. Geri bildirimleriniz benim için çok değerlidir.

Kodlarınız bugsız testleriniz success olsun

https://www.linkedin.com/in/engingulek/

https://github.com/engingulek


메타데이터
post_id
110f5cb079c7
slug
tuist-ile-modüler-uygulama-geliştirme-110f5cb079c7
url
https://medium.com/@engingulek0/tuist-ile-mod%C3%BCler-uygulama-geli%C5%9Ftirme-110f5cb079c7
canonical_url
https://medium.com/@engingulek0/tuist-ile-mod%C3%BCler-uygulama-geli%C5%9Ftirme-110f5cb079c7
author_url
https://medium.com/@engingulek0
status
ok
fetched_at
2026-07-13 06:23:13