diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index da8af4d..918984c 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -42,6 +42,46 @@ jobs: path: build.log if-no-files-found: ignore + example_app: + name: ExampleApp Build + runs-on: macos-26 + env: + DEVELOPER_DIR: /Applications/Xcode_26.6.app/Contents/Developer + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: List ExampleApp Workspace + run: xcodebuild -list -workspace Examples/ExampleApp/ExampleApp.xcworkspace + + - name: Disable SwiftSyntax Prebuilts + run: defaults write com.apple.dt.Xcode IDEPackageEnablePrebuilts NO + + - name: Build ExampleApp + shell: bash + run: | + set -euo pipefail + set -o pipefail + set +e + xcodebuild \ + -workspace Examples/ExampleApp/ExampleApp.xcworkspace \ + -scheme ExampleApp \ + -configuration Debug \ + -destination 'generic/platform=iOS Simulator' \ + CODE_SIGNING_ALLOWED=NO \ + build 2>&1 | tee example-app-build.log + buildStatus=${PIPESTATUS[0]} + set -e + exit $buildStatus + + - name: Upload ExampleApp build log artifact + if: always() + uses: actions/upload-artifact@v7 + with: + name: example-app-build-log + path: example-app-build.log + if-no-files-found: ignore + test: name: Swift Test needs: build diff --git a/Examples/ExampleApp/App/ExampleApp.swift b/Examples/ExampleApp/App/ExampleApp.swift new file mode 100644 index 0000000..318ea7b --- /dev/null +++ b/Examples/ExampleApp/App/ExampleApp.swift @@ -0,0 +1,35 @@ +// +// ExampleApp.swift +// ExampleApp +// +// Created by opfic on 9/4/26. +// + +import SwiftUI + +// Cradle 소비 예제의 앱 진입점 +@main +struct ExampleApp: App { + // 앱 수명 동안 graph를 보유할 Composition Root + private let composition: ExampleAppComposition + // ViewModel 수명을 맡을 최상위 View + private let rootView: ProductDetailView + + // Composition Root에서 생성한 ViewModel을 최상위 View에 주입 + init() { + let composition = ExampleAppComposition() + self.composition = composition + rootView = ProductDetailView( + viewModel: composition.makeProductDetailViewModel( + productID: ProductID(rawValue: "featured-product") + ) + ) + } + + // ExampleApp 장면 구성 + var body: some Scene { + WindowGroup { + rootView + } + } +} diff --git a/Examples/ExampleApp/AppComposition/AppGraph.swift b/Examples/ExampleApp/AppComposition/AppGraph.swift new file mode 100644 index 0000000..3ca534d --- /dev/null +++ b/Examples/ExampleApp/AppComposition/AppGraph.swift @@ -0,0 +1,27 @@ +// +// AppGraph.swift +// ExampleApp +// +// Created by opfic on 9/4/26. +// + +import Cradle + +// Data graph와 Presentation 생성을 연결하는 app graph +@DependencyGraph(sources: [DataGraph.self]) +final class AppGraph { + // Data graph의 Domain 계약으로 상품 조회 use case 생성 + @Provide + private func makeLoadProductUseCase() -> LoadProductUseCase { + LoadProductUseCase(repository: dataGraph.productRepository) + } + + // graph 의존성과 외부 상품 식별자로 ViewModel 생성 + @Provide(.transient) + private func makeProductDetailViewModel( + useCase: LoadProductUseCase, + @External productID: ProductID + ) -> ProductDetailViewModel { + ProductDetailViewModel(useCase: useCase, productID: productID) + } +} diff --git a/Examples/ExampleApp/AppComposition/ExampleAppComposition.swift b/Examples/ExampleApp/AppComposition/ExampleAppComposition.swift new file mode 100644 index 0000000..9750bd4 --- /dev/null +++ b/Examples/ExampleApp/AppComposition/ExampleAppComposition.swift @@ -0,0 +1,28 @@ +// +// ExampleAppComposition.swift +// ExampleApp +// +// Created by opfic on 9/4/26. +// + +// leaf source graph부터 앱 graph까지 조립하고 소유하는 Composition Root +final class ExampleAppComposition { + // 조립한 계층 graph를 소유할 앱 graph + private let graph: AppGraph + + // Persistence부터 앱 graph까지 명시적인 생성 순서로 조립 + init() { + let persistenceGraph = PersistenceGraph() + let infraGraph = InfraGraph() + let dataGraph = DataGraph( + infraGraph: infraGraph, + persistenceGraph: persistenceGraph + ) + graph = AppGraph(dataGraph: dataGraph) + } + + // 호출 시점 상품 식별자로 새로운 ViewModel 생성 + func makeProductDetailViewModel(productID: ProductID) -> ProductDetailViewModel { + graph.productDetailViewModel(productID: productID) + } +} diff --git a/Examples/ExampleApp/AppComposition/Previews/ProductDetailPreviews.swift b/Examples/ExampleApp/AppComposition/Previews/ProductDetailPreviews.swift new file mode 100644 index 0000000..cd1fa30 --- /dev/null +++ b/Examples/ExampleApp/AppComposition/Previews/ProductDetailPreviews.swift @@ -0,0 +1,71 @@ +// +// ProductDetailPreviews.swift +// ExampleApp +// +// Created by opfic on 9/4/26. +// + +import Cradle +import SwiftUI + +// 저장값이 없을 때 Infra 결과를 표시하는 Preview 구성 +@MainActor @ViewBuilder +private func infraProductDetailPreview() -> some View { + // 저장값이 없는 기본 Persistence graph + let persistenceGraph = PersistenceGraph() + // 결정적인 상품을 제공할 기본 Infra graph + let infraGraph = InfraGraph() + // Preview가 독립적으로 소유할 Data graph + let dataGraph = DataGraph( + infraGraph: infraGraph, + persistenceGraph: persistenceGraph + ) + // Preview가 독립적으로 소유할 앱 graph + let graph = AppGraph(dataGraph: dataGraph) + // 외부 상품 식별자로 생성한 Preview 전용 ViewModel + let viewModel = graph.productDetailViewModel( + productID: ProductID(rawValue: "remote-product") + ) + + ProductDetailView(viewModel: viewModel) +} + +// Persistence 등록을 교체한 Preview 구성 +@MainActor @ViewBuilder +private func persistenceProductDetailPreview() -> some View { + // 등록 소유 graph에 직접 적용한 저장 상품 교체 + let persistenceGraph = PersistenceGraph.override( + productStore: .replace { + ProductStore( + storedProduct: StoredProduct( + id: ProductID(rawValue: "stored-product"), + name: "저장 상품", + summary: "Persistence 계층에서 제공한 상품" + ) + ) + } + ).build() + // 교체를 전파하지 않는 독립 Infra graph + let infraGraph = InfraGraph() + // 교체한 Persistence graph를 직접 받는 Data graph + let dataGraph = DataGraph( + infraGraph: infraGraph, + persistenceGraph: persistenceGraph + ) + // Preview가 독립적으로 소유할 앱 graph + let graph = AppGraph(dataGraph: dataGraph) + // 저장 상품 식별자로 생성한 Preview 전용 ViewModel + let viewModel = graph.productDetailViewModel( + productID: ProductID(rawValue: "stored-product") + ) + + ProductDetailView(viewModel: viewModel) +} + +#Preview("Infra 대체 조회") { + infraProductDetailPreview() +} + +#Preview("Persistence 교체") { + persistenceProductDetailPreview() +} diff --git a/Examples/ExampleApp/Data/Infra/InfraGraph.swift b/Examples/ExampleApp/Data/Infra/InfraGraph.swift new file mode 100644 index 0000000..68ad11f --- /dev/null +++ b/Examples/ExampleApp/Data/Infra/InfraGraph.swift @@ -0,0 +1,18 @@ +// +// InfraGraph.swift +// ExampleApp +// +// Created by opfic on 9/4/26. +// + +import Cradle + +// 상품 client 등록을 소유하는 source graph +@DependencyGraph +final class InfraGraph { + // 결정적인 상품 응답을 제공할 client 생성 + @Provide + private func makeProductClient() -> ProductClient { + ProductClient() + } +} diff --git a/Examples/ExampleApp/Data/Infra/ProductClient.swift b/Examples/ExampleApp/Data/Infra/ProductClient.swift new file mode 100644 index 0000000..267a43e --- /dev/null +++ b/Examples/ExampleApp/Data/Infra/ProductClient.swift @@ -0,0 +1,28 @@ +// +// ProductClient.swift +// ExampleApp +// +// Created by opfic on 9/4/26. +// + +// Infra 계층이 제공할 상품 응답 +struct ProductResponse { + // 요청한 상품 식별자 + let id: ProductID + // 결정적인 상품 이름 + let name: String + // 결정적인 상품 설명 + let summary: String +} + +// 실제 통신 없이 결정적인 상품 응답을 제공하는 client +struct ProductClient { + // 식별자에 대응하는 상품 응답 생성 + func product(for id: ProductID) -> ProductResponse { + ProductResponse( + id: id, + name: "기본 상품", + summary: "Infra 계층에서 제공한 상품 \(id.rawValue)" + ) + } +} diff --git a/Examples/ExampleApp/Data/Persistence/PersistenceGraph.swift b/Examples/ExampleApp/Data/Persistence/PersistenceGraph.swift new file mode 100644 index 0000000..2fe5644 --- /dev/null +++ b/Examples/ExampleApp/Data/Persistence/PersistenceGraph.swift @@ -0,0 +1,18 @@ +// +// PersistenceGraph.swift +// ExampleApp +// +// Created by opfic on 9/4/26. +// + +import Cradle + +// 상품 저장소 등록을 소유하는 override 가능 source graph +@DependencyGraph(overrides: true) +final class PersistenceGraph { + // 기본 구성에서 비어 있는 메모리 저장소 생성 + @Provide + private func makeProductStore() -> ProductStore { + ProductStore(storedProduct: nil) + } +} diff --git a/Examples/ExampleApp/Data/Persistence/ProductStore.swift b/Examples/ExampleApp/Data/Persistence/ProductStore.swift new file mode 100644 index 0000000..949cb54 --- /dev/null +++ b/Examples/ExampleApp/Data/Persistence/ProductStore.swift @@ -0,0 +1,35 @@ +// +// ProductStore.swift +// ExampleApp +// +// Created by opfic on 9/4/26. +// + +// Persistence 계층이 보관할 상품 데이터 +struct StoredProduct { + // 저장한 상품 식별자 + let id: ProductID + // 저장한 상품 이름 + let name: String + // 저장한 상품 설명 + let summary: String +} + +// 단일 상품의 메모리 저장 상태를 제공하는 저장소 +struct ProductStore { + // 메모리에 보관한 상품 데이터 + private let storedProduct: StoredProduct? + + // 상품이 있거나 없는 저장 상태 구성 + init(storedProduct: StoredProduct?) { + self.storedProduct = storedProduct + } + + // 식별자가 일치하는 저장 상품 조회 + func product(for id: ProductID) -> StoredProduct? { + guard storedProduct?.id == id else { + return nil + } + return storedProduct + } +} diff --git a/Examples/ExampleApp/Data/Repository/DataGraph.swift b/Examples/ExampleApp/Data/Repository/DataGraph.swift new file mode 100644 index 0000000..2929796 --- /dev/null +++ b/Examples/ExampleApp/Data/Repository/DataGraph.swift @@ -0,0 +1,50 @@ +// +// DataGraph.swift +// ExampleApp +// +// Created by opfic on 9/4/26. +// + +import Cradle + +// Persistence와 Infra를 Domain 상품 계약으로 감추는 Repository +private struct DefaultProductRepository: ProductRepository { + // 저장된 상품을 먼저 조회할 저장소 + let store: ProductStore + // 저장된 상품이 없을 때 사용할 client + let client: ProductClient + + // 저장 상품을 우선 사용하고 없으면 Infra 응답을 Domain entity로 변환 + func product(for id: ProductID) -> Product { + if let product = store.product(for: id) { + return Product( + id: product.id, + name: product.name, + summary: product.summary + ) + } + + let response = client.product(for: id) + return Product( + id: response.id, + name: response.name, + summary: response.summary + ) + } +} + +// Persistence와 Infra source graph를 Domain 계약으로 조합하는 Data graph +@DependencyGraph(sources: [ + PersistenceGraph.self, + InfraGraph.self +]) +final class DataGraph { + // 구체 구현을 숨긴 Domain 상품 조회 계약 생성 + @Provide + private func makeProductRepository() -> any ProductRepository { + DefaultProductRepository( + store: persistenceGraph.productStore, + client: infraGraph.productClient + ) + } +} diff --git a/Examples/ExampleApp/Domain/LoadProductUseCase.swift b/Examples/ExampleApp/Domain/LoadProductUseCase.swift new file mode 100644 index 0000000..5b1f645 --- /dev/null +++ b/Examples/ExampleApp/Domain/LoadProductUseCase.swift @@ -0,0 +1,22 @@ +// +// LoadProductUseCase.swift +// ExampleApp +// +// Created by opfic on 9/4/26. +// + +// 상품 조회 계약에 실행을 위임하는 Domain use case +struct LoadProductUseCase { + // 상품 조회를 수행할 Domain 계약 + private let repository: any ProductRepository + + // Domain 계약 주입 + init(repository: any ProductRepository) { + self.repository = repository + } + + // 식별자에 해당하는 상품 상세 조회 + func execute(id: ProductID) -> Product { + repository.product(for: id) + } +} diff --git a/Examples/ExampleApp/Domain/Product.swift b/Examples/ExampleApp/Domain/Product.swift new file mode 100644 index 0000000..fd7e50a --- /dev/null +++ b/Examples/ExampleApp/Domain/Product.swift @@ -0,0 +1,22 @@ +// +// Product.swift +// ExampleApp +// +// Created by opfic on 9/4/26. +// + +// 상품을 구분하는 Domain 식별자 +struct ProductID: Hashable, Sendable { + // 외부 입력으로 전달할 원시 식별값 + let rawValue: String +} + +// 상품 상세 화면이 표시할 Domain entity +struct Product: Equatable, Sendable { + // 조회한 상품의 식별자 + let id: ProductID + // 화면에 표시할 상품 이름 + let name: String + // 화면에 표시할 상품 설명 + let summary: String +} diff --git a/Examples/ExampleApp/Domain/ProductRepository.swift b/Examples/ExampleApp/Domain/ProductRepository.swift new file mode 100644 index 0000000..d74d9bc --- /dev/null +++ b/Examples/ExampleApp/Domain/ProductRepository.swift @@ -0,0 +1,12 @@ +// +// ProductRepository.swift +// ExampleApp +// +// Created by opfic on 9/4/26. +// + +// Data 계층이 구현할 상품 조회 계약 +protocol ProductRepository { + // 식별자에 해당하는 상품 조회 + func product(for id: ProductID) -> Product +} diff --git a/Examples/ExampleApp/ExampleApp.xcodeproj/project.pbxproj b/Examples/ExampleApp/ExampleApp.xcodeproj/project.pbxproj new file mode 100644 index 0000000..2596484 --- /dev/null +++ b/Examples/ExampleApp/ExampleApp.xcodeproj/project.pbxproj @@ -0,0 +1,374 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 77; + objects = { + +/* Begin PBXBuildFile section */ + A1000000000000000000001A /* ExampleApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000000000000000000019 /* ExampleApp.swift */; }; + A1000000000000000000001D /* Cradle in Frameworks */ = {isa = PBXBuildFile; productRef = A1000000000000000000001C /* Cradle */; }; + A10000000000000000000023 /* Product.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000000000000000000020 /* Product.swift */; }; + A10000000000000000000024 /* ProductRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000000000000000000021 /* ProductRepository.swift */; }; + A10000000000000000000025 /* LoadProductUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000000000000000000022 /* LoadProductUseCase.swift */; }; + A10000000000000000000028 /* ProductStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000000000000000000026 /* ProductStore.swift */; }; + A10000000000000000000029 /* PersistenceGraph.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000000000000000000027 /* PersistenceGraph.swift */; }; + A1000000000000000000002C /* ProductClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1000000000000000000002A /* ProductClient.swift */; }; + A1000000000000000000002D /* InfraGraph.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1000000000000000000002B /* InfraGraph.swift */; }; + A10000000000000000000031 /* DataGraph.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1000000000000000000002F /* DataGraph.swift */; }; + A10000000000000000000034 /* ProductDetailViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000000000000000000032 /* ProductDetailViewModel.swift */; }; + A10000000000000000000035 /* ProductDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000000000000000000033 /* ProductDetailView.swift */; }; + A10000000000000000000038 /* AppGraph.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000000000000000000036 /* AppGraph.swift */; }; + A10000000000000000000039 /* ExampleAppComposition.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000000000000000000037 /* ExampleAppComposition.swift */; }; + A1000000000000000000003B /* ProductDetailPreviews.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1000000000000000000003A /* ProductDetailPreviews.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + A10000000000000000000005 /* ExampleApp.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = ExampleApp.app; sourceTree = BUILT_PRODUCTS_DIR; }; + A10000000000000000000019 /* ExampleApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExampleApp.swift; sourceTree = ""; }; + A10000000000000000000020 /* Product.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Product.swift; sourceTree = ""; }; + A10000000000000000000021 /* ProductRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductRepository.swift; sourceTree = ""; }; + A10000000000000000000022 /* LoadProductUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadProductUseCase.swift; sourceTree = ""; }; + A10000000000000000000026 /* ProductStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductStore.swift; sourceTree = ""; }; + A10000000000000000000027 /* PersistenceGraph.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersistenceGraph.swift; sourceTree = ""; }; + A1000000000000000000002A /* ProductClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductClient.swift; sourceTree = ""; }; + A1000000000000000000002B /* InfraGraph.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InfraGraph.swift; sourceTree = ""; }; + A1000000000000000000002F /* DataGraph.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataGraph.swift; sourceTree = ""; }; + A10000000000000000000032 /* ProductDetailViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductDetailViewModel.swift; sourceTree = ""; }; + A10000000000000000000033 /* ProductDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductDetailView.swift; sourceTree = ""; }; + A10000000000000000000036 /* AppGraph.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppGraph.swift; sourceTree = ""; }; + A10000000000000000000037 /* ExampleAppComposition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExampleAppComposition.swift; sourceTree = ""; }; + A1000000000000000000003A /* ProductDetailPreviews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductDetailPreviews.swift; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + A10000000000000000000007 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + A1000000000000000000001D /* Cradle in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + A10000000000000000000002 = { + isa = PBXGroup; + children = ( + A10000000000000000000010 /* App */, + A10000000000000000000011 /* Domain */, + A10000000000000000000012 /* Data */, + A10000000000000000000016 /* Presentation */, + A10000000000000000000017 /* AppComposition */, + A10000000000000000000003 /* Products */, + ); + sourceTree = ""; + }; + A10000000000000000000003 /* Products */ = { + isa = PBXGroup; + children = ( + A10000000000000000000005 /* ExampleApp.app */, + ); + name = Products; + sourceTree = ""; + }; + A10000000000000000000010 /* App */ = { + isa = PBXGroup; + children = ( + A10000000000000000000019 /* ExampleApp.swift */, + ); + path = App; + sourceTree = ""; + }; + A10000000000000000000011 /* Domain */ = { + isa = PBXGroup; + children = ( + A10000000000000000000020 /* Product.swift */, + A10000000000000000000021 /* ProductRepository.swift */, + A10000000000000000000022 /* LoadProductUseCase.swift */, + ); + path = Domain; + sourceTree = ""; + }; + A10000000000000000000012 /* Data */ = { + isa = PBXGroup; + children = ( + A10000000000000000000013 /* Persistence */, + A10000000000000000000014 /* Infra */, + A10000000000000000000015 /* Repository */, + ); + path = Data; + sourceTree = ""; + }; + A10000000000000000000013 /* Persistence */ = { + isa = PBXGroup; + children = ( + A10000000000000000000026 /* ProductStore.swift */, + A10000000000000000000027 /* PersistenceGraph.swift */, + ); + path = Persistence; + sourceTree = ""; + }; + A10000000000000000000014 /* Infra */ = { + isa = PBXGroup; + children = ( + A1000000000000000000002A /* ProductClient.swift */, + A1000000000000000000002B /* InfraGraph.swift */, + ); + path = Infra; + sourceTree = ""; + }; + A10000000000000000000015 /* Repository */ = { + isa = PBXGroup; + children = ( + A1000000000000000000002F /* DataGraph.swift */, + ); + path = Repository; + sourceTree = ""; + }; + A10000000000000000000016 /* Presentation */ = { + isa = PBXGroup; + children = ( + A10000000000000000000032 /* ProductDetailViewModel.swift */, + A10000000000000000000033 /* ProductDetailView.swift */, + ); + path = Presentation; + sourceTree = ""; + }; + A10000000000000000000017 /* AppComposition */ = { + isa = PBXGroup; + children = ( + A10000000000000000000036 /* AppGraph.swift */, + A10000000000000000000037 /* ExampleAppComposition.swift */, + A10000000000000000000018 /* Previews */, + ); + path = AppComposition; + sourceTree = ""; + }; + A10000000000000000000018 /* Previews */ = { + isa = PBXGroup; + children = ( + A1000000000000000000003A /* ProductDetailPreviews.swift */, + ); + path = Previews; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + A10000000000000000000004 /* ExampleApp */ = { + isa = PBXNativeTarget; + buildConfigurationList = A10000000000000000000009 /* Build configuration list for PBXNativeTarget "ExampleApp" */; + buildPhases = ( + A10000000000000000000006 /* Sources */, + A10000000000000000000007 /* Frameworks */, + A10000000000000000000008 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = ExampleApp; + packageProductDependencies = ( + A1000000000000000000001C /* Cradle */, + ); + productName = ExampleApp; + productReference = A10000000000000000000005 /* ExampleApp.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + A10000000000000000000001 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 2660; + LastUpgradeCheck = 2660; + TargetAttributes = { + A10000000000000000000004 = { + CreatedOnToolsVersion = 26.0; + }; + }; + }; + buildConfigurationList = A1000000000000000000000A /* Build configuration list for PBXProject "ExampleApp" */; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = A10000000000000000000002; + minimizedProjectReferenceProxies = 1; + packageReferences = ( + A1000000000000000000001B /* XCLocalSwiftPackageReference "Cradle" */, + ); + preferredProjectObjectVersion = 77; + productRefGroup = A10000000000000000000003 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + A10000000000000000000004 /* ExampleApp */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + A10000000000000000000008 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + A10000000000000000000006 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + A1000000000000000000001A /* ExampleApp.swift in Sources */, + A10000000000000000000023 /* Product.swift in Sources */, + A10000000000000000000024 /* ProductRepository.swift in Sources */, + A10000000000000000000025 /* LoadProductUseCase.swift in Sources */, + A10000000000000000000028 /* ProductStore.swift in Sources */, + A10000000000000000000029 /* PersistenceGraph.swift in Sources */, + A1000000000000000000002C /* ProductClient.swift in Sources */, + A1000000000000000000002D /* InfraGraph.swift in Sources */, + A10000000000000000000031 /* DataGraph.swift in Sources */, + A10000000000000000000034 /* ProductDetailViewModel.swift in Sources */, + A10000000000000000000035 /* ProductDetailView.swift in Sources */, + A10000000000000000000038 /* AppGraph.swift in Sources */, + A10000000000000000000039 /* ExampleAppComposition.swift in Sources */, + A1000000000000000000003B /* ProductDetailPreviews.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + A1000000000000000000000B /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CLANG_ENABLE_MODULES = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_TESTABILITY = YES; + GCC_OPTIMIZATION_LEVEL = 0; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + A1000000000000000000000C /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CLANG_ENABLE_MODULES = YES; + COPY_PHASE_STRIP = YES; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + }; + name = Release; + }; + A1000000000000000000000D /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + "CODE_SIGNING_ALLOWED[sdk=iphonesimulator*]" = NO; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = dev.opfic.CradleExampleApp; + PRODUCT_NAME = "$(TARGET_NAME)"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_STRICT_CONCURRENCY = complete; + SWIFT_VERSION = 6.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + A1000000000000000000000E /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + "CODE_SIGNING_ALLOWED[sdk=iphonesimulator*]" = NO; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = dev.opfic.CradleExampleApp; + PRODUCT_NAME = "$(TARGET_NAME)"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_STRICT_CONCURRENCY = complete; + SWIFT_VERSION = 6.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + A10000000000000000000009 /* Build configuration list for PBXNativeTarget "ExampleApp" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + A1000000000000000000000D /* Debug */, + A1000000000000000000000E /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + A1000000000000000000000A /* Build configuration list for PBXProject "ExampleApp" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + A1000000000000000000000B /* Debug */, + A1000000000000000000000C /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + +/* Begin XCLocalSwiftPackageReference section */ + A1000000000000000000001B /* XCLocalSwiftPackageReference "Cradle" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = ../..; + }; +/* End XCLocalSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + A1000000000000000000001C /* Cradle */ = { + isa = XCSwiftPackageProductDependency; + package = A1000000000000000000001B /* XCLocalSwiftPackageReference "Cradle" */; + productName = Cradle; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = A10000000000000000000001 /* Project object */; +} diff --git a/Examples/ExampleApp/ExampleApp.xcodeproj/xcshareddata/xcschemes/ExampleApp.xcscheme b/Examples/ExampleApp/ExampleApp.xcodeproj/xcshareddata/xcschemes/ExampleApp.xcscheme new file mode 100644 index 0000000..5420c70 --- /dev/null +++ b/Examples/ExampleApp/ExampleApp.xcodeproj/xcshareddata/xcschemes/ExampleApp.xcscheme @@ -0,0 +1,59 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/Examples/ExampleApp/ExampleApp.xcworkspace/contents.xcworkspacedata b/Examples/ExampleApp/ExampleApp.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..c81d0a8 --- /dev/null +++ b/Examples/ExampleApp/ExampleApp.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + + + diff --git a/Examples/ExampleApp/Presentation/ProductDetailView.swift b/Examples/ExampleApp/Presentation/ProductDetailView.swift new file mode 100644 index 0000000..020c7c7 --- /dev/null +++ b/Examples/ExampleApp/Presentation/ProductDetailView.swift @@ -0,0 +1,43 @@ +// +// ProductDetailView.swift +// ExampleApp +// +// Created by opfic on 9/4/26. +// + +import SwiftUI + +// 주입받은 ViewModel의 수명을 소유하는 상품 상세 View +struct ProductDetailView: View { + // 화면 수명 동안 보관할 ViewModel + @State private var viewModel: ProductDetailViewModel + + // Optional이 아닌 ViewModel 주입과 State 초기화 + init(viewModel: ProductDetailViewModel) { + _viewModel = State(initialValue: viewModel) + } + + // 상품 조회 상태에 따른 화면 구성 + var body: some View { + NavigationStack { + Group { + // ViewModel이 제공한 상품 상세 표시 + if let product = viewModel.product { + VStack(alignment: .leading, spacing: 12) { + Text(product.name) + .font(.title) + Text(product.summary) + .foregroundStyle(.secondary) + } + .padding() + } else { + ProgressView() + } + } + .navigationTitle("상품 상세") + } + .task { + viewModel.load() + } + } +} diff --git a/Examples/ExampleApp/Presentation/ProductDetailViewModel.swift b/Examples/ExampleApp/Presentation/ProductDetailViewModel.swift new file mode 100644 index 0000000..660ac5c --- /dev/null +++ b/Examples/ExampleApp/Presentation/ProductDetailViewModel.swift @@ -0,0 +1,31 @@ +// +// ProductDetailViewModel.swift +// ExampleApp +// +// Created by opfic on 9/4/26. +// + +import Foundation + +// 상품 상세 화면의 상태와 Domain 실행을 연결하는 ViewModel +@Observable +final class ProductDetailViewModel { + // 화면에 표시할 상품 상태 + private(set) var product: Product? + + // 상품 조회를 수행할 Domain use case + private let useCase: LoadProductUseCase + // 호출 시점에 정해진 상품 식별자 + private let productID: ProductID + + // Domain use case와 외부 상품 식별자 주입 + init(useCase: LoadProductUseCase, productID: ProductID) { + self.useCase = useCase + self.productID = productID + } + + // 상품 상세를 조회해 화면 상태 갱신 + func load() { + product = useCase.execute(id: productID) + } +}