8.8 KiB
Package Architecture
How to decompose Swift code into focused SwiftPM targets within the repository's
single active package manifest. In a package repository that manifest is
Package.swift at the repository root. In a project monorepo that manifest is
Packages/Package.swift.
The pattern: single-responsibility SwiftPM targets with explicit, unidirectional dependencies, all in one active manifest. Each target has one cohesive job and declares exactly what it depends on. This buys isolated compilation, parallel builds, a clear dependency graph, and targets you can test and lift out in isolation.
What this covers
Add a new target to the active package manifest when one of these is true:
- A part of TileKit has become a clearly separable responsibility (a parser, a transport, a renderer) used in more than one place.
- Compilation is slow and a large stable chunk would benefit from being its own target.
- A second front-door appears (an app target, a server, a second CLI verb set).
Core rules
Rule 1: Single responsibility per target
Create targets with one clear purpose:
- One well-defined responsibility per target.
- Do not mix concerns (UI plus networking, models plus API client).
- The name communicates the purpose.
- Independently buildable and testable.
Rule 2: Explicit dependency declaration
Declare dependencies explicitly in Package.swift:
- List every dependency in the manifest.
- No reliance on implicit or transitive dependencies.
- Minimize cross-target dependencies.
- Prefer unidirectional dependency flow.
Rule 3: Target granularity
Prefer smaller, focused targets over larger ones:
- Single-file targets are fine when the unit is genuinely standalone (a color foundation, a single protocol, a single transport).
- Separate by role, not by topical bundling: foundation primitives, infrastructure, protocols, middleware, services, per-feature or per-verb operation targets, front-door binaries.
- Each cohesive responsibility (a feature, a CLI verb, a middleware, a transport, a service) gets its own target.
Rule 4: Naming conventions
Use a consistent scheme. Patterns that travel across project shapes:
- Shared foundation:
Shared*for cross-target value types, models, utilities, configuration (for exampleSharedModels,SharedProtocols). - Core infrastructure:
Core*or a descriptive single-purpose name for the foundation domain (protocols, parsers, transports). - Per-feature or per-verb operation targets: one target per user-facing flow (in a UI app) or one per CLI verb (in a CLI). Name after the responsibility, not after a role suffix.
- Service targets:
*Service, or aServicesaggregator for cross-layer read/write services consumed by multiple front-doors. - Aggregators: umbrella targets only when the umbrella adds real value (a preview host, an all-features aggregator for app composition). Do not add one by default.
Keep the established public library name as the public anchor and grow new targets around it rather than renaming it.
Rule 5: Layer architecture (unidirectional)
Organize targets into layers ordered from foundation at the bottom to front doors at the top. Imports and manifest dependencies point only from a higher-level consumer to a lower-level provider. The exact number of layers varies per project; the constants are:
- Foundation (bottom): shared value types, models, primitive utilities, logging.
- Infrastructure: protocols, persistence, networking, file I/O, parsers, transports.
- Domain: services, business logic, per-feature or per-verb operation targets.
- Presentation (UI projects only): component targets, design-system targets, screens.
- Front-door (top): binary targets, apps, CLIs, preview hosts, test harnesses.
A lower layer MUST NOT import or declare a dependency on a higher layer; that is a
back-edge. Build order and dependent propagation naturally travel from the lower
provider upward, but the declared depends on relation points downward. The
active layer instantiation lives in the repository's active Package.swift.
When to create a new target
Decision tree
Need to add new code?
├─ Is it a reusable domain model or value type?
│ └─ YES → add to the shared foundation target (e.g. SharedModels)
│
├─ Is it a cross-target protocol seam?
│ └─ YES → add to SharedProtocols (see shared-protocols.md)
│
├─ Is it a complete user-facing feature or a distinct CLI verb?
│ └─ YES → create a new feature / verb target
│
├─ Is it infrastructure (transport, parser, persistence, middleware)?
│ └─ YES → create or extend the matching infrastructure target
│
└─ Still unsure?
└─ Ask: "Could this be reused or tested in isolation?"
├─ YES → create a new target
└─ NO → add to the most specific existing target
Create a new target when
- It is a complete feature or distinct verb (a self-contained user-facing flow, or one CLI command's logic).
- It is reusable infrastructure that can be tested in isolation and used by more than one consumer (a caching middleware, a transport).
- It wraps a third-party integration, isolating that external dependency behind a seam.
- It is platform-specific code that is cleaner separated (iOS-only vs macOS-only).
- It is a build-optimization win: large, stable, or expensive-to-compile code (for example generated code) that rarely changes.
Do NOT create a new target when
- It is single use, only used by one feature and tightly coupled to it. Add it to that feature's target.
- It is a trivial helper of one or two functions with no external dependencies. Add it to an existing utility target.
- It is temporary (a spike or proof of concept). Keep it in the feature until it proves stable.
Decision checklist
Before creating a new target:
- One clear responsibility
- Name follows the conventions above
- Dependencies are minimal and explicit
- No circular dependency introduced
- Can be built and tested in isolation
- Fits a single architectural layer
- Test target created alongside the source target
- Product registered in the
Package.swiftproducts array when the target is public or executable - Imports and declared dependencies point only to the same or a lower layer
Before adding to an existing target:
- The new code shares a responsibility with what is there
- No better-suited target exists
- You are not creating a "god target" with mixed concerns
- You are not introducing unwanted dependencies onto the target's consumers
Before modifying Package.swift:
- Used a closure-with-local-variables pattern for the
deps,allProducts, andtargetsarrays rather than one giant inline array - Grouped dependencies by purpose, products by platform, targets by layer, with comment headers
- Separated platform-specific code with
#if os(iOS) || os(macOS)in the manifest - Did NOT use
#if canImport(UIKit)inPackage.swift(the manifest evaluates it lazily and it breaks Linux builds;#if os()is parse-time and safe). In regular Swift source files,#if canImport(UIKit)/#if canImport(AppKit)is the correct idiom. - Each target/product/dependency has a descriptive variable name
- Trailing commas in all arrays
.when(platforms:)applied for platform-specific dependencies within targets
If you add a font or resource target:
- Used
.process()for resources, never.copy() - Accessed resources via
Bundle.module, neverBundle.main - Used
#if canImport(UIKit)/#if canImport(AppKit)for platform imports - The target has zero dependencies (resources are a foundation-layer concern)
Example layout (illustrative, not normative)
As Tiledown grows targets within its single package, the layout might look like this. Your actual target list will differ.
Sources/ # or Packages/Sources/ in a project monorepo
├── Foundation Layer (0 internal dependencies)
│ ├── TileCore # core value types, no dependencies
│ └── SharedProtocols # cross-target protocol seams
│
├── Infrastructure Layer
│ ├── TileParser # depends: TileCore
│ └── TileTransport # depends: SharedProtocols
│
├── Domain Layer
│ └── TileKit # the main library; depends: TileCore, TileParser
│
└── Front-door (not under Sources/)
└── tile-down # the CLI executable; wires the concretes
Related rules
- package-structure.md: repository layout and the
Package.swiftmanifest shape - package-import-contract.md: what each target may import
- shared-protocols.md: the cross-target protocol-seam target
- dependency-injection.md: the DI principles these layers serve