InnoSample은 banksalad-ios의 Features / Layers / Cores / ThirdParties / Util 분리를 참고해 만든 Tuist 기반 baseline sample 입니다.
목적은 기능 구현보다 먼저, Inno 계열 앱에서 공통으로 쓸 모듈 경계, composition 방식, DI wiring, navigation ownership, Tuist helper 정책을 고정하는 데 있습니다.
즉 이 저장소는 “샘플 앱”이면서 동시에 개발 시작 전 기본 뼈대(scaffold) 역할을 합니다.
구조 원칙은 다음 의존 방향으로 정리합니다.
Feature -> DomainData -> DomainRemote -> Data + CoreNetworkLayers -> Domain + Data + RemoteFeatures -> Domain + FeatureApp -> CoreNetwork + Layers + Features + ThirdParty
InnoDI, InnoFlow, InnoNetwork, InnoRouter를 쓰는 각 모듈은 독립 Project.swift로 관리하고, 루트는 Workspace.swift로 조립합니다.
Inno 라이브러리 의존성은 로컬 path package가 아니라 Tuist/Package.swift에서 관리하는 remote package + exact version 고정 방식으로 소비합니다.
기본 개발 환경은 Xcode 26.4+와 Swift 6.3입니다.
상세 구조 평가와 개선 우선순위는 Docs/ArchitectureReview.md 에 정리합니다.
이 샘플은 banksalad-ios/Layers의 루트 composition 아이디어를 InnoDI 방식으로 축소 적용한 baseline scaffold 입니다.
핵심 의도는 세 가지입니다.
-
개발을 시작하기 전에 레이어 경계와 책임을 먼저 고정한다.
-
root composition과 feature composition을 분리해서 상위 wiring 비용을 낮춘다.
-
샘플 코드가 아니라 실제 앱 시작점으로 이어질 수 있는 구조를 제공한다.
-
InnoNetwork- 가장 안쪽 transport 구현입니다.
NetworkClient,APIDefinition, retry, interceptor, logger 같은 실행 메커니즘을 가집니다.
-
CoreNetworkInnoNetwork를 감싸는 infrastructure boundary 입니다.- 앱 공통 환경값, 기본 header, request/response interceptor, retry policy, logger를 소유합니다.
NetworkTransport를 통해 상위 레이어에 "요청을 실행하는 방법"만 노출합니다.
-
Remote- 외부 API의 endpoint 의미와 remote model 변환만 가집니다.
Data가 소유한 remote data source contract를 구현합니다.CoreNetwork의RequestDefinition,NetworkTransport만 사용합니다.
-
Data- domain repository 구현과 remote data source contract를 함께 소유합니다.
- 비어 있는 응답 처리나 리스트 curate 같은 데이터 정책이 이 레이어에 있습니다.
-
Domain- entity, repository contract, use case implementation만 가집니다.
- 네트워크 구현이나 remote model을 모릅니다.
- repository는 레이어 경계 계약이라 protocol을 유지합니다.
- use case는 현재 stateless 단일 구현체라 protocol을 유지할 실익이 작아서 concrete type으로 제공합니다.
- 즉 use case는 feature에 주입되는 의존성이지만, domain에서는 shared scope로 보관하지 않고 shared repository 위에 매번 가볍게 조합되는 concrete stateless 값으로 다룹니다.
- concrete use case의 public surface는
callAsFunction()하나로 제한하고, 생성 책임은Domain내부에만 둡니다.
-
LayersRemote -> Data -> Domain을 연결하는 composition-only 모듈입니다.RemoteContainer,DataContainer,DomainContainer를 순서대로 생성하고, feature가 바로 받는 use case만 외부에 노출합니다.- business logic, remote model, use case 구현을 두지 않습니다.
-
Features- leaf feature router들을 묶는 root feature composition 모듈입니다.
App이 전달한 use case로 각 leaf feature input을 만들고, rootFeatureContainer가 leafRouter타깃만 조립합니다.App은 개별 feature wiring 대신 rootFeatureContainer만 연결합니다.- leaf feature는
Interface / Logic / UI / Router / Testing / Tests타깃으로 나눠서 컴파일 단계에서 구조를 강제합니다. - cross-feature navigation도 leaf끼리 직접 연결하지 않고,
EntireTab같은 상위 coordinator가 child coordinator를 중재하는 방식으로만 처리합니다.
이 선택은 DI를 제거한 것이 아닙니다.
- repository는
Data -> Domain경계를 넘는 진짜 계약이라 protocol을 유지합니다. - use case는 지금 기준으로 구현이 하나뿐인 stateless wrapper라, protocol 추상화보다 concrete type이 더 읽기 쉽습니다.
- 따라서
Feature에는 use case를 의존성으로 주입하되,Domain에서는 concrete type을 computed value로 제공합니다. - 그리고 feature가 보는 public interface는
callAsFunction()하나뿐이라, use case를 단일 action object처럼 다룰 수 있습니다. - 추후 use case 구현이 둘 이상 생기거나 decorator/cache/logging wrapper가 붙으면 protocol 재도입을 다시 검토할 수 있습니다.
컨테이너 경계는 아래처럼 나뉩니다.
AppContainer- 전역 인프라와 상위 composition 연결만 담당
LayerContainerRemoteContainer -> DataContainer -> DomainContainer조립을 담당하고 feature가 바로 쓸 use case만 외부에 노출
FeatureContainer- root
Features프로젝트에서 leafRouter타깃만 조립 LayerContainer자체를 모르고 concrete use case만 입력으로 받음- 외부에는 root coordinator만 공개하고, child router/input/use case는 내부 구현으로 유지
- root
- 각 leaf feature
Interface- 외부에 공개할 feature entry/input/output 계약만 둠
LogicInnoFlowreducer/state/action/effect와 use case 호출만 담당SwiftUI,InnoRouterimport 금지
UI- SwiftUI scene/view와
Util기반 공용 UI 조합만 담당 Domain,InnoRouter직접 import 금지
- SwiftUI scene/view와
RouterInnoRoutercoordinator, push/modal/tab wiring 담당- root
Features가 조립하는 concrete 진입점
Testing- fixture와 test helper 제공
InnoRouter는 상위 coordinator가 child coordinator를 조립하는 구조와 잘 맞지만, sibling navigation을 자동으로 해결해 주지는 않습니다.
이 샘플에서는 People -> Settings -> People 왕복 이동 예제를 넣되, leaf feature는 sibling router를 직접 모르고 intent만 올리며 EntireTabCoordinator가 실제 탭 전환과 child detail push를 중재합니다.
People UI -> People Logic intent -> People Coordinator consume -> EntireTabCoordinator mediation -> SettingsCoordinator pushSettings UI -> Settings Logic intent -> Settings Coordinator consume -> EntireTabCoordinator mediation -> PeopleCoordinator push
즉 런타임 이동은 왕복이 가능하지만, 컴파일 의존은 People -> EntireTab <- Settings 구조만 유지합니다.
이렇게 둔 이유는 두 가지입니다.
AppContainer가 remote/data/domain 구현이나 개별 feature wiring을 직접 조립하지 않게 해서 상위 composition 책임을 줄이기 위해banksalad-ios와 비슷하게 루트Layers프로젝트가Remote/Data/Domain연결을 맡고, 루트Features프로젝트는 domain 의존성만 받아 feature 조립을 맡게 하기 위해
즉, 이 샘플에서 CoreNetwork는 단순 factory가 아니라 transport adapter 입니다.
또한 Layers 루트 프로젝트와 Features 루트 프로젝트는 구현 레이어가 아니라 composition 레이어입니다.
graph TD
App["App"]
AppContainer["AppContainer"]
Analytics["Analytics"]
CoreNetwork["CoreNetwork"]
NetworkTransport["NetworkTransport"]
Layers["Layers"]
LayerContainer["LayerContainer"]
RemoteContainer["RemoteContainer"]
DataContainer["DataContainer"]
DomainContainer["DomainContainer"]
Features["Features"]
FeatureContainer["FeatureContainer"]
EntireTab["EntireTabFeature"]
People["PeopleFeature"]
Posts["PostsFeature"]
Settings["SettingsFeature"]
App --> AppContainer
AppContainer --> Analytics
AppContainer --> CoreNetwork
CoreNetwork --> NetworkTransport
AppContainer --> NetworkTransport
AppContainer --> Layers
AppContainer --> Features
Layers --> LayerContainer
LayerContainer --> RemoteContainer
LayerContainer --> DataContainer
LayerContainer --> DomainContainer
RemoteContainer --> CoreNetwork
DataContainer --> RemoteContainer
DomainContainer --> DataContainer
Features --> FeatureContainer
FeatureContainer --> DomainContainer
FeatureContainer --> EntireTab
FeatureContainer --> People
FeatureContainer --> Posts
FeatureContainer --> Settings
EntireTab --> People
EntireTab --> Posts
EntireTab --> Settings
이 그래프는 현재 샘플의 실제 composition 의도를 문서용으로 수동 관리합니다.
InnoDI-DependencyGraph는 시각화보다 DAG 검증용으로 사용합니다.
Workspace.swift- 전체 모듈 project를 묶는 루트 workspace
Tuist/ProjectDescriptionHelpers- 공용 destinations / deployment targets / dependency helper
App/Project.swiftFeatures/Project.swiftCores/CoreNetwork/Project.swiftLayers/*/Project.swiftFeatures/*/Project.swiftThirdParties/*/Project.swift(SDK wrapper가 생길 때 사용)Utils/*/Project.swift
Tuist helper는 현재 두 단계로 destinations / deployment targets를 나눕니다.
- 일반 모듈:
defaultDestinations/defaultDeploymentTargets(iPhone / iPad / macOS) - shared multi-platform 모듈:
sharedModuleDestinations/sharedModuleDeploymentTargets(iPhone / iPad / macOS / tvOS / visionOS / watchOS) 실제 iOS 앱과 함께 설치되는 watchOS companion은 별도 watch app/watch extension 타깃이 있어야 하며, 현재 샘플은App프로젝트에 그 타깃을 포함합니다.
App/Sources/InnoSampleApp.swift- Composition root
AppContainer에서 InnoDI graph를 만들고 rootFeaturesscene에 전달- 샘플 analytics wrapper를 통해 앱 시작 이벤트를 기록
App/Sources/AppContainer.swiftInnoDIroot container- base URL 입력으로
NetworkTransport -> LayerContainer -> FeatureContainer순서로 조립 Layers내부의 data/domain container를 모르고, 개별 feature wiring 대신 use case를 rootFeatures에 전달AnalyticsClientconcrete 구현을 앱 composition root에서 생성Project.app(watchCompanion: ...)로 iOS companion watch app / watch extension 타깃을 함께 생성App/Project.swift에서 앱 타깃 정의
Cores/CoreNetworkInnoNetwork를 앱 공통 규칙으로 감싸는 네트워크 코어NetworkEnvironment로 base URL / app metadata / header policy source of truth 관리NetworkFactory로NetworkConfiguration, retry policy, transport 생성RequestDefinition,HeaderPolicy,NetworkFailure를 통해 request/error 정책을 명시HeaderPolicy.custom은 환경 기본 헤더를 opt-out하지만X-Request-ID는 추적을 위해 계속 자동 추가- request/response interceptor와 logger를 공통 적용
InnoNetwork 3.1.0의LowLevelNetworkClient.perform(executable:)를 소비해 concrete client 타입 의존을 줄임NetworkTransport가Remote대신InnoNetwork를 직접 다룸Cores/CoreNetwork/Project.swift에서 독립 project로 관리
Layers- composition-only framework
LayerContainer는 plain composition wrapper로 남기고,RemoteContainer -> DataContainer -> DomainContainer를 연결한 뒤 feature 입력용 use case만 외부에 노출App과 rootFeatures는 내부 조립 단계를 모르고LayerContainer.featureUseCases같은 feature-facing surface만 사용SwiftUI,InnoFlow,InnoRouter,InnoNetwork를 import 하지 않음- 현재 제약:
FeatureContainer,AppContainer같은 UI/root composition만@MainActorLayerContainer는 non-UI composition이므로 actor-agnostic 유지Domain,Data,Remote의 shared contract는 actor-agnostic 유지Features는DomainContainerconcrete를 직접 알지 않고FeatureUseCaseContaining만 소비App은RemoteContainer,DataContainer,DomainContainer를 직접 조립하지 않음
Layers/Domain- summary 모델은 도메인별
Models에 두고,DomainContainer와 도메인별 composition extension은Container아래로 분리 - repository protocol과 concrete use case 정의
- shared contract는 actor-agnostic로 유지하고, main actor는
Layers / Features / Appcomposition 경계에서만 적용 DomainContainer가 모든 repository 입력을 받고, use case는 도메인별 extension에서 computed로 제공- 현재 제약:
Domain은Datacontract만 알고Remote나CoreNetwork를 모름- summary 모델, repository protocol, use case는 도메인별 폴더에 두고, composition helper는
Container아래로 분리
- summary 모델은 도메인별
Layers/Data- repository 구현과 remote data source contract 정의
DataContainer가 모든 repository를 shared로 제공User/Post/Todo도메인 아래Models/DataSources/Repositories/Factories로 폴더를 나눠 책임을 드러냄- 현재 제약:
Data가 DTO -> Domain 매핑과 curate / empty response 정책을 담당Data는RemoteDataSourceContainingcontract만 의존하고RemoteContainerconcrete는 직접 퍼뜨리지 않음
Layers/Remote- JSONPlaceholder request definition과 remote model 매핑
User/Post/Todo도메인 아래Requests/DataSources/Factories로 폴더를 나눠 역할을 드러냄- raw 응답을 그대로 퍼뜨리지 않고, 필요한 필드만 평탄화/정규화한 DTO를 반환
- 외부 도메인 호출은
HeaderPolicy.external을 사용 Data가 정의한 remote data source contract 구현RemoteContainer가CoreNetworktransport 기반 data source를 shared로 제공InnoNetwork를 직접 import 하지 않음- 각 layer는 별도
Project.swift로 분리 - 현재 제약:
Remote는 DTO decode와 remote data source 구현만 담당하고Domain모델을 직접 만들지 않음- request 실행은
NetworkTransport가 맡고, request 타입은 순수 명세값으로 유지
Features- root feature composition framework
FeatureContainer와FeatureRootScene를 제공App이 전달한 use case로 leaf featureRouter타깃과 tab container를 조립- 외부 공개 표면은 root coordinator 중심으로 유지
- generated 파일과 test support를 제외한 production 코드는 가급적 한 파일에 한 top-level 객체만 두고, 같은 씬 안에서만 쓰는 보조 뷰는
Screen+*.swiftextension 파일로 분리 - UI 규칙:
- 메인 화면과 push destination은
*Screen, modal은*Sheetsuffix 사용 - 한 씬 안에서만 쓰는 보조 뷰와 section builder는
Screen+*.swiftextension 파일로 분리 - 재사용 가능한 공용 컴포넌트만
SampleDesignSupport로 승격
- 메인 화면과 push destination은
- Router 규칙:
- coordinator와 route 정의는
Router에 두고, navigation/modal host view는*RouteHostsuffix 사용 *RouteHost는NavigationHost,ModalHost,TabCoordinatorView를 통해 route를 실제 screen/sheet에 매핑
- coordinator와 route 정의는
Features/EntireTabFeature- 3탭 셸 (
People,Posts,Settings) InnoRouter.TabCoordinatorView사용EntireTabFeatureInterface / Logic / UI / Router / Testing / Tests로 분리EntireTabContainer는Router타깃에서 탭 coordinator를 조립
- 3탭 셸 (
Features/PeopleFeature/users호출- row 선택 시 push detail
Overview버튼으로 modal sheetPeopleFeatureInterface / Logic / UI / Router / Testing / Tests로 분리PeopleFeatureContainer는Router타깃에서 coordinator를 조립
Features/PostsFeature/posts호출- row 선택 시 push detail
Highlights버튼으로 modal sheetPostsFeatureInterface / Logic / UI / Router / Testing / Tests로 분리PostsFeatureContainer는Router타깃에서 coordinator를 조립
Features/SettingsFeature/todos호출- row 선택 시 push detail
Digest버튼으로 modal sheetSettingsFeatureInterface / Logic / UI / Router / Testing / Tests로 분리SettingsFeatureContainer는Router타깃에서 coordinator를 조립- 각 feature는 별도
Project.swift에서 다중 타깃으로 분리
ThirdParties/Analytics- 외부 SDK wrapper 예시 모듈
AnalyticsInterfaceframework에 상위 모듈이 의존할 계약을 둡니다.Analyticsstatic library에 가상의 SDK adapter 구현을 둡니다.App이 composition root로서 concrete 구현을 생성하고 앱 시작 이벤트를 기록합니다.
Project.thirdParty(...)Interfaceframework + implementation static library + tests 조합을 기본으로 제공ThirdParty는 SDK wrapper/provider adapter 전용이라는 전제를 반영합니다.
Utils/SampleDesignSupport- 로딩/에러/metric card/pill 같은 공용 UI 컴포넌트
- 샘플 내부 helper/UI support 모듈이므로
ThirdParty가 아니라Util로 분리합니다. - 독립 framework project
cd /Users/changwoo.son/Developer/InnoSquad/InnoSample
tuist generate --no-open
open InnoSample.xcworkspaceor
cd /Users/changwoo.son/Developer/InnoSquad/InnoSample
make generate
make opencd /Users/changwoo.son/Developer/InnoSquad/InnoSample
./Scripts/check-layer-boundaries.sh
xcodebuild -workspace InnoSample.xcworkspace -scheme Domain -destination 'platform=macOS' test
xcodebuild -workspace InnoSample.xcworkspace -scheme Data -destination 'platform=macOS' test
xcodebuild -workspace InnoSample.xcworkspace -scheme PeopleFeature -destination 'platform=macOS' test
xcodebuild -workspace InnoSample.xcworkspace -scheme PostsFeature -destination 'platform=macOS' test
xcodebuild -workspace InnoSample.xcworkspace -scheme SettingsFeature -destination 'platform=macOS' test
xcodebuild -workspace InnoSample.xcworkspace -scheme EntireTabFeature -destination 'platform=macOS' test
xcodebuild -workspace InnoSample.xcworkspace -scheme Layers -destination 'platform=macOS' test
xcodebuild -workspace InnoSample.xcworkspace -scheme Features -destination 'platform=macOS' test
xcodebuild -workspace InnoSample.xcworkspace -scheme InnoSampleApp -destination 'platform=macOS' test
xcodebuild -workspace InnoSample.xcworkspace -scheme InnoSampleApp -destination 'platform=macOS' buildInnoDI graph CLI도 같이 쓰려면 아래를 사용할 수 있습니다.
cd /Users/changwoo.son/Developer/InnoSquad/InnoSample
./Scripts/check-di-graph.sh validate
make verifyApp/UITests는 app 레벨 smoke test와 핵심 사용자 흐름 검증만 담당합니다.- 현재 baseline 시나리오:
- 앱 실행
- 3개 탭 전환
- People / Posts / Settings 리스트 렌더링 확인
- People detail push
- People overview modal 표시/닫기
- feature별 UI test 타깃은 두지 않고, 루트 시나리오 중심으로 유지합니다.
Layers는 현재 framework로 유지합니다.- generated artifact hygiene는
.gitignore기준으로 유지합니다.Derived/.xcodeproj/xcuserdata/.DS_Store
- 이유는
App과 rootFeatures가 모두Layers를 소비하는 구조라, static product로 두면 duplicate link 경고가 발생하기 때문입니다. - 추후
App이Layers를 직접 참조하지 않는 구조로 바뀌면 static product 복귀를 다시 검토할 수 있습니다.
Derived/,xcuserdata/,.DS_Store,.xcuserstate는 repo 관리 대상이 아닙니다..gitignore에서 이 산출물을 무시하고, boundary script와 wiring test로 실제 구조 회귀를 검증합니다.
InnoSample은 로컬 monorepo path dependency 샘플이 아니라, 릴리즈된 Inno 라이브러리를 소비하는 샘플입니다.- Inno 라이브러리 의존성 source of truth는
Tuist/Package.swift입니다. - 버전은
exact로 고정하고Tuist/Package.resolved를 함께 관리합니다. - 따라서 프레임워크를 로컬 수정해 즉시 반영하는 용도보다는, 외부 사용자 관점의 통합 예제로 보는 편이 맞습니다.
현재 확인된 빌드 상태:
InnoDI와InnoFlow모두 consumer package graph에서swift-docc-plugin을 제거한 뒤에도tuist generate,xcodebuild는 성공합니다.- SwiftPM lockfile을 새로 해석하면 기존
swift-docc-pluginidentity 경고도 사라집니다.