Modular SwiftUI Features with Swift Packages Without Fragmenting the Domain
Putting every SwiftUI screen in a Swift package does not create modularity. A useful boundary follows a domain capability and reason to change: account, projects, payments, or search can own models, use cases, and interface adapters, while the application target composes them. If every package imports Shared, reaches into another feature’s view models, and broadcasts core events through notifications, the directory tree grew while coupling became harder to see.
Draw dependency direction first
A practical starting shape has small stable domain contracts at the bottom, feature packages in the middle, and application composition at the top. A feature may depend on domain language and necessary platform wrappers. It cannot depend upward on the application target and should not import another feature’s internal views.
AppComposition
├─ SearchFeature ─┐
├─ LibraryFeature ├─ DomainContracts
└─ AccountFeature ┘
│
PlatformServices
This diagram does not require a repository per box. One local package may contain several targets. The important part is that the manifest states dependencies and the compiler prevents crossing a boundary. When two features need to call each other, extract a domain command they both depend on or let the application coordinate instead of adding target dependencies in both directions.
Avoid a package dependency merely to reuse one convenience extension. A few lines of local code may be cheaper than connecting two change graphs. Duplication of syntax is not necessarily duplication of domain knowledge.
Keep the feature entry point small
A feature can export a root view or a function that constructs it, plus the dependency protocols it needs. Internal routes, view models, subviews, and concrete services remain internal by default. A smaller public surface preserves refactoring freedom and can improve incremental builds.
public struct SearchFeatureView: View {
private let client: any SearchClient
private let onOpenResult: (SearchResult.ID) -> Void
public init(
client: any SearchClient,
onOpenResult: @escaping (SearchResult.ID) -> Void
) {
self.client = client
self.onOpenResult = onOpenResult
}
public var body: some View {
SearchScreen(model: SearchModel(client: client), onOpen: onOpenResult)
}
}
The closure sends navigation intent to composition rather than importing a page from LibraryFeature. A larger flow can use a typed destination defined in domain language, but it should still describe a product destination instead of exposing a concrete SwiftUI or UIKit type.
Make shared mean shared semantics
Packages named Shared, Core, and Common easily become drawers for anything difficult to place. Share only a type that has the same meaning and reason to change across features, such as an immutable identifier or a defined authentication contract. An order status and a synchronization status are not one concept merely because both can be named Status.
Design tokens and generic controls may live in a UI package that imports no business model. A button accepts a label, state, and action rather than an entire Order. Put extensions beside the semantics they serve. A global View extension that knows application-specific state recreates hidden coupling.
Resources also need an owner. Keep strings, images, and fixtures in the target that defines their meaning. Access them through the package resource bundle and test localization there rather than depending on the main application’s bundle by accident.
Inject dependencies at composition
The application entry point creates real networking, persistence, and analytics services, then passes narrow protocols to features. Tests use in-memory implementations or fakes. Avoid service locators and global singletons: they make manifests appear independent while every feature shares invisible runtime state. Define a protocol near the feature that consumes it instead of asking an infrastructure module to predict one enormous interface.
Swift 6.2 concurrency is part of the contract. Values crossing targets and isolation domains need deliberate Sendable and actor choices. Do not scatter @unchecked Sendable so an old singleton can pass through every package. If composition owns a database actor, expose only the asynchronous operations a feature needs.
Verify the boundary with builds and tests
Each feature target can own logic tests and selected preview or component fixtures. The application target retains cross-feature navigation and launch tests. CI may test packages in parallel, but a full application build is still required for resources, signing, and composition. Observe incremental build time; excessive generic public APIs and extra compilation boundaries can carry cost as well.
Architecture tests or simple dependency checks can prevent forbidden imports. More importantly, code review should reject public symbols added only to reach around a boundary. If a feature repeatedly needs another feature’s internals, the domain seam may be in the wrong place.
Migrate in vertical slices. Choose a feature with few dependencies, define its entry point, move code, remove the old path, and then continue. Creating ten empty packages first leaves two structures alive for too long. Every step should build, test, and remain releaseable.
Swift Package Manager enforces boundaries; it does not choose them. Split by domain capability, keep dependencies one-way, return navigation to composition, constrain public API, and allow shared modules to contain only genuinely shared language. The measure of success is whether a feature can be understood, tested, and replaced independently—not the number of folders in the project navigator.