Architecture

How Cinematic is put together and why. Read this before changing structure; read MVI.md for the pattern inside each feature.

The dependency rule

Clean Architecture reduces to one sentence: source-code dependencies point only inward, toward policy. In this project “inward” means toward CinematicDomain, and the rule is not a convention — it is enforced by the package graph. A build, not a review comment, fails when someone breaks it.

Layer Package May import Holds
Pattern MVIKit nothing Store, Reducer, Effect, Send, LoadingPhase
Domain CinematicDomain nothing Entities, use cases, repository protocols, MovieError
Data CinematicData Domain APIClient, DTOs, mappers, repositories, caching
Design CinematicDesign nothing DesignSystem tokens, reusable components
Presentation CinematicPresentation Domain, MVIKit, Design Reducers, views, skeletons, strings
App Cinematic target everything Composition root, navigation, About

Two absences carry most of the meaning:

The domain layer

Entities are small and honest: Movie (with a typed Movie.ID so an identifier can’t be confused with any other string), MovieDetails (an aggregate over Movie for the fields only lookup provides), DiscoverCatalog, Price (a Decimal, never a Double).

Use cases own business rules so neither reducers nor repositories grow them:

Errors are domain vocabulary. MovieError is Hashable and travels through typed throws (async throws(MovieError)) from repository to reducer, which keeps feature states Equatable and reducer tests value-for-value.

The data layer

Everything Apple-specific stops here.

StoredMovie is the persistence DTO shared by the cache and favorites. Domain entities stay Codable-free; a storage schema change is a data-layer migration, not a domain edit.

The composition root

AppDependencies (in the app target) is the only type that constructs concrete repositories:

static func live() -> AppDependencies {
    AppDependencies(
        catalog: CachedMovieCatalogRepository(
            wrapping: ITunesMovieCatalogRepository(client: APIClient()),
        ),
        favorites: UserDefaultsFavoritesRepository(),
    )
}

preview() swaps both repositories for in-memory doubles from CinematicPresentation/PreviewSupport — the same doubles drive Xcode previews, reducer tests, and the -uiTestMode launch argument. One fixture set for the whole project.

Dependencies reach views through a SwiftUI @Entry environment value, and the factories pull from it when building screens.

The coordinator pattern lives entirely in the app target: AppCoordinator owns one TabCoordinator per tab; Screen, Sheet, and Cover enums name every destination; factories map them to views. CoordinatedNavigationStack is the only place .navigationDestination, .sheet, and .fullScreenCover appear.

Feature views are deliberately navigation-agnostic. They emit events — onSelectMovie, onPlayTrailer — and the factories translate those into coordinator calls. That keeps every feature previewable and testable in isolation, and it means “where does tapping a movie lead?” has exactly one answer, in one file.

Deep links follow the same path: cinematic://movie/<id> lands in AppCoordinator.handle(_:), which selects a tab and pushes a screen like any other caller.

Concurrency

The isolation story is deliberate and layered:

Trade-offs, named

FAQ

Why five packages instead of folders? Folders ask politely; manifests enforce. The compiler rejecting import CinematicData inside a reducer is the entire point.

Why does MVIKit not depend on the app at all? It is the reusable pattern, not the app. Copy the package into another project and it works unchanged.

Where are the view models? There are none. The Store + Reducer pair plays that role with a stricter contract: state only changes in reduce, and every change has a named cause.

Why is CinematicDesign not imported by the domain? Tokens are presentation policy. The domain doesn’t know the app has a screen.