A collection of best practices, patterns, and solutions gathered from building production Swift packages.
- Package Structure
- Swift 6 & Strict Concurrency
- CLI Tools with ArgumentParser
- Build Plugins
- DocC Documentation
- Universal Binary Distribution
- Code Quality Tooling
- Static Analysis Patterns
- Testing Strategies
- Performance Optimization
- GitHub Actions CI/CD
MyPackage/
├── Package.swift
├── Sources/
│ ├── MyPackageCore/ # Core library (no dependencies)
│ │ ├── Models/
│ │ ├── Visitors/
│ │ └── MyPackageCore.docc/ # DocC catalog
│ ├── FeatureModule/ # Feature-specific module
│ └── MyPackageCLI/ # CLI executable
├── Plugins/
│ ├── MyBuildPlugin/
│ └── MyCommandPlugin/
├── Tests/
│ ├── MyPackageCoreTests/
│ └── FeatureModuleTests/
├── .swiftlint.yml
├── .swiftformat
├── CLAUDE.md # AI assistant instructions
└── README.md
// swift-tools-version: 6.2
import PackageDescription
let package = Package(
name: "MyPackage",
platforms: [
.macOS(.v15),
.iOS(.v18)
],
products: [
.library(name: "MyPackageCore", targets: ["MyPackageCore"]),
.executable(name: "mytool", targets: ["MyPackageCLI"]),
.plugin(name: "MyBuildPlugin", targets: ["MyBuildPlugin"]),
],
dependencies: [
.package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.7.0"),
.package(url: "https://github.com/swiftlang/swift-syntax.git", from: "602.0.0"),
.package(url: "https://github.com/swiftlang/swift-docc-plugin.git", from: "1.4.5"),
],
targets: [
.target(
name: "MyPackageCore",
dependencies: [
.product(name: "SwiftSyntax", package: "swift-syntax"),
.product(name: "SwiftParser", package: "swift-syntax"),
],
swiftSettings: [
.swiftLanguageMode(.v6),
.enableExperimentalFeature("StrictConcurrency"),
]
),
// ... more targets
]
)- Default to
@MainActorfor UI and ViewModels - Use
actorfor shared mutable state - All cross-boundary types must be
Sendable - No GCD - use
Task,TaskGroup,AsyncStream
// Value types are automatically Sendable
struct SourceLocation: Sendable {
let file: String
let line: Int
let column: Int
}
// Reference types need explicit conformance
final class Configuration: Sendable {
let minConfidence: Double // Immutable = Sendable
init(minConfidence: Double) {
self.minConfidence = minConfidence
}
}
// Actors are inherently Sendable
actor AnalysisEngine {
private var cache: [String: AnalysisResult] = [:]
func analyze(file: String) async throws -> AnalysisResult {
if let cached = cache[file] { return cached }
let result = try await performAnalysis(file)
cache[file] = result
return result
}
}// Process files in parallel with controlled concurrency
func processFiles(_ files: [URL]) async throws -> [Result] {
try await withThrowingTaskGroup(of: Result.self) { group in
for file in files {
group.addTask {
try await self.process(file)
}
}
var results: [Result] = []
for try await result in group {
results.append(result)
}
return results
}
}import ArgumentParser
@main
struct MyCLI: AsyncParsableCommand {
static let configuration = CommandConfiguration(
commandName: "mytool",
abstract: "A tool for doing things",
version: "1.0.0",
subcommands: [Analyze.self, Format.self],
defaultSubcommand: Analyze.self
)
}struct Analyze: AsyncParsableCommand {
static let configuration = CommandConfiguration(
abstract: "Analyze source files"
)
@Argument(help: "Path to analyze")
var path: String
@Option(name: .shortAndLong, help: "Output format")
var format: OutputFormat = .text
@Option(name: .long, parsing: .upToNextOption, help: "Types to detect")
var types: [DetectionType] = DetectionType.allCases
@Flag(name: .long, help: "Use sensible defaults")
var sensibleDefaults = false
func run() async throws {
// Implementation
}
}
// Type-safe enum arguments with validation
enum OutputFormat: String, ExpressibleByArgument, CaseIterable {
case text, json, xcode
}
enum DetectionType: String, ExpressibleByArgument, CaseIterable {
case exact, near, semantic
// Custom init for better error messages
init?(argument: String) {
guard let value = Self(rawValue: argument.lowercased()) else {
return nil
}
self = value
}
static var allValueStrings: [String] {
allCases.map(\.rawValue)
}
}// For options that can be specified multiple times:
// mytool --exclude "Tests/**" --exclude "Fixtures/**"
@Option(name: .long, parsing: .upToNextOption, help: "Paths to exclude")
var excludePaths: [String] = []import PackagePlugin
@main
struct MyBuildPlugin: BuildToolPlugin {
func createBuildCommands(
context: PluginContext,
target: Target
) async throws -> [Command] {
guard let sourceTarget = target as? SourceModuleTarget else {
return []
}
let tool = try context.tool(named: "mytool")
let sourceFiles = sourceTarget.sourceFiles
.filter { $0.url.pathExtension == "swift" }
.map(\.url)
guard !sourceFiles.isEmpty else { return [] }
let outputDir = context.pluginWorkDirectoryURL
.appendingPathComponent("output")
return [
.prebuildCommand(
displayName: "MyTool \(target.name)",
executable: tool.url,
arguments: ["analyze"] + sourceFiles.map(\.path),
outputFilesDirectory: outputDir
)
]
}
}Build plugins should check PATH before downloading:
/// Check PATH before downloading binaries
private func findInPath(_ executable: String) -> URL? {
let searchPaths = [
"/opt/homebrew/bin",
"/usr/local/bin",
"/usr/bin",
"/bin",
]
let envPath = ProcessInfo.processInfo.environment["PATH"] ?? ""
let allPaths = searchPaths + envPath.split(separator: ":").map(String.init)
for dir in allPaths {
let fullPath = URL(fileURLWithPath: dir).appendingPathComponent(executable)
if FileManager.default.isExecutableFile(atPath: fullPath.path) {
return fullPath
}
}
return nil
}
// Usage in plugin
func ensureTool(in workDir: URL) async throws -> URL {
// First check if tool is installed system-wide
if let systemPath = findInPath("mytool") {
Diagnostics.remark("Using system-installed mytool at \(systemPath.path)")
return systemPath
}
// Fall back to downloading
return try await downloadTool(to: workDir)
}SwiftLint may fail in CI with cache permission errors. Use these flags:
// In your build plugin
var arguments = [
"lint",
"--quiet",
"--no-cache", // Prevents cache permission errors in CI sandboxes
"--force-exclude", // Ensures exclusions work with direct file paths
"--reporter", "xcode"
]
// Add config file if found
if let configPath = findConfigFile(in: packageDir) {
arguments += ["--config", configPath.path]
}
// Add source files
arguments += sourceFiles.map(\.path)Key flags:
--no-cache: Prevents SwiftLint from writing to cache directories (may be read-only in CI)--force-exclude: Ensures.swiftlint.ymlexclusions are respected with direct file arguments
import PackagePlugin
@main
struct MyCommandPlugin: CommandPlugin {
func performCommand(
context: PluginContext,
arguments: [String]
) async throws {
let tool = try context.tool(named: "mytool")
let process = Process()
process.executableURL = tool.url
process.arguments = arguments
try process.run()
process.waitUntilExit()
guard process.terminationStatus == 0 else {
throw PluginError.commandFailed(process.terminationStatus)
}
}
}Sources/MyPackage/MyPackage.docc/
├── MyPackage.md # Landing page
├── GettingStarted.md # Quick start guide
├── Articles/
│ ├── CoreConcepts.md
│ ├── AdvancedUsage.md
│ └── CIIntegration.md
└── Resources/
└── diagram.png
# ``MyPackage``
Brief description of your package.
## Overview
Longer description with key features:
- **Feature 1**: Description
- **Feature 2**: Description
- **Feature 3**: Description
## Topics
### Essentials
- <doc:GettingStarted>
- <doc:CoreConcepts>
### Configuration
- <doc:AdvancedUsage>
- <doc:CIIntegration>
### Core Types
- ``MainType``
- ``Configuration``
- ``Result``# Local preview
swift package --disable-sandbox preview-documentation --target MyPackage
# Generate for static hosting
swift package --allow-writing-to-directory ./docs \
generate-documentation \
--target MyPackage \
--disable-indexing \
--transform-for-static-hosting \
--hosting-base-path MyPackage \
--output-path ./docsdocs:
runs-on: macos-15
steps:
- uses: actions/checkout@v4
- run: sudo xcode-select -s /Applications/Xcode_26.1.1.app
- run: |
swift package --allow-writing-to-directory ./docs \
generate-documentation \
--target MyPackage \
--transform-for-static-hosting \
--hosting-base-path MyPackage \
--output-path ./docs
- uses: actions/upload-pages-artifact@v3
with:
path: ./docs
deploy:
needs: docs
runs-on: ubuntu-latest
environment:
name: github-pages
steps:
- uses: actions/deploy-pages@v4# Build for each architecture
swift build -c release --arch arm64
mv .build/release/mytool .build/release/mytool-arm64
swift build -c release --arch x86_64
mv .build/release/mytool .build/release/mytool-x86_64
# Create universal binary
lipo -create -output .build/release/mytool \
.build/release/mytool-arm64 \
.build/release/mytool-x86_64
# Verify
lipo -info .build/release/mytool
# Output: Architectures in the fat file: mytool are: x86_64 arm64- name: Build Universal Binary
run: |
swift build -c release --arch arm64
mv .build/release/mytool .build/release/mytool-arm64
swift build -c release --arch x86_64
mv .build/release/mytool .build/release/mytool-x86_64
lipo -create -output .build/release/mytool \
.build/release/mytool-arm64 \
.build/release/mytool-x86_64
- name: Package
run: |
VERSION="${{ needs.prepare.outputs.version }}"
mkdir release
for ARCH in universal arm64 x86_64; do
if [[ "$ARCH" == "universal" ]]; then
cp .build/release/mytool release/mytool
else
cp .build/release/mytool-$ARCH release/mytool
fi
tar -C release -czvf "release/mytool-${VERSION}-macos-${ARCH}.tar.gz" mytool
done
cd release && shasum -a 256 *.tar.gz > checksums.txt# Enabled rules
opt_in_rules:
- array_init
- closure_spacing
- contains_over_filter_count
- empty_collection_literal
- explicit_init
- first_where
- last_where
- modifier_order
- override_in_extension
- pattern_matching_keywords
- prefer_self_in_static_references
- private_action
- sorted_first_last
- toggle_bool
- unneeded_parentheses_in_closure_argument
- vertical_whitespace_closing_braces
- yoda_condition
# Disabled rules
disabled_rules:
- todo
- trailing_comma
# Configuration
line_length:
warning: 120
error: 150
ignores_comments: true
type_body_length:
warning: 300
error: 500
file_length:
warning: 500
error: 1000
identifier_name:
min_length: 2
excluded:
- id
- x
- y
- i
- j
# Exclusions
excluded:
- .build
- Plugins
- Tests/Fixtures# Format options
--indent 4
--indentcase false
--trimwhitespace always
--stripunusedargs closure-only
--maxwidth 120
--wraparguments before-first
--wrapparameters before-first
--wrapcollections before-first
# Rules
--enable isEmpty
--enable sortImports
--enable wrapMultilineStatementBraces
--enable blankLineAfterImports
--disable redundantSelf
--disable trailingCommas
# Exclusions
--exclude .build,Plugins
import SwiftSyntax
import SwiftParser
final class DeclarationCollector: SyntaxVisitor {
private(set) var declarations: [Declaration] = []
override func visit(_ node: FunctionDeclSyntax) -> SyntaxVisitorContinueKind {
let name = node.name.text
let location = sourceLocation(of: node)
declarations.append(Declaration(
name: name,
kind: .function,
location: location
))
return .visitChildren
}
override func visit(_ node: ClassDeclSyntax) -> SyntaxVisitorContinueKind {
let name = node.name.text
declarations.append(Declaration(
name: name,
kind: .class,
location: sourceLocation(of: node)
))
return .visitChildren
}
private func sourceLocation(of node: some SyntaxProtocol) -> SourceLocation {
let position = node.positionAfterSkippingLeadingTrivia
return SourceLocation(
offset: position.utf8Offset,
line: 0, // Calculate from source
column: 0
)
}
}
// Usage
func analyze(source: String) -> [Declaration] {
let sourceFile = Parser.parse(source: source)
let collector = DeclarationCollector(viewMode: .sourceAccurate)
collector.walk(sourceFile)
return collector.declarations
}final class ScopeTracker: SyntaxVisitor {
private var scopeStack: [Scope] = []
var currentScope: Scope? { scopeStack.last }
override func visit(_ node: ClassDeclSyntax) -> SyntaxVisitorContinueKind {
scopeStack.append(Scope(name: node.name.text, kind: .class))
return .visitChildren
}
override func visitPost(_ node: ClassDeclSyntax) {
scopeStack.removeLast()
}
override func visit(_ node: FunctionDeclSyntax) -> SyntaxVisitorContinueKind {
scopeStack.append(Scope(name: node.name.text, kind: .function))
return .visitChildren
}
override func visitPost(_ node: FunctionDeclSyntax) {
scopeStack.removeLast()
}
}import XCTest
@testable import MyPackageCore
final class AnalyzerTests: XCTestCase {
func testDetectsUnusedFunction() async throws {
let source = """
func unused() {}
func used() { print("hello") }
used()
"""
let analyzer = Analyzer()
let results = try await analyzer.analyze(source: source)
XCTAssertEqual(results.count, 1)
XCTAssertEqual(results[0].name, "unused")
}
}final class FixtureTests: XCTestCase {
func testComplexScenario() async throws {
let fixtureURL = Bundle.module.url(
forResource: "ComplexClass",
withExtension: "swift",
subdirectory: "Fixtures"
)!
let source = try String(contentsOf: fixtureURL)
let results = try await analyzer.analyze(source: source)
// Assertions
}
}swift test --parallelfunc readFile(_ url: URL) throws -> String {
let data = try Data(contentsOf: url, options: .mappedIfSafe)
guard let string = String(data: data, encoding: .utf8) else {
throw FileError.invalidEncoding
}
return string
}struct LazyFileProcessor {
let files: [URL]
func process() -> some Sequence<Result> {
files.lazy.compactMap { file in
try? processFile(file)
}
}
}actor AnalysisCache {
private var cache: [URL: AnalysisResult] = [:]
private var fileHashes: [URL: String] = [:]
func get(_ url: URL) async throws -> AnalysisResult {
let currentHash = try computeHash(url)
if let cached = cache[url], fileHashes[url] == currentHash {
return cached
}
let result = try await analyze(url)
cache[url] = result
fileHashes[url] = currentHash
return result
}
}Avoid redundant installation messages:
- name: Ensure Linting Tools
run: |
command -v swiftlint >/dev/null 2>&1 || brew install swiftlint
command -v swiftformat >/dev/null 2>&1 || brew install swiftformatNote: Use CodeQL Action v4 (v3 deprecated December 2026)
- uses: github/codeql-action/init@v4
with:
languages: swift
build-mode: manual
- run: swift build -c release --arch arm64
- uses: github/codeql-action/analyze@v4- uses: actions/cache@v4
with:
path: |
.build
~/Library/Developer/Xcode/DerivedData
key: spm-${{ runner.os }}-${{ hashFiles('Package.resolved') }}| Task | Solution |
|---|---|
| Validate CLI arguments | Use ExpressibleByArgument enums |
| Share code between modules | Create a Core module with no external deps |
| Document API | Use DocC catalog with <doc:Article> links |
| Distribute binaries | Build universal with lipo |
| Ensure thread safety | Use actor for shared state |
| Parse Swift code | Use SwiftSyntax SyntaxVisitor |
| Run in CI | Use command -v check before brew install |
| Fix SwiftLint CI cache errors | Use --no-cache --force-exclude flags |
| Generate docs | Use swift-docc-plugin |
| Security scanning | Use CodeQL Action v4 |
Last updated: December 2025 Generated with Claude Code