Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
35 changes: 35 additions & 0 deletions Examples/ExampleApp/App/ExampleApp.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
}
27 changes: 27 additions & 0 deletions Examples/ExampleApp/AppComposition/AppGraph.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
28 changes: 28 additions & 0 deletions Examples/ExampleApp/AppComposition/ExampleAppComposition.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
Original file line number Diff line number Diff line change
@@ -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()
}
18 changes: 18 additions & 0 deletions Examples/ExampleApp/Data/Infra/InfraGraph.swift
Original file line number Diff line number Diff line change
@@ -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()
}
}
28 changes: 28 additions & 0 deletions Examples/ExampleApp/Data/Infra/ProductClient.swift
Original file line number Diff line number Diff line change
@@ -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)"
)
}
}
18 changes: 18 additions & 0 deletions Examples/ExampleApp/Data/Persistence/PersistenceGraph.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
35 changes: 35 additions & 0 deletions Examples/ExampleApp/Data/Persistence/ProductStore.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
50 changes: 50 additions & 0 deletions Examples/ExampleApp/Data/Repository/DataGraph.swift
Original file line number Diff line number Diff line change
@@ -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
)
}
}
22 changes: 22 additions & 0 deletions Examples/ExampleApp/Domain/LoadProductUseCase.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading