home / skills / kaakati / rails-enterprise-dev / swift-conventions

This skill helps you make expert Swift architecture decisions for iOS/tvOS by guiding struct vs class, concurrency, and memory management choices.

npx playbooks add skill kaakati/rails-enterprise-dev --skill swift-conventions

Review the files below or copy the command above to add this skill to your agents.

Files (2)
SKILL.md
10.3 KB
---
name: swift-conventions
description: "Expert Swift decisions Claude doesn't instinctively make: struct vs class trade-offs, @MainActor placement, async/await vs Combine selection, memory management pitfalls, and iOS-specific anti-patterns. Use when writing Swift code for iOS/tvOS apps, reviewing Swift architecture decisions, or debugging memory/concurrency issues. Trigger keywords: Swift, iOS, tvOS, actor, async, Sendable, retain cycle, memory leak, struct, class, protocol, generic"
version: "3.0.0"
---

# Swift Conventions — Expert Decisions

Expert decision frameworks for Swift choices that require experience. Claude knows Swift syntax — this skill provides the judgment calls.

---

## Decision Trees

### Struct vs Class

```
Need shared mutable state across app?
├─ YES → Class (singleton pattern, session managers)
└─ NO
   └─ Need inheritance hierarchy?
      ├─ YES → Class (UIKit subclasses, NSObject interop)
      └─ NO
         └─ Data model or value type?
            ├─ YES → Struct (User, Configuration, Point)
            └─ NO → Consider what identity means
               ├─ Same instance matters → Class
               └─ Same values matters → Struct
```

**The non-obvious trade-off**: Structs with reference-type properties (arrays, classes inside) lose copy-on-write benefits. A `struct` containing `[UIImage]` copies the array reference, not images — mutations affect all "copies."

### async/await vs Combine vs Callbacks

```
Is this a one-shot operation? (fetch user, save file)
├─ YES → async/await (cleaner, better stack traces)
└─ NO → Is it a stream of values over time?
   ├─ YES
   │  └─ Need transformations/combining?
   │     ├─ Heavy transforms → Combine (map, filter, merge)
   │     └─ Simple iteration → AsyncStream
   └─ NO → Must support iOS 14?
      ├─ YES → Combine or callbacks
      └─ NO → async/await with continuation
```

**When Combine still wins**: Multiple publishers needing `combineLatest`, `merge`, or `debounce`. Converting this to pure async/await requires manual coordination that Combine handles elegantly.

### @MainActor Placement

```
Is every public method UI-related?
├─ YES → @MainActor on class/struct
└─ NO
   └─ Does it manage UI state? (@Published, bindings)
      ├─ YES → @MainActor on class, nonisolated for non-UI methods
      └─ NO
         └─ Only some methods touch UI?
            ├─ YES → @MainActor on specific methods
            └─ NO → No @MainActor needed
```

**Critical**: `@Published` properties MUST be updated on MainActor. SwiftUI observes on main thread — background updates cause undefined behavior, not just warnings.

### TaskGroup vs async let

```
Number of concurrent operations known at compile time?
├─ YES (2-5 fixed operations) → async let
│  Example: async let user = fetchUser()
│           async let posts = fetchPosts()
│
└─ NO (dynamic count, array of IDs) → TaskGroup
   Example: for id in userIds { group.addTask { ... } }
```

**async let gotcha**: All `async let` values MUST be awaited before scope ends. Forgetting to await silently cancels the task — no error, just missing data.

---

## NEVER Do

### Memory & Retain Cycles

**NEVER** capture `self` strongly in stored closures:
```swift
// ❌ Retain cycle — ViewModel never deallocates
class ViewModel {
    var onUpdate: (() -> Void)?

    func setup() {
        onUpdate = { self.refresh() } // self → onUpdate → self
    }
}

// ✅ Break with weak capture
onUpdate = { [weak self] in self?.refresh() }
```

**NEVER** use `unowned` unless you can PROVE the reference outlives the closure. When in doubt, use `weak`. The crash from dangling `unowned` is worse than the nil-check cost.

**NEVER** forget Timer invalidation:
```swift
// ❌ Timer retains target — object never deallocates
timer = Timer.scheduledTimer(target: self, selector: #selector(tick), ...)

// ✅ Block-based with weak capture + invalidate in deinit
timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in
    self?.tick()
}
deinit { timer?.invalidate() }
```

### Concurrency

**NEVER** access `@Published` from background:
```swift
// ❌ Undefined behavior — may work sometimes, crash others
Task.detached {
    viewModel.isLoading = false // Background thread!
}

// ✅ Explicit MainActor
Task { @MainActor in
    viewModel.isLoading = false
}
```

**NEVER** use `Task { }` for fire-and-forget without understanding cancellation:
```swift
// ❌ Task inherits actor context — may block UI
func buttonTapped() {
    Task { await heavyOperation() } // Runs on MainActor!
}

// ✅ Explicit detachment for background work
func buttonTapped() {
    Task.detached(priority: .userInitiated) {
        await heavyOperation()
    }
}
```

**NEVER** assume `Task.cancel()` stops execution immediately. Cancellation is cooperative — your code must check `Task.isCancelled` or use `try Task.checkCancellation()`.

### Optionals

**NEVER** force-unwrap in production code except:
1. `@IBOutlet` — set by Interface Builder
2. `URL(string: "https://known-valid.com")!` — compile-time known strings
3. `fatalError` paths where crash is correct behavior

**NEVER** use implicitly unwrapped optionals (`var user: User!`) for regular properties. Only valid for:
- `@IBOutlet` connections
- Two-phase initialization where value is set immediately after init

### Protocol Design

**NEVER** make protocols require `AnyObject` unless you need `weak` references:
```swift
// ❌ Unnecessarily restricts to classes
protocol DataProvider: AnyObject {
    func fetchData() -> Data
}

// ✅ Only require AnyObject for delegates that need weak reference
protocol ViewModelDelegate: AnyObject { // Needed for weak var delegate
    func viewModelDidUpdate()
}
```

**NEVER** add default implementations that change protocol semantics:
```swift
// ❌ Dangerous — conformers might not override
protocol Validator {
    func validate() -> Bool
}
extension Validator {
    func validate() -> Bool { true } // Silent "always valid"
}

// ✅ Make requirement obvious or use different name
extension Validator {
    func isAlwaysValid() -> Bool { true } // Clear this is a default
}
```

---

## iOS-Specific Patterns

### Dependency Injection in ViewModels

```swift
// ✅ Protocol-based for testability
protocol UserServiceProtocol {
    func fetchUser(id: String) async throws -> User
}

@MainActor
final class UserViewModel: ObservableObject {
    @Published private(set) var user: User?
    @Published private(set) var error: Error?

    private let userService: UserServiceProtocol

    init(userService: UserServiceProtocol = UserService()) {
        self.userService = userService
    }
}
```

**Why default parameter**: Production code uses real service, tests inject mock. No container framework needed for most apps.

### Property Wrapper Selection

| Wrapper | Use When | Memory Behavior |
|---------|----------|-----------------|
| `@State` | View-local primitive/value types | View-owned, recreated on parent rebuild |
| `@StateObject` | View creates and owns the ObservableObject | Created once, survives view rebuilds |
| `@ObservedObject` | View receives ObservableObject from parent | Not owned, may be recreated |
| `@EnvironmentObject` | Shared across view hierarchy | Must be injected by ancestor |
| `@Binding` | Two-way connection to parent's state | Reference to parent's storage |

**The StateObject vs ObservedObject trap**: Using `@ObservedObject` for a locally-created object causes recreation on every view update — losing all state.

### Error Handling Strategy

```swift
// Domain-specific errors with recovery info
enum UserError: LocalizedError {
    case notFound(userId: String)
    case unauthorized
    case networkFailure(underlying: Error)

    var errorDescription: String? {
        switch self {
        case .notFound(let id): return "User \(id) not found"
        case .unauthorized: return "Please log in again"
        case .networkFailure: return "Connection failed"
        }
    }

    var recoverySuggestion: String? {
        switch self {
        case .notFound: return "Check the user ID and try again"
        case .unauthorized: return "Your session expired"
        case .networkFailure: return "Check your internet connection"
        }
    }
}
```

---

## Performance Traps

### Copy-on-Write Gotchas

```swift
// ✅ COW works — array copied only on mutation
var a = [1, 2, 3]
var b = a        // No copy yet
b.append(4)      // Now b gets its own copy

// ❌ COW broken — class inside struct
struct Container {
    var items: NSMutableArray // Reference type!
}
var c1 = Container(items: NSMutableArray())
var c2 = c1      // Both point to same NSMutableArray
c2.items.add(1)  // Mutates c1.items too!
```

### Lazy vs Computed

```swift
// lazy: Computed ONCE, stored
lazy var dateFormatter: DateFormatter = {
    let f = DateFormatter()
    f.dateStyle = .medium
    return f
}()

// computed: Computed EVERY access
var formattedDate: String {
    dateFormatter.string(from: date) // Cheap, uses cached formatter
}
```

**Rule**: Expensive object creation → `lazy`. Simple derived values → computed.

### String Performance

```swift
// ❌ O(n) for each concatenation in loop
var result = ""
for item in items {
    result += item.description // Creates new String each time
}

// ✅ O(n) total
var result = ""
result.reserveCapacity(estimatedLength)
for item in items {
    result.append(item.description)
}

// ✅ Best for joining
let result = items.map(\.description).joined(separator: ", ")
```

---

## Quick Reference

### Access Control Decision

| Level | Use When |
|-------|----------|
| `private` | Implementation detail within declaration |
| `fileprivate` | Shared between types in same file (rare) |
| `internal` | Module-internal, app code default |
| `package` | Same package, different module (Swift 5.9+) |
| `public` | Framework API, readable outside module |
| `open` | Framework API, subclassable outside module |

**Default to most restrictive**. Start `private`, widen only when needed.

### Naming Quick Check

- Types: `PascalCase` nouns — `UserViewModel`, `NetworkError`
- Protocols: `PascalCase` — capability (`-able/-ible`) or description
- Functions: `camelCase` verbs — `fetchUser()`, `configure(with:)`
- Booleans: `is/has/should/can` prefix — `isLoading`, `hasContent`
- Factory methods: `make` prefix — `makeUserViewModel()`

Overview

This skill codifies expert Swift conventions and trade-offs that experienced engineers make but tools miss. It focuses on struct vs class decisions, actor and @MainActor placement, concurrency choices (async/await vs Combine), memory and retain-cycle pitfalls, and common iOS anti-patterns. Use it to make safer, clearer architecture and runtime decisions for iOS/tvOS apps.

How this skill works

The skill inspects design intents and code patterns and recommends the most appropriate Swift constructs based on concrete criteria: shared mutable state, inheritance needs, UI threading, and the shape of asynchronous work. It provides decision trees (struct vs class, async/await vs Combine, TaskGroup vs async let), warns about gotchas (COW breaks, Timer retention, unowned vs weak), and prescribes actor isolation and property-wrapper placement for SwiftUI. It returns actionable fixes and minimal code examples.

When to use it

  • Choosing between struct and class for models, controllers, or managers
  • Deciding async/await, Combine, or AsyncStream for network and streaming flows
  • Placing @MainActor or isolating actors when mixing UI and background work
  • Reviewing and fixing retain cycles, Timer leaks, or misuse of unowned/weak
  • Designing ViewModel dependency injection and property-wrapper selection

Best practices

  • Prefer value types (struct) for immutable data unless identity or shared mutable state is required
  • Use async/await for single-shot async work; use Combine for heavy stream composition
  • Annotate only UI-affecting APIs with @MainActor; keep others nonisolated to limit contention
  • Break closure retain cycles with [weak self]; use unowned only when lifetime is guaranteed
  • Default to the most restrictive access control and widen only when necessary
  • Use lazy for expensive one-time objects and reserveCapacity or join for string-heavy loops

Example use cases

  • Deciding whether a settings model should be a struct or a class for testability and copy semantics
  • Refactoring multiple Combine publishers into async/await or keeping Combine when combineLatest is needed
  • Annotating a ViewModel with @MainActor because it publishes @Published properties observed by SwiftUI
  • Replacing a Timer target-based API with a block-based timer and invalidating in deinit to avoid leaks
  • Choosing TaskGroup to parallelize work over a dynamic list of IDs instead of async let

FAQ

When should I use unowned instead of weak?

Use unowned only when you can prove the referenced object outlives the closure; otherwise use weak to avoid dangling pointer crashes.

Can I update @Published from a background Task?

No — @Published must be updated on the MainActor. Wrap updates in Task { @MainActor in ... } or mark the type @MainActor.