Architecture dependency rule'larını compiler'a enforce ettirmek: iOS

Wiki'de duran bir dependency rule'u çoktan çiğnenmiştir. Kuralı Package.swift'e, access control'e, bir CI script'ine ve Swift 6 isolation'ına taşıyıp build'i kırdırmanın yolunu, SwiftPM'in sessizce warning'e çevirdiği boşlukla birlikte anlatıyoruz.

Cuma akşamı, saat altıya çeyrek var. Arama ekranındaki filtre hatası pazartesi sürümüne yetişmek zorunda, repository'nin o filtreyi desteklemesi ise iki günlük iş. Geliştirici import Networking yazıyor, isteği view model'den doğrudan atıyor, başına bir // TODO: geçici koyuyor. Reviewer yorgun, diff küçük, onay geliyor. Confluence'taki "Feature'lar data katmanını import edemez" sayfası hâlâ yerinde duruyor ve artık yanlış.

Bu yazının tezi şu: bir dependency rule'u yalnızca bir wiki sayfasında ya da review yorumlarında yaşıyorsa çoktan çiğnenmiştir, siz henüz hangi dosyada olduğunu bilmiyorsunuzdur. Kural build'i kırmalı. Aynı konunun Gradle tarafını Android yazısında anlattık; burada Swift Package Manager, access control, bir CI script'i ve Swift 6 concurrency ile aynı işi iOS'ta nasıl yaptığımıza bakacağız. Örnek olarak hayali bir seyahat uygulaması kullanıyoruz: Voyage. Yazıdaki bütün kodu ve hata mesajlarını Swift 6.4 toolchain'i (Xcode 27.1) ve SwiftLint 0.63 ile derleyip çalıştırdık; farklı bir sürümdeyseniz davranışı kendi toolchain'inizde doğrulayın.

Yazılı kural, ilk teslim tarihinde bozulur

Büyük projelerde sık gördüğümüz tablo aşağıdaki gibi bir dosyadır. Kötü niyet yok, acemilik yok, yalnızca derleyicinin itiraz etmediği bir kısayol var.

// App target'ı: Features/Search/SearchViewModel.swift
import SwiftUI

@MainActor
final class SearchViewModel: ObservableObject {
    @Published var results: [BookingDTO] = []          // data katmanının DTO'su ekrana kadar gelmiş

    func search(_ query: String) async throws {
        // TODO: geçici, repository filtreyi destekleyince kaldırılacak
        var request = URLRequest(url: APIClient.shared.baseURL.appending(path: "bookings/search"))
        request.setValue(SessionStore.shared.token, forHTTPHeaderField: "Authorization")
        let (data, _) = try await URLSession.shared.data(for: request)
        results = try JSONDecoder().decode([BookingDTO].self, from: data)

        CheckoutCoordinator.shared.warmUpPaymentMethods()  // başka bir feature'a doğrudan çağrı
    }
}

Bu dosyada üç kural çiğneniyor: feature ağ katmanına doğrudan ulaşıyor, DTO domain'e sızıyor, bir feature diğerini tanıyor. Üçü de tek target'lı bir projede derleyici açısından tamamen meşru. O TODO da büyük ihtimalle ilkokula başlayacak yaşa gelecek.

Kuralları uygulatmanın bir merdiveni var. Aşağıdan yukarıya çıktıkça kuralı atlatmak zorlaşır, ihlalin fark edilme anı da kodun yazıldığı ana yaklaşır.

Basamak Kural nerede yaşıyor İhlal ne zaman fark ediliyor Nasıl atlatılıyor
Convention Wiki, onboarding sunumu Fark edilmiyor Okumayarak
Review Reviewer'ın hafızası PR'da, reviewer dikkatliyse Cuma akşamı PR açarak
Lint .swiftlint.yml Kaydederken ya da CI'da // swiftlint:disable
CI kontrolü Repo'daki script PR'da, her seferinde Script'i ya da baseline'ı değiştirerek (diff'te görünür)
Compiler Manifest ve tipler Yazarken Manifest'i değiştirerek (diff'te görünür)

Bizim tercihimiz net: her kuralı çıkabildiği en üst basamağa taşıyın. Modüller arası kurallar compiler'a ve CI script'ine, modül içi kurallar lint'e gider. Review'a yalnızca makinenin ifade edemediği şeyler kalmalı: isimlendirmenin anlamı, soyutlamanın yerinde olup olmadığı.

Bir kuralı çiğnemek için kural dosyasını değiştirmek gerekiyorsa, o kural uygulanıyordur.

Kural, Package.swift'in kendisidir

SwiftPM'de bir target, manifest'te bağımlılık olarak listelemediği bir module'ü göremez. SearchFeature target'ının dependencies listesinde Networking yoksa import Networking satırının derlenmemesi gerekir. Dolayısıyla katman kuralını yazacağınız yer manifest'tir, katmanları da klasör yapısına yansıtırız:

Sources/
  Kernel/          SharedKernel, DesignSystem
  Interfaces/      BookingInterface, PaymentInterface
  Infrastructure/  Networking, BookingData, PaymentData
  Features/        SearchFeature, CheckoutFeature
  App/             AppComposition

Düz .target(...) çağrılarıyla dolu bir manifest'te kuralı görmek için her satırın dependencies dizisini okumak gerekir. Bunun yerine her katman için bir yardımcı fonksiyon yazıyoruz. Fonksiyonun imzası kuralın kendisi oluyor: feature fonksiyonunda başka bir feature ya da infrastructure module'ü yazabileceğiniz parametre yok.

// swift-tools-version: 6.2
import PackageDescription

let base: [SwiftSetting] = [.enableUpcomingFeature("InternalImportsByDefault")]

func deps(_ names: [String]) -> [Target.Dependency] { names.map { .byName(name: $0) } }

/// Kernel: hiçbir şeye bağımlı değildir.
func kernel(_ name: String) -> Target {
    .target(name: name, path: "Sources/Kernel/\(name)", swiftSettings: base)
}

/// Interface: yalnızca SharedKernel'i görür. Başka parametre yok, çünkü başka izin yok.
func interface(_ name: String) -> Target {
    .target(name: name, dependencies: ["SharedKernel"],
            path: "Sources/Interfaces/\(name)", swiftSettings: base)
}

/// Infrastructure: interface'leri implement eder, diğer infrastructure module'lerini kullanabilir.
func infrastructure(_ name: String, implements interfaces: [String] = [],
                    uses infra: [String] = [], products: [Target.Dependency] = []) -> Target {
    .target(name: name, dependencies: deps(["SharedKernel"] + interfaces + infra) + products,
            path: "Sources/Infrastructure/\(name)", swiftSettings: base)
}

/// Feature: kernel ve interface. Feature ya da infrastructure adı yazacak parametre yok.
func feature(_ name: String, uses interfaces: [String]) -> Target {
    .target(name: name, dependencies: deps(["SharedKernel", "DesignSystem"] + interfaces),
            path: "Sources/Features/\(name)",
            swiftSettings: base + [.defaultIsolation(MainActor.self)])
}

let package = Package(
    name: "Voyage",
    platforms: [.iOS(.v17), .macOS(.v14)],
    products: [.library(name: "VoyageApp", targets: ["AppComposition"])],
    targets: [
        kernel("SharedKernel"),
        kernel("DesignSystem"),

        interface("BookingInterface"),
        interface("PaymentInterface"),

        infrastructure("Networking"),
        infrastructure("BookingData", implements: ["BookingInterface"], uses: ["Networking"]),
        infrastructure("PaymentData", implements: ["PaymentInterface"], uses: ["Networking"]),

        feature("SearchFeature", uses: ["BookingInterface"]),
        feature("CheckoutFeature", uses: ["BookingInterface", "PaymentInterface"]),

        // Composition root: her şeyi gören tek target.
        .target(name: "AppComposition",
                dependencies: ["SearchFeature", "CheckoutFeature", "BookingData", "PaymentData"],
                path: "Sources/App/AppComposition", swiftSettings: base),
    ]
)

Feature'ların birbirini görmemesi ve implementasyonların interface module'lerinin arkasında durması, döngüsel bağımlılıkları da kaynağında keser; o konunun ayrıntısı circular dependency yazısında. Projeyi Tuist ya da XcodeGen ile üretiyorsanız aynı fikir geçerli: katman fonksiyonları proje tanımının içinde yaşar.

Manifest tek başına duvar değildir

Burada dürüst olmamız gereken bir nokta var, çünkü bu yazıyı hazırlarken biz de test edene kadar tersini varsayıyorduk. SearchFeature'a beyan edilmemiş bir import Networking ekleyip tam build aldığımızda sonuç şu oldu:

warning: 'SearchFeature' is missing a dependency on 'Networking' because dependency scan
of Swift module 'SearchFeature' discovered a dependency on 'Networking'
Build complete!

Build geçti. Sebebi basit: AppComposition zaten Networking'e bağımlı, module aynı build'de üretiliyor ve ortak ürün klasöründen bulunabiliyor. Temiz bir klasörde yalnızca o target'ı derlediğimizde (swift build --target SearchFeature) aynı satır unable to resolve module dependency: 'Networking' hatasıyla kırıldı; önce tam build alıp sonra aynı komutu çalıştırdığımızda yine geçti. Bir başka denemede, beyan edilmemiş bir feature import'u tam build'de hata verdi. Yani sonuç build sırasına ve diskte ne kaldığına bağlı. Bir kuralın isteyeceğiniz son özelliği budur.

İki şeyle kapatıyoruz. Xcode ile derlenen yerde bu teşhisi hataya çevirmek mümkün: xcodebuild komutuna DIAGNOSE_MISSING_TARGET_DEPENDENCIES=YES_ERROR verdiğimizde aynı warning error oldu ve build kırıldı. Bu ayar Swift Build'in kaynak kodunda tanımlı, Apple'ın build settings referansında ise bulamadık; kendi Xcode sürümünüzde bilerek bir ihlal yazıp deneyin. İkincisi, toolchain'den bağımsız çalışan bir kontrol: aşağıdaki CI script'i kaynak dosyalardaki import'ları manifest'teki beyanlarla karşılaştırıyor.

Access control, mimarinin öbür yarısı

Manifest hangi module'ün hangisini görebileceğini söyler. Gördüğü module'ün içinde neye dokunabileceğini ise access control belirler ve çoğu projede bu yarı boş bırakılır: her şey public, çünkü "lazım olur".

Kuralımız şu: bir infrastructure module'ü dışarıya yalnızca bir fabrika açar. Implementasyon tipleri internal kalır, böylece composition root dışında kimse LiveBookingRepository adını yazamaz.

// Sources/Infrastructure/BookingData/LiveBookingRepository.swift
public import BookingInterface
public import Foundation
import Networking          // InternalImportsByDefault açıkken bu satır internal'dır
import SharedKernel

// internal: module dışından adı bile yazılamaz.
struct LiveBookingRepository: BookingRepository {
    let client: HTTPClient

    func booking(id: BookingID) async throws -> Booking {
        let data = try await client.get("bookings/\(id.rawValue)")
        let dto = try JSONDecoder().decode(BookingDTO.self, from: data)
        return Booking(id: id, title: dto.title)   // DTO bu dosyadan çıkmaz
    }
}

struct BookingDTO: Decodable { let title: String }

// Module'ün dışarıya açtığı tek kapı.
public enum BookingDataAssembly {
    public static func makeRepository(baseURL: URL) -> any BookingRepository {
        LiveBookingRepository(client: HTTPClient(baseURL: baseURL))
    }
}

Import'ların da access level'ı var

SE-0409 ile (Swift 6.0'da implement edildi) import satırlarına access level yazılabiliyor. internal import Networking demek, Networking tiplerinin bu module'ün public API'sinde görünemeyeceği demek. Biri BookingData'ya şunu eklerse:

public struct BookingDebugTools {
    public let client: HTTPClient
}

derleyici şunu söyler:

error: property cannot be declared public because its type uses an internal type
note: struct 'HTTPClient' is imported by this file as 'internal' from 'Networking'

Dikkat edilecek ayrıntı: Swift 6 language mode'da bile import'ların varsayılanı hâlâ public. Proposal, varsayılanın internal olmasını gelecekteki bir language mode'a bırakıyor ve o güne kadar InternalImportsByDefault upcoming feature'ını sunuyor. Yukarıdaki manifest'te bu bayrağı bütün target'lara açtık. Açtığınız gün bir dizi hata alırsınız, çünkü public API'de görünen her module için public import yazmanız gerekir. Bu hatalar faydalıdır: her dosyanın başında, o module'ün neyi dışarı sızdırdığının açık listesi oluşur. public import Networking satırı review'da artık göze batar.

package: public olmadan paylaşmak

SE-0386 (Swift 5.9) package access level'ını getirdi: aynı package'daki module'ler görür, package dışı göremez. SwiftPM package kimliğini derleyiciye -package-name olarak kendisi geçirir. İki yerde kullanıyoruz. Birincisi, birkaç module'ün paylaştığı ama uygulamanın API'si olmayan yardımcılar ve test fixture'ları. İkincisi, aşağıda göreceğiniz gibi, belirli tiplerin yalnızca package içinden üretilebilmesi.

Sınırını da bilin: package tek bir halkadır. Aynı package'daki bir feature da o sembolü görür, yani katman kuralı ifade etmez. Xcode projesindeki app target'ı ise package'ın dışındadır ve package sembollere erişemez.

Derleyicinin ifade edemediği kurallar için bir CI script'i

Katman fonksiyonları kuralı okunur kılar ama kimse sizi onları kullanmaya zorlamaz. Biri manifest'e düz bir .target(name: "CheckoutFeature", dependencies: [..., "SearchFeature"]) yazarsa SwiftPM bunu memnuniyetle derler. "Hiçbir feature başka bir feature'a bağımlı olamaz" cümlesini SwiftPM'e söylemenin yolu yok. Ama manifest'i veri olarak okuyabiliriz.

swift package describe --type json her target için name, path, type, sources, target_dependencies ve (varsa) product_dependencies alanlarını verir. swift package dump-package de iş görür ama bağımlılıkları {"byName": ["SharedKernel", null]} biçiminde, manifest'in ham haliyle döker; describe çıktısı düz isim listesi olduğu için onu tercih ediyoruz. Script katmanı klasörden okuyor, üç şeyi kontrol ediyor ve bir baseline dosyasını destekliyor:

// Kullanım: swift package describe --type json | swift Scripts/check-architecture.swift
import Foundation

struct PackageDescription: Decodable {
    struct Target: Decodable {
        let name: String
        let path: String
        let type: String
        let sources: [String]
        let target_dependencies: [String]?
        let product_dependencies: [String]?
    }
    let targets: [Target]
}

// Katman, klasörden okunur: Sources/<Katman>/<Target>
func layer(of target: PackageDescription.Target) -> String {
    target.path.split(separator: "/").dropFirst().first.map(String.init) ?? "?"
}

// Hangi katman hangi katmanı görebilir. Kuralın tamamı bu tablo.
let allowedLayers: [String: Set<String>] = [
    "Kernel": [],
    "Interfaces": ["Kernel"],
    "Infrastructure": ["Kernel", "Interfaces", "Infrastructure"],
    "Features": ["Kernel", "Interfaces"],
    "App": ["Kernel", "Interfaces", "Infrastructure", "Features"],
]
let layersAllowedThirdParty: Set<String> = ["Infrastructure", "App"]

// `import X`, `public import X`, `@testable import X`, `import struct X.Y` satırlarından module adını çıkarır.
func importedModules(in source: String) -> Set<String> {
    let modifiers: Set<Substring> = ["public", "package", "internal", "fileprivate", "private"]
    let kinds: Set<Substring> = ["struct", "class", "enum", "protocol", "typealias", "func", "let", "var"]
    var result: Set<String> = []
    for line in source.split(separator: "\n") {
        var tokens = line.split(whereSeparator: \.isWhitespace)
            .drop { $0.hasPrefix("@") || modifiers.contains($0) }
        guard tokens.first == "import" else { continue }
        tokens = tokens.dropFirst()
        if let first = tokens.first, kinds.contains(first) { tokens = tokens.dropFirst() }
        if let module = tokens.first?.split(separator: ".").first { result.insert(String(module)) }
    }
    return result
}

let input = FileHandle.standardInput.readDataToEndOfFile()
let package = try JSONDecoder().decode(PackageDescription.self, from: input)
let targets = package.targets.filter { $0.type != "test" }
let byName = Dictionary(uniqueKeysWithValues: targets.map { ($0.name, $0) })
var violations: Set<String> = []

for target in targets {
    let from = layer(of: target)
    let declared = Set(target.target_dependencies ?? [])
    guard let allowed = allowedLayers[from] else {
        violations.insert("\(target.name): bilinmeyen katman '\(from)'")
        continue
    }
    // 1. Manifest'te izin verilmeyen katmana bağımlılık
    for dependency in declared.sorted() {
        guard let to = byName[dependency].map(layer(of:)), !allowed.contains(to) else { continue }
        violations.insert("\(target.name) -> \(dependency): \(from) katmanı \(to) katmanına bağımlı olamaz")
    }
    // 2. Üçüncü parti product yalnızca izinli katmanlarda
    if !layersAllowedThirdParty.contains(from) {
        for product in target.product_dependencies ?? [] {
            violations.insert("\(target.name) -> \(product): \(from) katmanı üçüncü parti product kullanamaz")
        }
    }
    // 3. Kaynakta import edilen ama manifest'te beyan edilmeyen package module'ü
    for file in target.sources {
        let url = URL(fileURLWithPath: target.path).appendingPathComponent(file)
        guard let source = try? String(contentsOf: url, encoding: .utf8) else { continue }
        for module in importedModules(in: source) where byName[module] != nil
            && module != target.name && !declared.contains(module) {
            violations.insert("\(target.name) -> \(module): beyan edilmemiş import (\(file))")
        }
    }
}

// Baseline: bilinen ihlaller. Yeni ihlal de, baseline'da unutulmuş çözülmüş satır da build'i kırar.
let baselinePath = "architecture-baseline.txt"
let baseline = Set(((try? String(contentsOfFile: baselinePath, encoding: .utf8)) ?? "")
    .split(separator: "\n").map(String.init))

if CommandLine.arguments.contains("--update-baseline") {
    let text = violations.sorted().joined(separator: "\n") + "\n"
    try text.write(toFile: baselinePath, atomically: true, encoding: .utf8)
    print("Baseline güncellendi: \(violations.count) ihlal")
    exit(0)
}

let new = violations.subtracting(baseline).sorted()
let fixed = baseline.subtracting(violations).sorted()
new.forEach { print("error: \($0)") }
fixed.forEach { print("error: çözülmüş, baseline'dan silin: \($0)") }
print("Mimari kontrol: \(targets.count) target, \(new.count) yeni ihlal, \(baseline.intersection(violations).count) baseline'da")
exit(new.isEmpty && fixed.isEmpty ? 0 : 1)

allowedLayers tablosundaki "Features": ["Kernel", "Interfaces"] satırı "feature feature'a bağımlı olamaz" kuralıdır; "Interfaces": ["Kernel"] satırı da "interface target'ları shared kernel dışında hiçbir şeye bağımlı olamaz" kuralı. Kasıtlı bir ihlalle çalıştırdığımızda çıktı şöyle:

error: CheckoutFeature -> SearchFeature: Features katmanı Features katmanına bağımlı olamaz
error: SearchFeature -> Networking: beyan edilmemiş import (SearchViewModel.swift)
Mimari kontrol: 10 target, 2 yeni ihlal, 0 baseline'da

Script build almadan çalışır, bu yüzden CI'da ilk adım olarak koyuyoruz:

set -euo pipefail
swift package describe --type json | swift Scripts/check-architecture.swift
swiftlint lint --quiet
swift test --package-path ArchitectureRules
xcodebuild build -scheme Voyage -destination 'generic/platform=iOS Simulator' \
  DIAGNOSE_MISSING_TARGET_DEPENDENCIES=YES_ERROR

Import taraması satır bazlıdır; #if canImport(...) blokları ya da çok satırlı string içindeki import kelimesi gibi köşe durumları ayırt etmez. Örnek projede yanlış pozitif görmedik. Görürseniz o durumu script'e açık bir istisna olarak yazın; baseline'a eklerseniz gerçek bir ihlalle aynı listede kaybolur.

Module'ün içinde kalan kurallar: SwiftLint ve Harmonize

Her kural module sınırına denk gelmez. Henüz bölünmemiş bir app target'ında "Features klasörü data katmanını import edemez" kuralını ya da bölünmüş bir projede "feature içinde URLSession kullanılmaz" kuralını lint taşır. SwiftLint'in regex tabanlı custom_rules bölümü bunun için yeterli:

custom_rules:
  feature_imports_infrastructure:
    included:
      - "Sources/Features/.*\\.swift"
    name: "Feature katmanı infrastructure import edemez"
    regex: "^\\s*(@\\w+\\s+)?((public|package|internal|fileprivate|private)\\s+)?import\\s+(Networking|\\w+Data)\\b"
    message: "Feature'lar yalnızca Interface module'lerini görür. Implementasyon AppComposition'da bağlanır."
    severity: error
  ui_framework_outside_ui:
    included:
      - "Sources/(Kernel/SharedKernel|Interfaces|Infrastructure)/.*\\.swift"
    name: "UI framework'ü UI dışı katmanda"
    regex: "^\\s*((public|package|internal|fileprivate|private)\\s+)?import\\s+(UIKit|SwiftUI)\\b"
    message: "Kernel, Interface ve Infrastructure katmanları UIKit ya da SwiftUI import edemez."
    severity: error
  url_session_in_feature:
    included:
      - "Sources/Features/.*\\.swift"
    name: "Feature içinde doğrudan URLSession"
    regex: "\\bURLSession\\b"
    match_kinds:
      - identifier
      - typeidentifier
    message: "Ağ çağrısı repository'nin işi. Feature'da URLSession kullanmayın."
    severity: error

included ve excluded dosya yolu üzerinde çalışan regex'lerdir, match_kinds ise eşleşmeyi yorum ve string'lerin dışına sınırlar (üçüncü kural, yorumdaki URLSession kelimesine takılmaz). only_rules kullanıyorsanız listeye custom_rules satırını da eklemeniz gerekir, yoksa kurallar sessizce çalışmaz. Regex'in sınırı açık: metne bakar, anlama bakmaz. typealias arkasına saklanan bir tipi ya da // swiftlint:disable yorumunu yakalamaz. Disable yorumlarını da PR diff'inde ayrıca aramanızı öneririz.

Metinden fazlası gerektiğinde, Kotlin'deki Konsist'in ve Java'daki ArchUnit'in Swift karşılığına en yakın araç Harmonize. Kuralları SwiftSyntax üzerinden, unit test olarak yazıyorsunuz. Olgunluk konusunda dürüst olalım: Harmonize 1.0'a Mayıs 2026'da ulaştı, bu yazı yazılırken son sürüm 1.3.0 (Eylül 2026) ve depo aktif. ArchUnit kadar yerleşik bir araç değil, API'sinin bir kısmı (örneğin assertEmpty'deki baseline parametresi) kaynak kodunda hâlâ "experimental" olarak işaretli. Biz kuralları ayrı küçük bir package'da tutuyoruz ki swift-syntax bağımlılığı uygulamanın grafiğine girmesin:

// ArchitectureRules/Tests/ArchitectureRulesTests/LayerRules.swift
import Harmonize
import Testing

@Test
func featuresDoNotImportInfrastructure() {
    Harmonize.on("Sources/Features").sources()
        .withImport { $0 == "Networking" || $0.hasSuffix("Data") }
        .assertEmpty(message: "Feature'lar infrastructure module'lerini import edemez")
}

@Test
func viewModelsAreFinal() {
    Harmonize.productionCode().classes()
        .withSuffix("ViewModel")
        .assertTrue(message: "ViewModel'ler final olmalı") { $0.hasModifier(.final) }
}

@Test
func repositoryImplementationsStayInternal() {
    Harmonize.on("Sources/Infrastructure").structs()
        .withSuffix("Repository")
        .assertFalse(message: "Repository implementasyonları public olamaz") { $0.hasModifier(.public) }
}

Harmonize proje kökünü bulmak için kökte (boş da olsa) bir .harmonize.yaml dosyası ister. Hangisini seçeceğiniz sorusuna cevabımız: import yasakları için SwiftLint yeter ve zaten kurulu. "Şu isimdeki tipler şu özelliği taşır" türünden yapısal kurallar başladığında Harmonize'ı ekleyin. Module'ler arası kurallar için ikisine de güvenmeyin, o iş manifest'in ve script'in.

Import'un ötesi: tipler ve isolation

Bağımlılık yönü kuralların yalnızca en görünür olanı. "Ödemesi alınmamış sipariş için bilet kesilmez" de bir mimari kuraldır ve onu da derleyiciye yazdırabiliriz. Yöntem, yasak durumu temsil eden değeri üretilemez yapmak:

// Sources/Kernel/SharedKernel
public struct PaymentReceipt: Sendable {
    public let reference: String
    public let amountInMinorUnits: Int

    // Yalnızca Voyage package'ındaki module'ler makbuz üretebilir.
    package init(reference: String, amountInMinorUnits: Int) {
        self.reference = reference
        self.amountInMinorUnits = amountInMinorUnits
    }
}

public struct PaidOrder: Sendable {
    public let bookingID: BookingID
    public let receipt: PaymentReceipt

    // internal: PaidOrder üretmenin tek yolu aşağıdaki confirm fonksiyonu.
    init(bookingID: BookingID, receipt: PaymentReceipt) {
        self.bookingID = bookingID
        self.receipt = receipt
    }
}

public enum CheckoutError: Error { case amountMismatch }

public extension DraftOrder {
    func confirm(with receipt: PaymentReceipt) throws(CheckoutError) -> PaidOrder {
        guard receipt.amountInMinorUnits == amountInMinorUnits else { throw .amountMismatch }
        return PaidOrder(bookingID: bookingID, receipt: receipt)
    }
}

// Sources/Interfaces/BookingInterface
public protocol TicketIssuer: Sendable {
    func issueTicket(for order: PaidOrder) async throws
}

issueTicket bir PaidOrder ister. Bir feature PaidOrder(bookingID:receipt:) yazmaya kalkarsa initializer is inaccessible due to 'internal' protection level hatası alır; package dışındaki bir module sahte bir PaymentReceipt üretmeye kalkarsa aynı hatayı 'package' protection level ile alır. Kural artık imzanın parçası; onu hatırlatacak bir if kontrolüne ya da yorum satırına gerek kalmadı. package halkasının geniş olduğunu yukarıda söyledik: package içindeki bir feature makbuz üretebilir. Onu da bir lint kuralıyla (PaymentReceipt( yalnızca PaymentData içinde geçer) kapatabilirsiniz.

Swift 6 isolation'ı bir mimari kuraldır

"UI kodu main thread'de çalışır, repository'ler herhangi bir thread'den çağrılabilir" cümlesi yıllarca wiki'de yaşadı. Swift 6 language mode'da bu cümle derleniyor. Manifest'teki feature fonksiyonu target'a .defaultIsolation(MainActor.self) veriyor (SE-0466, Swift 6.2): feature module'lerindeki her şey aksi yazılmadıkça main actor'dadır. Interface protokolleri ise Sendable'dır, yani sınırı geçen her implementasyon thread-safe olduğunu derleyiciye kanıtlamak zorundadır. Biri data katmanına korumasız bir cache eklerse:

final class CachingBookingRepository: BookingRepository {
    private var cache: [BookingID: Booking] = [:]
    // ...
}
error: stored property 'cache' of 'Sendable'-conforming class 'CachingBookingRepository' is mutable

Çözüm tipi actor yapmak ya da state'i bir kilidin arkasına almaktır; hangisini seçerseniz seçin, kararı derleyici sizden istemiş olur. Tools version'ı 6.x olan bir manifest'te target'lar varsayılan olarak Swift 6 language mode'da derlenir. Henüz hazır olmayan module'leri .swiftLanguageMode(.v5) ile tek tek geride bırakabilirsiniz; bu satırların her biri de baseline'ınızın bir kalemidir.

Mevcut bir projede devreye almak

Yukarıdaki yapıyı sıfırdan kurmak kolay. Asıl iş, yüzlerce ihlali olan çalışan bir projede kimseyi durdurmadan oraya varmak. Sıra şöyle:

  1. Ölçün. Script'i ve lint kurallarını yazın, CI'da yalnızca raporlasınlar. Kaç ihlal var, hangi module'lerde toplanıyor, bunu görmeden hedef koymayın.
  2. Baseline alın. Script'i --update-baseline argümanıyla çalıştırmak mevcut ihlalleri architecture-baseline.txt dosyasına yazar. SwiftLint tarafında swiftlint lint komutunun --write-baseline ve --baseline seçenekleri, Harmonize'da baseline: parametresi aynı işi görür. Baseline dosyaları repo'da durur.
  3. Yenileri engelleyin. Kontrolü zorunlu yapın. O günden sonra ihlal sayısı yalnızca azalabilir. Script, çözülmüş bir satır baseline'da kaldığında da kırıldığı için liste kendiliğinden küçülür ve kimse eski bir muafiyetin arkasına yeni ihlal saklayamaz.
  4. Baseline diff'ini review edin. Baseline'a satır ekleyen bir PR, kuralı çiğnediğini açıkça ilan eder. CODEOWNERS ile Package.swift, script ve baseline dosyalarına mimari sahibini reviewer yapın.
  5. Listeyi eritin. Her sprint'e birkaç satır. Önce interface'leri çıkarmak gereken ihlalleri, sonra import temizliklerini alın. InternalImportsByDefault ve Swift 6 language mode'u module module, yaprak module'lerden başlayarak açın.
  6. Kanarya testi yapın. Arada bir bilerek ihlal içeren bir PR açın. Kırmızı olmuyorsa kontrolünüz bir yerde sessizce devre dışı kalmıştır.

İşe yaradığını iki sayıdan anlarsınız: baseline dosyasındaki satır sayısı düşüyor mu, ve mimari yüzünden kırılan PR'lar review yorumuna dönüşmeden yazarı tarafından düzeltiliyor mu.

Nerede durmalı

Tek ekipli, tek target'lı, on beş ekranlık bir uygulamada beş katmanlı bir manifest ve bir kural package'ı fazladır; orada iki üç SwiftLint kuralı yeter. Module sayısının maliyeti ayrı bir konu ve onu modularization'ın maliyeti yazısında ele aldık. Kural sayısında da ölçülü olun: uygulatamayacağınız ya da gerekçesini bir cümlede söyleyemeyeceğiniz kuralı yazmayın. Beş sağlam kural, kimsenin okumadığı kırk kuraldan iyidir.

Kontrol listesi

  • Katmanlar klasör yapısında ve Package.swift'te yardımcı fonksiyonlarla tanımlı; feature(...) fonksiyonunda feature ya da infrastructure yazacak parametre yok.
  • Bilerek beyan edilmemiş bir import yazdınız ve build'in gerçekten kırıldığını gördünüz (DIAGNOSE_MISSING_TARGET_DEPENDENCIES=YES_ERROR ya da script'in import kontrolü).
  • Infrastructure module'leri yalnızca bir fabrika açıyor; implementasyon tipleri ve DTO'lar internal.
  • InternalImportsByDefault açık; public import satırları bilinçli ve az.
  • package access level, public yapmak istemediğiniz paylaşımlar için kullanılıyor.
  • CI'ın ilk adımı swift package describe --type json çıktısını okuyan mimari script'i; feature'dan feature'a ve interface'ten kernel dışına bağımlılık build'i kırıyor.
  • Module içi yasaklar SwiftLint custom_rules ile, yapısal kurallar gerekiyorsa Harmonize testleriyle tanımlı.
  • En az bir iş kuralı tiplerle ifade edilmiş (üretilemeyen PaidOrder gibi).
  • Interface protokolleri Sendable; feature target'ları main actor'da, module'ler Swift 6 language mode'da ya da baseline'da.
  • Baseline dosyaları repo'da, yalnızca küçülüyor, değişiklikleri CODEOWNERS ile review ediliyor.
  • Bu çeyrekte bir kanarya PR'ı açtınız ve kırmızı oldu.
Seviye 12 tamamlandıİlerlemen bu tarayıcıda saklanıyor.
Sıradaki: Architecture dependency rule'larını compiler'a enforce ettirmek: Android

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.